Compare commits

..

311 Commits
v2.0.6 ... dev

Author SHA1 Message Date
Flaminel
d9341ff441 fixed readme 2025-06-27 15:26:37 +03:00
Flaminel
555be79a7a fixed docs favicon 2025-06-27 15:13:15 +03:00
Flaminel
66be8aaa32 removed flmorg references 2025-06-27 15:07:55 +03:00
Flaminel
737b683285 fixed release workflow 2025-06-27 14:56:41 +03:00
Flaminel
c0e94733cd removed test directory 2025-06-27 14:52:31 +03:00
Flaminel
5843ec1d15 changed docs #8 2025-06-27 14:51:06 +03:00
Flaminel
51c968fb2d changed docs #7 2025-06-27 14:29:00 +03:00
Flaminel
ea50a7a535 changed docs #6 2025-06-27 14:20:24 +03:00
Flaminel
b1477704b6 changed docs #5 2025-06-27 13:21:14 +03:00
Flaminel
04d287e1e0 hidden username for deluge 2025-06-26 23:03:21 +03:00
Flaminel
59a5eea684 fixed docs tag location 2025-06-26 22:47:15 +03:00
Flaminel
2078eab054 changed docs #4 2025-06-26 22:35:56 +03:00
Flaminel
3278a24931 changed docs #3 2025-06-26 20:00:22 +03:00
Flaminel
f9b83cf3b2 changed docs #2 2025-06-26 19:37:34 +03:00
Flaminel
54997b184c changed docs #1 2025-06-26 16:27:08 +03:00
Flaminel
2e7d89e1c5 fixed frontend error handling 2025-06-26 12:13:38 +03:00
Flaminel
68c9d41f5f fixed ignored downloads description 2025-06-25 20:17:00 +03:00
Flaminel
a9503bbca3 fixed blocklist reload time 2025-06-25 20:14:45 +03:00
Flaminel
07553131a0 try fix macos installers #4 2025-06-25 16:58:40 +03:00
Flaminel
b21bab2a8a try fix macos installers #3 2025-06-25 15:35:29 +03:00
Flaminel
c9e033c9d1 fixed port configuration 2025-06-25 13:35:02 +03:00
Flaminel
a26fd1da5d removed usenet type 2025-06-24 21:28:38 +03:00
Flaminel
d904240aa8 try fix macos installers #2 2025-06-24 20:56:32 +03:00
Flaminel
4f066c3f2a try fix macos installers 2025-06-24 20:40:37 +03:00
Flaminel
84bee5f63a fixed download cleaner reset 2025-06-24 20:40:23 +03:00
Flaminel
8d7b8e5848 fixed cache strikes not being reset when dry run is disabled 2025-06-24 19:27:34 +03:00
Flaminel
8115205158 fixed frontend name 2025-06-24 19:08:26 +03:00
Flaminel
ada2b9fa4a try fix installers and removed default configuration file 2025-06-23 23:15:40 +03:00
Flaminel
222c21a85e revert base path validator 2025-06-23 21:01:42 +03:00
Flaminel
d6cea762eb fixed macos installer 2025-06-23 19:57:44 +03:00
Flaminel
f9d096d76a fixed base path validator 2025-06-23 19:57:38 +03:00
Flaminel
ee89570cad fixed executable build 2025-06-23 19:44:03 +03:00
Flaminel
42145e58eb fixed naming 2025-06-23 19:22:51 +03:00
Flaminel
0cda75157e fixed config for installers and icons 2025-06-23 19:09:57 +03:00
Flaminel
4ef223b0cc removed debug symbols from app build 2025-06-23 18:48:00 +03:00
Flaminel
0df1fd0d19 fixed release workflows 2025-06-23 18:36:44 +03:00
Flaminel
7409ddeb33 removed windsurf 2025-06-23 17:51:10 +03:00
Flaminel
f46702f073 fixed missing port when not in docker 2025-06-23 17:51:01 +03:00
Flaminel
9f22db9cad updated input description 2025-06-23 15:21:35 +03:00
Flaminel
bbd2052086 fixed queue cleaner input states 2025-06-23 15:20:08 +03:00
Flaminel
8436f93727 fixed content blocker input states 2025-06-23 15:20:01 +03:00
Flaminel
8c020d5aa5 fixed download cleaner input states 2025-06-23 15:17:29 +03:00
Flaminel
cd36a32b91 fixed column value type 2025-06-23 01:51:20 +03:00
Flaminel
9e43ce76f6 changed settings page layout 2025-06-22 22:10:41 +03:00
Flaminel
a068b93458 fixed blocklist input width 2025-06-22 22:09:12 +03:00
Flaminel
96dc8bec51 fixed unlinked categories validation 2025-06-22 22:03:42 +03:00
Flaminel
910633a413 added download cleaner pop-up 2025-06-22 21:51:02 +03:00
Flaminel
823b9fec3f added general settings pop-up 2025-06-22 21:50:48 +03:00
Flaminel
b99cbd0d6e reorganized content blocker settings 2025-06-22 12:06:25 +03:00
Flaminel
534a8a9c66 reorganized download cleaner settings 2025-06-22 12:06:16 +03:00
Flaminel
0473d14462 fixed setting message 2025-06-22 04:55:56 +03:00
Flaminel
febb9c4432 fixed some settings 2025-06-22 04:53:33 +03:00
Flaminel
0933b99cea fixed encryption key seeding 2025-06-22 02:12:18 +03:00
Flaminel
20483fab9a fix unlink categories not being sent 2025-06-22 01:53:35 +03:00
Flaminel
60f96589cd fixed general setting HTTPS description 2025-06-22 00:45:23 +03:00
Flaminel
eec0cb9605 fixed general settings dropdowns 2025-06-22 00:08:20 +03:00
Flaminel
20fe4c5b3f fixed cleanup settings 2025-06-21 23:51:47 +03:00
Flaminel
8d7b207181 fixed queue cleaner and download cleaner layout 2025-06-21 20:29:13 +03:00
Flaminel
8f34bdc780 fixed port configuration and logs 2025-06-21 20:20:20 +03:00
Flaminel
a61a10af8f updated support section 2025-06-21 19:52:47 +03:00
Flaminel
17c37e8eef reorganized sidebar 2025-06-21 19:43:02 +03:00
Flaminel
bfbd2c16dd fixed support component icons; removed p-card 2025-06-21 16:40:20 +03:00
Flaminel
eef21ac734 added huntarr link 2025-06-21 16:28:01 +03:00
Flaminel
978b1c9ade added makefile 2025-06-21 16:16:29 +03:00
Flaminel
5ea1361832 removed enabled per arr config and added enabled per arr instance 2025-06-21 16:16:21 +03:00
Flaminel
861c74e452 fixed entrypoint to create user and group if they don't exist 2025-06-21 02:12:49 +03:00
Flaminel
6f437de698 added support card 2025-06-21 02:12:12 +03:00
Flaminel
2cdbc938fd fixed qbit label name 2025-06-20 23:13:41 +03:00
Flaminel
26d98f815f added support for PUID, PGID and UMASK 2025-06-20 23:06:25 +03:00
Flaminel
1a7e86aca4 removed https redirection 2025-06-20 21:12:59 +03:00
Flaminel
4b2aa6c4f6 Merge branch 'main' into dev 2025-06-20 15:00:40 +03:00
Flaminel
2dcd495da7 fixed default base path 2025-06-20 14:57:20 +03:00
Flaminel
38b7d1d4bb fixed content blocker 2025-06-20 14:39:00 +03:00
Flaminel
c109b15ec1 added content blocker back 2025-06-20 12:06:48 +03:00
Flaminel
1cc749243c fixed base path and port 2025-06-20 01:32:05 +03:00
Flaminel
a7c8f37bed fixed job enabling failing 2025-06-19 00:08:06 +03:00
Flaminel
65d0d5188d gitignore 2025-06-18 23:53:49 +03:00
Flaminel
15b0e4218d fixed settings page 2025-06-18 23:38:42 +03:00
Flaminel
de7e7d244b fixed initial docker build 2025-06-18 23:31:49 +03:00
Flaminel
7a8cdbb354 fixed frontend build 2025-06-18 23:02:52 +03:00
Flaminel
4c5e4e95d9 try fix Dockerfile 2025-06-18 21:59:14 +03:00
Flaminel
5881c24ff3 moved frontend 2025-06-18 21:59:04 +03:00
Flaminel
b323235227 moved backend 2025-06-18 21:58:48 +03:00
Flaminel
ee0f915aaf fixed some icons 2025-06-18 18:17:21 +03:00
Flaminel
8883a2ca3f fixed notifications page layout 2025-06-18 18:00:16 +03:00
Flaminel
bbfde4bb17 added notifications endpoint 2025-06-18 17:48:50 +03:00
Flaminel
4d8d3ea732 fixed mobile menu bar 2025-06-17 18:49:47 +03:00
Flaminel
e9718c3a66 fixed tooltip positions 2025-06-17 18:47:34 +03:00
Flaminel
3e8fb01f44 fixed cron validations 2025-06-17 18:44:49 +03:00
Flaminel
e5d7d8ed12 reorganized project 2025-06-17 18:21:18 +03:00
Flaminel
d76216665b try #1 for deployment 2025-06-17 14:41:55 +03:00
Flaminel
1255d0a50a fixed serialization options 2025-06-17 10:36:24 +03:00
Flaminel
cb53ee09da removed commented code 2025-06-16 23:59:18 +03:00
Flaminel
f2622b129d removed TODO comment 2025-06-16 23:58:19 +03:00
Flaminel
7ac72cbece fixed download cleaner processing 2025-06-16 23:57:51 +03:00
Flaminel
b7902ca7ba fixed queue cleaner processing 2025-06-16 23:35:01 +03:00
Flaminel
84dd43a85a removed dry run attribute 2025-06-16 23:14:10 +03:00
Flaminel
fe54813abf fixed download clients not being invalidated on config change 2025-06-16 23:11:00 +03:00
Flaminel
7b2af6dd5d try fix download clients factory 2025-06-16 22:40:12 +03:00
Flaminel
cacd62058f fixed singletons using datacontext 2025-06-16 22:23:40 +03:00
Flaminel
0a8d1450dd try fix download client factory 2025-06-16 21:38:27 +03:00
Flaminel
f651663fd3 fixed health checks and download service factory 2025-06-16 20:17:06 +03:00
Flaminel
b4548573ee fixed download client UI 2025-06-16 18:26:16 +03:00
Flaminel
f39e8eca46 fixed download services 2025-06-16 18:26:09 +03:00
Flaminel
ba1ced3b84 some more layout fixes 2025-06-16 17:37:49 +03:00
Flaminel
ef8aa4c002 fixed download button context menu 2025-06-16 17:28:05 +03:00
Flaminel
cd2b66a525 fix events and logs layout 2025-06-16 17:21:56 +03:00
Flaminel
b4dad7ebc9 fixed download clients layout 2025-06-16 14:42:32 +03:00
Flaminel
9411ab815e fixed lidarr layout 2025-06-15 23:51:23 +03:00
Flaminel
9ebf49f0b8 fixed mobile menu 2025-06-15 23:51:17 +03:00
Flaminel
369a75b4c5 fixed radarr layout 2025-06-15 23:38:15 +03:00
Flaminel
1f55929ab1 fixed sonarr disabled states 2025-06-15 23:12:53 +03:00
Flaminel
4558fa462f fixed sonarr layout 2025-06-15 23:08:37 +03:00
Flaminel
f23c0e0186 changed heart icon color 2025-06-15 23:01:43 +03:00
Flaminel
d5d661543f try fix sonarr layout 2025-06-15 22:55:58 +03:00
Flaminel
53163774a5 separated settings pages 2025-06-15 22:31:34 +03:00
Flaminel
00dfd63797 try fix sonarr 2025-06-15 22:23:52 +03:00
Flaminel
d5e3e9954d try fix arrs again 2025-06-15 21:46:01 +03:00
Flaminel
bf37668dcb combine arr configs #1 2025-06-15 21:15:50 +03:00
Flaminel
62eee94497 removed search type from sonarr config 2025-06-15 19:46:25 +03:00
Flaminel
91274dac4b try fix arr #3 2025-06-15 19:30:11 +03:00
Flaminel
689adb13d8 try fix arr #2 2025-06-15 18:54:33 +03:00
Flaminel
1194db6c1e try fix arr #1 2025-06-15 18:54:28 +03:00
Flaminel
5f412c2e6a try fix config controller 2025-06-15 11:27:16 +03:00
Flaminel
bd9f24eb1a fixed general settings 2025-06-15 03:30:25 +03:00
Flaminel
458688770a try fix download client #4 2025-06-15 03:06:24 +03:00
Flaminel
cf208754a3 try fix download client #3 2025-06-15 03:01:53 +03:00
Flaminel
4b5f4dc447 try fix download client #2 2025-06-15 02:45:49 +03:00
Flaminel
3c2e36eb9e try fix download client UI 2025-06-15 02:26:37 +03:00
Flaminel
95d39c40ed fixed initial db state 2025-06-15 01:21:58 +03:00
Flaminel
9e393eebcc try switch to db 2025-06-15 00:42:20 +03:00
Flaminel
033b50519b db config checkpoint 2025-06-14 01:20:37 +03:00
Flaminel
0be5f48717 fixed db naming 2025-06-13 18:52:51 +03:00
Flaminel
9353a55cff added config db 2025-06-13 18:16:03 +03:00
Flaminel
0cb737a7e1 try fix config controller 2025-06-13 16:42:22 +03:00
Flaminel
6a0641ef63 try fix config update 2025-06-13 15:46:00 +03:00
Flaminel
26bfa5adb2 try fix again 2025-06-13 14:29:01 +03:00
Flaminel
96130501c3 try fix download client settings 2025-06-13 14:18:17 +03:00
Flaminel
fef7efb7dc added download client config UI 2025-06-13 14:09:04 +03:00
Flaminel
857a6a88b4 added lidarr UI 2025-06-12 23:18:13 +03:00
Flaminel
b85842d3f0 added radarr config UI; fixed some messages 2025-06-12 22:57:14 +03:00
Flaminel
4800ec66d5 fixed some buttons 2025-06-12 22:28:50 +03:00
Flaminel
225e80cdbe try add sonarr config #2 2025-06-11 17:39:53 +03:00
Flaminel
64a24051d7 try add sonarr config #1 2025-06-11 16:01:55 +03:00
Flaminel
d73cb46006 try fix download cleaner again 2025-06-11 00:28:29 +03:00
Flaminel
0ab5c01ebc added toggle for unlinked settings 2025-06-10 22:31:42 +03:00
Flaminel
f2303023b4 try fix download cleaner #2 2025-06-10 21:48:26 +03:00
Flaminel
7b67ab6967 try fix download cleaner settings #1 2025-06-10 20:25:53 +03:00
Flaminel
3ae1f9c81d added download cleaner settings 2025-06-10 19:19:16 +03:00
Flaminel
3b676a8c60 try fix settings notifications and stuff 2025-06-10 09:42:49 +03:00
Flaminel
0f45b298b8 fixed dropdown 2025-06-09 12:52:34 +03:00
Flaminel
0260e43d79 try basic/advanced scheduling mode 2025-06-09 12:50:26 +03:00
Flaminel
a85687f687 fixed some other stuff 2025-06-09 03:28:20 +03:00
Flaminel
ee24856504 added new event type and fixed more stuff 2025-06-09 02:40:41 +03:00
Flaminel
9b337134c9 fixed queue settings validations 2025-06-09 02:21:34 +03:00
Flaminel
64d4abf25b fixed setting log level 2025-06-09 02:21:20 +03:00
Flaminel
a4423a28e9 some more fucking fixing 2025-06-09 01:47:22 +03:00
Flaminel
fc26f40fb3 fixed some stuff on queue cleaner 2025-06-09 00:59:42 +03:00
Flaminel
e2e775c073 fix some job stuff 2025-06-09 00:52:53 +03:00
Flaminel
ca13171b82 try fix settings 2025-06-08 23:59:55 +03:00
Flaminel
ee71eab6fa more fixes 2025-06-08 23:10:35 +03:00
Flaminel
565b6b3fde fixed general settings layout 2025-06-08 23:03:02 +03:00
Flaminel
91e7e92cc6 fixed api path 2025-06-08 22:37:16 +03:00
Flaminel
c8626c220e fixed missing certificate validation option 2025-06-08 22:35:28 +03:00
Flaminel
c9dc917401 try fix general settings #1 2025-06-08 22:25:30 +03:00
Flaminel
20e1df722e added general settings 2025-06-08 21:38:16 +03:00
Flaminel
06a2b82881 added loading/error screen for settings 2025-06-08 18:21:05 +03:00
Flaminel
1afbf5c573 fixed content blocker checkbox state 2025-06-08 02:45:15 +03:00
Flaminel
e5586fc774 fixed settings dirty state management and added leave page popup 2025-06-08 02:37:24 +03:00
Flaminel
565d6a78a7 fixed queue cleaner settings button 2025-06-08 01:53:01 +03:00
Flaminel
9cac4fdf8c fixed too many OnFileChanged being trigger when starting the app 2025-06-08 01:45:16 +03:00
Flaminel
d28389641a fixed trace and debug logs icon 2025-06-08 01:44:37 +03:00
Flaminel
46c48d7c00 try fix config manager 2025-06-07 02:15:36 +03:00
Flaminel
d2bcf8ac89 removed content blocker config file 2025-06-07 02:14:07 +03:00
Flaminel
8463b8b786 removed ignored downloads path and config 2025-06-07 02:05:51 +03:00
Flaminel
fd74455951 removed fallback code 2025-06-07 00:41:56 +03:00
Flaminel
34314de284 fixed accordion toggle 2025-06-07 00:36:27 +03:00
Flaminel
195d361364 fixed patterns input 2025-06-07 00:11:46 +03:00
Flaminel
cc07466c2b try change to accordion panel 2025-06-06 23:39:35 +03:00
Flaminel
30aaac5e67 fixed dropdowns and schedule 2025-06-06 23:12:34 +03:00
Flaminel
44a6c37530 fixed small stuff 2025-06-06 22:53:29 +03:00
Flaminel
9526f133a1 changed some info logs 2025-06-06 22:45:19 +03:00
Flaminel
ddbfee33d1 fixed queue cleaner config retrieval 2025-06-06 22:44:08 +03:00
Flaminel
12ab97825b fixed queue cleaner UI settings 2025-06-06 22:20:56 +03:00
Flaminel
bcf093148c fixed UI queue cleaner config 2025-06-06 21:26:51 +03:00
Flaminel
d2cc8517c1 try fix blocklist provider to be dynamic 2025-06-06 21:11:15 +03:00
Flaminel
cae4e323a5 try remove content blocker 2025-06-06 20:46:38 +03:00
Flaminel
f6b0014ec6 fixed some configuration stuff 2025-06-02 12:59:33 +03:00
Flaminel
5d0a48e7cd fixed download cleaner job on api update 2025-06-01 18:49:59 +03:00
Flaminel
d177790d6f fixed queue cleaner job scheduling on API call 2025-06-01 18:37:58 +03:00
Flaminel
d7e28fc6e7 change logs layout 2025-06-01 18:12:16 +03:00
Flaminel
14f16cbe70 fixed events layout 2025-06-01 02:37:15 +03:00
Flaminel
8b8dc672d1 updated some names 2025-06-01 02:36:59 +03:00
Flaminel
d80d897acb test 2025-05-31 21:38:45 +03:00
Flaminel
d9d1f86897 try fix #4 2025-05-31 21:38:41 +03:00
Flaminel
2de3cccac7 try fix #3 2025-05-31 20:39:30 +03:00
Flaminel
b2bb48a260 try fix #2 2025-05-31 00:54:25 +03:00
Flaminel
d078ea288c try fix settings enablement 2025-05-30 23:18:36 +03:00
Flaminel
84d984082c created separate component for settings 2025-05-30 19:13:02 +03:00
Flaminel
b289b2ee39 added queue cleaner settings 2025-05-30 18:36:38 +03:00
Flaminel
97473b47fd updated config controller 2025-05-30 16:27:20 +03:00
Flaminel
a38d370925 added global exception handler 2025-05-30 16:02:56 +03:00
Flaminel
1e3a4cb220 added config DTOs 2025-05-30 15:19:04 +03:00
Flaminel
62e31a2497 changed db file name 2025-05-30 04:14:28 +03:00
Flaminel
c58f6080f0 added encryption for sensitive configs 2025-05-29 23:01:42 +03:00
Flaminel
7cfd69b1f7 fixed search on events 2025-05-29 20:14:07 +03:00
Flaminel
86c356c3a9 added missing event search; fixed connection status for events page 2025-05-29 04:31:54 +03:00
Flaminel
36cfd3b4e5 fixed events viewer 2025-05-29 03:25:05 +03:00
Flaminel
c6f34432b7 removed auto scroll on events 2025-05-29 02:55:24 +03:00
Flaminel
97fe7138c2 fixed event timestamp 2025-05-29 01:57:12 +03:00
Flaminel
2b83e1a334 events api #1 2025-05-28 22:39:15 +03:00
Flaminel
599f8959a9 fixed some logs 2025-05-28 21:41:38 +03:00
Flaminel
f6bcd29ea0 removed old hubs 2025-05-28 19:31:02 +03:00
Flaminel
62fc39251b added unified signalr hub 2025-05-28 19:01:54 +03:00
Flaminel
f5fe9405cd removed some stuff from LoggingController 2025-05-28 15:12:59 +03:00
Flaminel
e4bb0ac04c added glow on logo and heart 2025-05-28 15:12:45 +03:00
Flaminel
fdab02a937 fixed some things; renamed stuff 2025-05-28 11:34:18 +03:00
Flaminel
b7546a7015 fixed dashboard streams not being populated at first 2025-05-28 02:17:52 +03:00
Flaminel
7482f963e3 removed weird left border on event and log entries 2025-05-28 01:21:59 +03:00
Flaminel
90b0ea8306 fixed connection status styling on dashboard 2025-05-28 01:18:36 +03:00
Flaminel
25ae542e87 fixed timeline colors 2025-05-28 01:06:15 +03:00
Flaminel
e96d76091b fixes to layout and styling 2025-05-27 23:03:21 +03:00
Flaminel
28b6c10917 fixed sidebar items 2025-05-27 20:31:32 +03:00
Flaminel
60884198ff removed icons; fixed buttons 2025-05-27 20:30:58 +03:00
Flaminel
367a499155 removed title from topbar 2025-05-27 20:04:18 +03:00
Flaminel
414ad299b6 removed dashboard refresh button 2025-05-27 18:56:44 +03:00
Flaminel
b3bc071943 added dashboard cards 2025-05-27 18:45:53 +03:00
Flaminel
7bd69046ea removed notification publisher definitions 2025-05-27 15:25:13 +03:00
Flaminel
7cfe1333d2 fixed config path 2025-05-27 15:21:18 +03:00
Flaminel
4bb54517c1 changed failed import naming 2025-05-27 14:37:39 +03:00
Flaminel
607bebaf0f fixed event architecture 2025-05-27 14:30:59 +03:00
Flaminel
0da1ef518a events #5 2025-05-27 03:03:06 +03:00
Flaminel
a660480a7c events #4 2025-05-27 02:21:34 +03:00
Flaminel
e8a7373b0d events #3 2025-05-27 01:32:02 +03:00
Flaminel
0785fe9a12 events #2 2025-05-26 23:16:22 +03:00
Flaminel
153c490198 added events #1 2025-05-26 23:08:02 +03:00
Flaminel
4f8d2c57d7 fixed toggle switch 2025-05-25 23:34:56 +03:00
Flaminel
0ba1979016 update theming 2025-05-25 23:16:44 +03:00
Flaminel
7ec9dfe68d remove collapsed sidebar 2025-05-25 22:50:37 +03:00
Flaminel
8b7e39fa86 try sidebar #2 2025-05-25 19:23:00 +03:00
Flaminel
2143c74767 try sidebar 2025-05-25 02:33:21 +03:00
Flaminel
9e596cfe17 topbar 2025-05-25 02:13:24 +03:00
Flaminel
dd95c40bec fixed logs 2025-05-24 01:28:07 +03:00
Flaminel
df37aee2a2 try #3 2025-05-24 00:54:45 +03:00
Flaminel
cb57b06abb try fix collapse #2 2025-05-24 00:48:26 +03:00
Flaminel
ac924d1294 try fix collapse animation 2025-05-24 00:38:43 +03:00
Flaminel
ba4379417e fixed nav bar icon alignment 2025-05-24 00:31:36 +03:00
Flaminel
ff565895ce fixed download logs 2025-05-24 00:12:54 +03:00
Flaminel
1f5e8e5711 fixed mobile side bar 2025-05-24 00:04:12 +03:00
Flaminel
e284ea2c13 changing main layout and styling 2025-05-23 23:49:45 +03:00
Flaminel
d5dab0a726 styling again 2025-05-23 23:13:31 +03:00
Flaminel
8b36e363ac style adjustments 2025-05-23 22:11:01 +03:00
Flaminel
d3c5f7a210 debounce 2025-05-23 22:00:58 +03:00
Flaminel
a97b898d33 #37 2025-05-23 20:58:22 +03:00
Flaminel
aa3ec6c49c theme 2025-05-23 20:18:04 +03:00
Flaminel
2a55ad456c rules 2025-05-23 19:59:15 +03:00
Flaminel
856bd9a93d #36 2025-05-23 17:27:25 +03:00
Flaminel
64518ad071 #35 2025-05-23 17:15:15 +03:00
Flaminel
100528ab06 fix #34 2025-05-22 19:18:47 +03:00
Flaminel
a8e188aa01 #33 2025-05-22 17:03:09 +03:00
Flaminel
91bd85708c #32 2025-05-22 15:13:25 +03:00
Flaminel
0bd4e77e9d #31 2025-05-21 20:49:40 +03:00
Flaminel
9bd46d7255 fix #30 2025-05-20 16:28:54 +03:00
Flaminel
1604e56a89 huntarr 2025-05-20 13:36:10 +03:00
Flaminel
6c9b60dff5 #29 2025-05-20 13:35:28 +03:00
Flaminel
ee02666dc1 #28 2025-05-20 13:21:32 +03:00
Flaminel
21e1fde1c8 #27 2025-05-20 12:32:42 +03:00
Flaminel
8721bc411e #26 2025-05-20 10:35:30 +03:00
Flaminel
3d0ab5f4a6 #25 2025-05-19 23:11:16 +03:00
Flaminel
0e4535d7a6 add ui 2025-05-19 22:16:52 +03:00
Flaminel
7a1e019c76 fix #24 2025-05-19 19:40:39 +03:00
Flaminel
701a7dc417 fix #23 2025-05-19 19:30:12 +03:00
Flaminel
eb0f782f53 #22 2025-05-19 15:32:57 +03:00
Flaminel
a1bd278652 #21 2025-05-19 13:40:59 +03:00
Flaminel
9409346732 fix #20 2025-05-19 12:38:52 +03:00
Flaminel
3d9b286206 #19 2025-05-19 12:35:58 +03:00
Flaminel
c675924be7 fix 2025-05-19 12:08:58 +03:00
Flaminel
2bd8f69aff fix 2025-05-18 02:39:20 +03:00
Flaminel
ef9868be4d fix 2025-05-18 02:17:45 +03:00
Flaminel
4a394928bb #17 2025-05-17 22:14:00 +03:00
Flaminel
60d3ddb3d2 fix #16 2025-05-17 21:33:02 +03:00
Flaminel
0173598519 #15 2025-05-17 21:30:09 +03:00
Flaminel
96b9a54b64 fix #14 2025-05-17 21:03:14 +03:00
Flaminel
212aeccaf3 fix env 2025-05-17 20:14:30 +03:00
Flaminel
d2eb9e50e0 fix #13 2025-05-17 20:12:53 +03:00
Flaminel
1b47921ac5 fix #12 2025-05-17 19:16:59 +03:00
Flaminel
d8d6e31395 fix #11 2025-05-16 21:52:28 +03:00
Flaminel
10be7d4a73 fix #10 2025-05-16 21:15:35 +03:00
Flaminel
e98ecfcb2a fix #9 2025-05-16 20:48:52 +03:00
Flaminel
bc1da2113c fix #8 2025-05-16 20:05:21 +03:00
Flaminel
46ef6123cc fix #7 2025-05-16 19:28:52 +03:00
Flaminel
f2027f77a9 #12 2025-05-16 19:16:32 +03:00
Flaminel
3c2bb7a289 #11 2025-05-16 18:53:01 +03:00
Flaminel
f9fd118f88 #10 2025-05-16 18:25:47 +03:00
Flaminel
4d79307d24 #9 2025-05-16 18:06:18 +03:00
Flaminel
a52fd9198d fix #6 2025-05-16 17:22:59 +03:00
Flaminel
4cb36e6727 #8 2025-05-16 16:24:38 +03:00
Flaminel
a7c2b698d6 rules #2 2025-05-16 16:24:23 +03:00
Flaminel
57326b2f8e fix #5 2025-05-15 22:09:42 +03:00
Flaminel
374f0f72a7 fix #4 2025-05-15 20:29:27 +03:00
Flaminel
06f514c3ea #7 2025-05-15 20:25:21 +03:00
Flaminel
f386bf700c fix #3 2025-05-15 20:15:20 +03:00
Flaminel
b6950f545f #6 2025-05-15 19:34:12 +03:00
Flaminel
6eb457ed8f fix #2 2025-05-15 18:18:41 +03:00
Flaminel
0e99a510a8 #5 2025-05-15 18:15:42 +03:00
Flaminel
b4316a4f0d #4 2025-05-15 17:46:25 +03:00
Flaminel
fa3aebde9a #3 2025-05-15 16:33:39 +03:00
Flaminel
a68a4c733f fix #1 2025-05-15 16:18:09 +03:00
Flaminel
0fc7352db6 #2 2025-05-14 22:49:39 +03:00
Flaminel
69788d55d2 #1 2025-05-14 22:42:52 +03:00
Flaminel
461e935128 initial windsurf 2025-05-14 22:42:24 +03:00
104 changed files with 451 additions and 3969 deletions

View File

@@ -14,7 +14,7 @@ body:
options:
- label: Reviewed the documentation.
required: true
- label: Ensured I am using ghcr.io/cleanuparr/cleanuparr docker repository.
- label: Ensured I am using ghcr.io/Cleanuparr/Cleanuparr docker repository.
required: true
- label: Ensured I am using the latest version.
required: true

View File

@@ -14,7 +14,7 @@ body:
options:
- label: Reviewed the documentation.
required: true
- label: Ensured I am using ghcr.io/cleanuparr/cleanuparr docker repository.
- label: Ensured I am using ghcr.io/Cleanuparr/Cleanuparr docker repository.
required: true
- label: Ensured I am using the latest version.
required: true

View File

@@ -53,10 +53,11 @@ jobs:
githubTags=""
if [ -n "$latestDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$latestDockerTag"
githubTags="$githubTags,ghcr.io/cleanuparr:$latestDockerTag"
fi
if [ -n "$versionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$versionDockerTag"
githubTags="$githubTags,ghcr.io/cleanuparr:$versionDockerTag"
fi
# set env vars
@@ -112,7 +113,7 @@ jobs:
version=${{ env.versionDockerTag }}
build-args: |
VERSION=${{ env.version }}
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
PACKAGES_USERNAME=${{ env.PACKAGES_USERNAME }}
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
outputs: |
type=image

View File

@@ -101,6 +101,28 @@ jobs:
- name: Build osx-arm64
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime osx-arm64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
- name: Create sample configuration files
run: |
# Create a sample appsettings.json for each platform
cat > sample-config.json << 'EOF'
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
EOF
# Copy to each build directory
cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64/appsettings.json
cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64/appsettings.json
cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64/appsettings.json
cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64/appsettings.json
cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64/appsettings.json
- name: Zip win-x64
run: |
cd ./artifacts

View File

@@ -55,7 +55,7 @@ jobs:
# Build portable executables
build-executables:
needs: validate
uses: ./.github/workflows/build-executable.yml
uses: ./.github/workflows/build_executable.yml
secrets: inherit
# Build Windows installer
@@ -110,8 +110,8 @@ jobs:
tag_name: ${{ needs.validate.outputs.release_version }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
target_commitish: main
generate_release_notes: true
prerelease: ${{ contains(needs.validate.outputs.app_version, '-') }}
files: |
./artifacts/**/*.zip
./artifacts/**/*.pkg

View File

@@ -12,67 +12,34 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
> **Features:**
> - Strike system to mark bad downloads.
> - Remove and block downloads that reached a maximum number of strikes.
> - Remove and block downloads that are **failing to be imported** by the arrs.
> - Remove and block downloads that are **stalled** or in **metadata downloading** state.
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**.
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Content Blocker**.
> - Remove and block downloads that are **failing to be imported** by the arrs. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/queue-cleaner/import-failed)
> - Remove and block downloads that are **stalled** or in **metadata downloading** state. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/queue-cleaner/stalled)
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/queue-cleaner/slow)
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Content Blocker**. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/content-blocker/general)
> - Automatically trigger a search for downloads removed from the arrs.
> - Clean up downloads that have been **seeding** for a certain amount of time.
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
> - Notify on strike or download removal.
> - Clean up downloads that have been **seeding** for a certain amount of time. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/download-cleaner/seeding)
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support). [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/download-cleaner/hardlinks)
> - Notify on strike or download removal. [configuration](https://cleanuparr.github.io/cleanuparr/docs/category/notifications)
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
## 🎯 Supported Applications
Cleanuparr supports both qBittorrent's built-in exclusion features and its own blocklist-based system. Binaries for all platforms are provided, along with Docker images for easy deployment.
### *Arr Applications
- **Sonarr** (TV Shows)
- **Radarr** (Movies)
- **Lidarr** (Music)
## Quick Start
### Download Clients
- **qBittorrent**
- **Transmission**
- **Deluge**
> [!NOTE]
>
> 1. **Docker (Recommended)**
> Pull the Docker image from `ghcr.io/Cleanuparr/Cleanuparr:latest`.
>
> 2. **Unraid (for Unraid users)**
> Use the Unraid Community App.
>
> 3. **Manual Installation (if you're not using Docker)**
> Go to [Windows](#windows), [Linux](#linux) or [MacOS](#macos).
### Platforms
- **Docker** (Linux, Windows, macOS)
- **Windows** (Native installer)
- **macOS** (Intel & Apple Silicon)
- **Linux** (Portable executable)
- **Unraid** (Community Apps)
# Docs
## 🚀 Quick Start
```bash
docker run -d --name cleanuparr \
--restart unless-stopped \
-p 11011:11011 \
-v /path/to/config:/config \
-e PORT=11011 \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=Etc/UTC \
ghcr.io/cleanuparr/cleanuparr:latest
```
For Docker Compose, health checks, and other installation methods, see our [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed).
### 🌐 Access the Web Interface
After installation, open your browser and navigate to:
```
http://localhost:11011
```
**Next Steps:** Check out the [📖 Complete Documentation](https://cleanuparr.github.io/Cleanuparr/) for detailed configuration guides and setup instructions.
## 📖 Documentation & Support
- **📚 [Complete Documentation](https://cleanuparr.github.io/Cleanuparr/)** - Installation guides, configuration, and troubleshooting
- **⚙️ [Configuration Guide](https://cleanuparr.github.io/Cleanuparr/docs/category/configuration)** - Set up download clients, *arr apps, and features
- **🔧 [Setup Scenarios](https://cleanuparr.github.io/Cleanuparr/docs/category/setup-scenarios)** - Common use cases and examples
- **💬 [Discord Community](https://discord.gg/SCtMCgtsc4)** - Get help and discuss with other users
- **🔗 [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases)** - Download binaries and view changelog
Docs can be found [here](https://Cleanuparr.github.io/Cleanuparr/).
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuparr"> <span style="vertical-align: middle;">Cleanuparr</span> <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/x.svg" height="24px" width="30px" style="vertical-align: middle;"> <span style="vertical-align: middle;">Huntarr</span> <img style="vertical-align: middle;" width="24px" src="https://github.com/plexguide/Huntarr.io/blob/main/frontend/static/logo/512.png?raw=true" alt Huntarr></img>

347
blacklist
View File

@@ -1,27 +1,12 @@
*(sample).*
*sample.avchd
*sample.avi
*sample.mkv
*sample.mov
*sample.mp4
*sample.webm
*sample.wmv
*.000
*.001
*.002
*.004
*.0xe
*.001
*.73k
*.73p
*.7z
*.7z.001
*.7z.002
*.89k
*.89z
*.8ck
*.a00
*.a01
*.a02
*.a7r
*.ac
*.acc
@@ -37,11 +22,8 @@
*.ahk
*.ai
*.aif
*.ain
*.air
*.alz
*.ana
*.apex
*.api
*.apk
*.app
@@ -49,28 +31,15 @@
*.applescript
*.application
*.appx
*.apz
*.ar
*.arc
*.archiver
*.arduboy
*.arh
*.ari
*.arj
*.ark
*.arscript
*.asb
*.asice
*.asp
*.aspx
*.aspx-exe
*.atmx
*.ayt
*.azw2
*.b1
*.b6z
*.b64
*.ba
*.ba_
*.bak
*.bas
@@ -78,48 +47,26 @@
*.bat
*.bdjo
*.bdmv
*.bdoc
*.beam
*.bh
*.bin
*.bmp
*.bms
*.bndl
*.bns
*.boo
*.bsa
*.btm
*.bundle
*.bz
*.bz2
*.bza
*.bzabw
*.bzip
*.bzip2
*.c
*.c00
*.c01
*.c02
*.c10
*.cab
*.caction
*.car
*.cb7
*.cba
*.cbr
*.cbt
*.cbz
*.cci
*.cda
*.cdb
*.cdz
*.cel
*.celx
*.cfs
*.cgi
*.cheat
*.chm
*.cit
*.ckpt
*.cla
*.class
@@ -129,15 +76,9 @@
*.coffee
*.com
*.command
*.comppkg.hauptwerk.rar
*.comppkg_hauptwerk_rar
*.conda
*.conf
*.config
*.cp9
*.cpgz
*.cpl
*.cpt
*.crt
*.cs
*.csh
@@ -145,27 +86,17 @@
*.csproj
*.css
*.csv
*.ctx
*.ctz
*.cue
*.cur
*.cxarchive
*.cyw
*.czip
*.daemon
*.daf
*.dar
*.dat
*.data-00000-of-00001
*.db
*.dd
*.deamon
*.deb
*.dek
*.dgc
*.dist
*.diz
*.dl_
*.dld
*.dll
*.dmc
@@ -182,27 +113,19 @@
*.dw
*.dword
*.dxl
*.dz
*.e_e
*.ear
*.ebacmd
*.ebm
*.ebs
*.ebs2
*.ecar
*.ecf
*.ecs
*.ecsbx
*.edz
*.efw
*.egg
*.eham
*.elf
*.elf-so
*.email
*.emu
*.epk
*.epi
*.es
*.esh
*.etc
@@ -218,62 +141,36 @@
*.exz
*.ezs
*.ezt
*.f
*.f3z
*.fas
*.fba
*.fcx
*.fky
*.flac
*.flatpak
*.flv
*.fp8
*.fpi
*.frs
*.fxp
*.fzpz
*.gadget
*.gar
*.gat
*.gca
*.gif
*.gifv
*.gm9
*.gmz
*.gpe
*.gpu
*.gs
*.gz
*.gz2
*.gza
*.gzi
*.gzip
*.h5
*.ha
*.ham
*.hbc
*.hbc2
*.hbe
*.hex
*.hki
*.hki1
*.hki2
*.hki3
*.hlp
*.hms
*.hpf
*.hpk
*.hpkg
*.hta
*.hta-psh
*.htaccess
*.htm
*.html
*.htmi
*.hyp
*.iadproj
*.icd
*.ice
*.icns
*.ico
*.idx
@@ -286,27 +183,17 @@
*.ins
*.ipa
*.ipf
*.ipg
*.ipk
*.ipsw
*.iqylink
*.ish
*.iso
*.isp
*.isu
*.isx
*.ita
*.ize
*.izh
*.izma ace
*.j
*.jar
*.jar.pack
*.java
*.jex
*.jgz
*.jhh
*.jic
*.jpeg
*.jpg
*.js
@@ -315,51 +202,27 @@
*.jse
*.jsf
*.json
*.jsonlz4
*.jsp
*.jsx
*.kextraction
*.kgb
*.kix
*.ksh
*.ksp
*.kwgt
*.kx
*.kz
*.layout
*.lbr
*.lck
*.ldb
*.lemon
*.lha
*.lhzd
*.lib
*.libzip
*.link
*.lnk
*.lo
*.lock
*.log
*.loop-vbs
*.lpkg
*.lqr
*.ls
*.lz
*.lz4
*.lzh
*.lzm
*.lzma
*.lzo
*.lzr
*.lzx
*.m3u
*.m4a
*.mac
*.macho
*.mamc
*.manifest
*.mar
*.mbz
*.mcr
*.md
*.mda
@@ -370,29 +233,22 @@
*.mdt
*.mel
*.mem
*.memo
*.meta
*.mgm
*.mhm
*.mht
*.mhtml
*.mid
*.mint
*.mio
*.mlappinstall
*.mlproj
*.mlx
*.mm
*.mobileconfig
*.model
*.moo
*.mou
*.movpkg
*.mozlz4
*.mp3
*.mpa
*.mpk
*.mpkg
*.mpls
*.mrc
*.mrp
@@ -411,79 +267,41 @@
*.msp
*.mst
*.msu
*.mxc
*.mxe
*.mzp
*.n
*.nar
*.ncl
*.net
*.nex
*.nexe
*.nfo
*.npk
*.nrg
*.num
*.nz
*.nzb.bz2
*.nzb.gz
*.nzbs
*.oar
*.ocx
*.odlgz
*.odt
*.opk
*.ore
*.osf
*.ost
*.osx
*.osx-app
*.otm
*.out
*.ova
*.oz
*.p
*.p01
*.p19
*.p7z
*.pa
*.pack.gz
*.package
*.pae
*.paf
*.pak
*.paq6
*.paq7
*.paq8
*.paq8f
*.paq8l
*.paq8p
*.par
*.par2
*.pax
*.pb
*.pbi
*.pcd
*.pcv
*.pdb
*.pdf
*.pea
*.perl
*.pet
*.pex
*.pf
*.phar
*.php
*.php5
*.pif
*.pim
*.pima
*.pit
*.piz
*.pkg
*.pkg.tar.xz
*.pkg.tar.zst
*.pkz
*.pl
*.plsc
*.plx
@@ -501,7 +319,6 @@
*.pptx
*.prc
*.prg
*.prs
*.ps
*.ps1
*.ps1xml
@@ -517,16 +334,9 @@
*.psh-reflection
*.psm1
*.pst
*.psz
*.pt
*.pup
*.puz
*.pvd
*.pvmp
*.pvmz
*.pwa
*.pwc
*.pxl
*.pxo
*.py
*.pyc
@@ -534,20 +344,8 @@
*.pyo
*.python
*.pyz
*.q
*.qda
*.qit
*.qpx
*.r0
*.r00
*.r01
*.r02
*.r03
*.r04
*.r1
*.r2
*.r21
*.r30
*.ram
*.rar
*.raw
@@ -558,35 +356,22 @@
*.reg
*.resources
*.resx
*.rev
*.rfs
*.rfu
*.rgs
*.rk
*.rm
*.rnc
*.rox
*.rp9
*.rpg
*.rpj
*.rpm
*.rss
*.ruby
*.run
*.rxe
*.rz
*.s00
*.s01
*.s02
*.s09
*.s2a
*.s7z
*.sample
*.sapk
*.sar
*.savedmodel
*.sbs
*.sbx
*.sca
*.scar
*.scb
@@ -596,85 +381,42 @@
*.scr
*.script
*.sct
*.sdc
*.sdn
*.sdoc
*.sdocx
*.sea
*.seed
*.sen
*.server
*.service
*.sfg
*.sfm
*.sfs
*.sfv
*.sfx
*.sh
*.shar
*.shb
*.shell
*.shk
*.shortcut
*.shr
*.shs
*.shtml
*.sifz
*.sipa
*.sit
*.sitx
*.sk
*.sldm
*.sln
*.smm
*.smpf
*.snap
*.snagitstamps
*.snappy
*.snb
*.snd
*.snz
*.spa
*.spd
*.spl
*.spm
*.spr
*.spt
*.sqf
*.sql
*.sqx
*.sqz
*.srec
*.srep
*.srt
*.ssm
*.stg
*.stkdoodlz
*.stproj
*.sts
*.sub
*.svg
*.swf
*.sy_
*.sys
*.tar
*.tar.bz2
*.tar.gz
*.tar.gz2
*.tar.lz
*.tar.lzma
*.tar.xz
*.tar.z
*.tar.zip
*.taz
*.tbl
*.tbz
*.tbz2
*.tcp
*.tcx
*.text
*.tf
*.tg
*.tgs
*.tgz
*.thm
*.thmx
@@ -683,35 +425,19 @@
*.tif
*.tiff
*.tipa
*.tlz
*.tlzma
*.tmp
*.tms
*.toast
*.torrent
*.tpk
*.tpsr
*.trs
*.txt
*.tx_
*.txz
*.tz
*.tzst
*.u3p
*.ubz
*.uc2
*.udf
*.ufdr
*.ufs.uzip
*.uha
*.upk
*.upx
*.url
*.uue
*.uvm
*.uw8
*.uzed
*.uzip
*.vb
*.vba
*.vba-exe
@@ -723,46 +449,26 @@
*.vbscript
*.vcd
*.vdo
*.vem
*.vexe
*.vfs
*.vhd
*.vhdx
*.vib
*.vip
*.vlx
*.vm
*.vmcz
*.vmdk
*.vms
*.vob
*.vocab
*.voca
*.vpk
*.vpm
*.vrpackage
*.vsi
*.vwi
*.vxp
*.wa
*.wacz
*.waff
*.war
*.wastickers
*.wav
*.wbk
*.wcm
*.wdz
*.webm
*.whl
*.wick
*.widget
*.wim
*.wiz
*.wlb
*.wma
*.workflow
*.wot
*.wpk
*.wpl
*.wpm
@@ -771,26 +477,14 @@
*.wsc
*.wsf
*.wsh
*.wux
*.x86
*.x86_64
*.xaml
*.xap
*.xapk
*.xar
*.xbap
*.xbe
*.xcf.bz2
*.xcf.gz
*.xcf.xz
*.xcfbz2
*.xcfgz
*.xcfxz
*.xex
*.xez
*.xfp
*.xig
*.xip
*.xla
*.xlam
*.xll
@@ -803,47 +497,24 @@
*.xltb
*.xltm
*.xlw
*.xmcdz
*.xml
*.xoj
*.xopp
*.xqt
*.xrt
*.xx
*.xys
*.xz
*.xzm
*.y
*.yc
*.ygh
*.yz1
*.z
*.z00
*.z01
*.z02
*.z03
*.z04
*.zabw
*.zap
*.zed
*.zfsendtotarget
*.zhelp
*.zi
*.zi_
*.zim
*.zip
*.zipx
*.zix
*.zl
*.zl9
*.zoo
*.zpaq
*.zpi
*.zsplit
*.zst
*.zw
*.zwi
*.zz
*sample.avchd
*sample.avi
*sample.mkv
*sample.mov
*sample.mp4
*sample.webm
*sample.wmv
Trailer.*
VOSTFR
api

View File

@@ -1,410 +1,53 @@
*.000
*.001
*.002
*.004
*.7z
*.7z.001
*.7z.002
*.a00
*.a01
*.a02
*.ace
*.ain
*.alz
*.ana
*.apex
*.apk
*.apz
*.ar
*.arc
*.archiver
*.arduboy
*.arh
*.ari
*.arj
*.ark
*.asice
*.ayt
*.b1
*.b6z
*.b64
*.ba
*.bat
*.bdoc
*.bh
*.bin
*.bmp
*.bndl
*.boo
*.bundle
*.bz
*.bz2
*.bza
*.bzabw
*.bzip
*.bzip2
*.c00
*.c01
*.c02
*.c10
*.car
*.cb7
*.cba
*.cbr
*.cbt
*.cbz
*.cdz
*.cit
*.cmd
*.com
*.comppkg.hauptwerk.rar
*.comppkg_hauptwerk_rar
*.conda
*.cp9
*.cpgz
*.cpt
*.ctx
*.ctz
*.cxarchive
*.czip
*.daf
*.dar
*.db
*.dd
*.deb
*.dgc
*.dist
*.diz
*.dl_
*.dll
*.dmg
*.dz
*.ecar
*.ecs
*.ecsbx
*.edz
*.efw
*.egg
*.epi
*.etc
*.exe
*.f
*.f3z
*.fcx
*.fp8
*.fzpz
*.gar
*.gca
*.gif
*.gmz
*.gz
*.gz2
*.gza
*.gzi
*.gzip
*.ha
*.hbc
*.hbc2
*.hbe
*.hki
*.hki1
*.hki2
*.hki3
*.hpk
*.hpkg
*.htm
*.html
*.htmi
*.hyp
*.iadproj
*.ice
*.ico
*.ini
*.ipg
*.ipk
*.ish
*.iso
*.isx
*.ita
*.ize
*.j
*.jar
*.jar.pack
*.jex
*.jgz
*.jhh
*.jic
*.jpg
*.js
*.jsonlz4
*.kextraction
*.kgb
*.ksp
*.kwgt
*.kz
*.layout
*.lbr
*.lemon
*.lha
*.lhzd
*.libzip
*.link
*.lnk
*.lpkg
*.lqr
*.lz
*.lz4
*.lzh
*.lzm
*.lzma
*.lzo
*.lzr
*.lzx
*.mar
*.mbz
*.md
*.memo
*.mint
*.mlproj
*.mou
*.movpkg
*.mozlz4
*.mpkg
*.msi
*.mxc
*.mzp
*.nar
*.nex
*.nfo
*.npk
*.nz
*.oar
*.odlgz
*.opk
*.osf
*.oz
*.p01
*.p19
*.p7z
*.pa
*.pack.gz
*.package
*.pae
*.pak
*.paq6
*.paq7
*.paq8
*.paq8f
*.paq8l
*.paq8p
*.par
*.par2
*.pax
*.pbi
*.pcv
*.pea
*.perl
*.pet
*.pf
*.php
*.pim
*.pima
*.pit
*.piz
*.pkg
*.pkg.tar.xz
*.pkg.tar.zst
*.pkz
*.pl
*.png
*.prs
*.ps1
*.psc1
*.psd1
*.psm1
*.psz
*.pup
*.puz
*.pvmp
*.pvmz
*.pwa
*.pxl
*.py
*.pyd
*.q
*.qda
*.r0
*.r00
*.r01
*.r02
*.r03
*.r04
*.r1
*.r2
*.r21
*.r30
*.rar
*.rb
*.readme
*.reg
*.rev
*.rk
*.rnc
*.rp9
*.rpm
*.rss
*.run
*.rz
*.s00
*.s01
*.s02
*.s09
*.s7z
*.sar
*.sbx
*.scr
*.sdc
*.sdn
*.sdoc
*.sdocx
*.sea
*.sen
*.sfg
*.sfm
*.sfs
*.sfx
*.sh
*.shar
*.shk
*.shr
*.sifz
*.sipa
*.sit
*.sitx
*.smpf
*.snagitstamps
*.snappy
*.snb
*.snz
*.spa
*.spd
*.spl
*.spm
*.spt
*.sql
*.sqf
*.sqx
*.sqz
*.srep
*.stg
*.stkdoodlz
*.stproj
*.sy_
*.tar.bz2
*.tar.gz
*.tar.gz2
*.tar.lz
*.tar.lzma
*.tar.xz
*.tar.z
*.tar.zip
*.taz
*.tbz
*.tbz2
*.tcx
*.text
*.tg
*.tgs
*.tgz
*.thumb
*.tlz
*.tlzma
*.torrent
*.tpsr
*.trs
*.txt
*.tx_
*.txz
*.tz
*.tzst
*.ubz
*.uc2
*.ufdr
*.ufs.uzip
*.uha
*.url
*.uue
*.uvm
*.uzed
*.uzip
*.vbs
*.vem
*.vfs
*.vib
*.vip
*.vmcz
*.vms
*.voca
*.vpk
*.vrpackage
*.vsi
*.vwi
*.wa
*.wacz
*.waff
*.war
*.wastickers
*.wdz
*.whl
*.wick
*.wlb
*.wot
*.wsf
*.wux
*.xapk
*.xar
*.xcf.bz2
*.xcf.gz
*.xcf.xz
*.xcfbz2
*.xcfgz
*.xcfxz
*.xez
*.xfp
*.xip
*.xml
*.xmcdz
*.xoj
*.xopp
*.xx
*.xz
*.xzm
*.y
*.yc
*.yz1
*.z
*.z00
*.z01
*.z02
*.z03
*.z04
*.zabw
*.zap
*.zed
*.zfsendtotarget
*.zhelp
*.zi
*.zi_
*.zim
*.zip
*.zipx
*.zix
*.zl
*.zoo
*.zpaq
*.zpi
*.zsplit
*.zst
*.zw
*.zwi
*.zz
*.zipx

View File

@@ -7,7 +7,6 @@ using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
@@ -17,6 +16,7 @@ using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Infrastructure.Services.Interfaces;
using Mapster;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -309,24 +309,6 @@ public class ConfigurationController : ControllerBase
}
}
[HttpGet("readarr")]
public async Task<IActionResult> GetReadarrConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Readarr);
return Ok(config.Adapt<ArrConfigDto>());
}
finally
{
DataContext.Lock.Release();
}
}
[HttpGet("notifications")]
public async Task<IActionResult> GetNotificationsConfig()
{
@@ -791,37 +773,6 @@ public class ConfigurationController : ControllerBase
DataContext.Lock.Release();
}
}
[HttpPut("readarr")]
public async Task<IActionResult> UpdateReadarrConfig([FromBody] UpdateReadarrConfigDto newConfigDto)
{
await DataContext.Lock.WaitAsync();
try
{
// Get existing config
var config = await _dataContext.ArrConfigs
.FirstAsync(x => x.Type == InstanceType.Readarr);
config.FailedImportMaxStrikes = newConfigDto.FailedImportMaxStrikes;
// Validate the configuration
config.Validate();
// Persist the configuration
await _dataContext.SaveChangesAsync();
return Ok(new { Message = "Readarr configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save Readarr configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
/// <summary>
/// Updates a job schedule based on configuration changes
@@ -1186,114 +1137,4 @@ public class ConfigurationController : ControllerBase
DataContext.Lock.Release();
}
}
[HttpPost("readarr/instances")]
public async Task<IActionResult> CreateReadarrInstance([FromBody] CreateArrInstanceDto newInstance)
{
await DataContext.Lock.WaitAsync();
try
{
// Get the Readarr config to add the instance to
var config = await _dataContext.ArrConfigs
.FirstAsync(x => x.Type == InstanceType.Readarr);
// Create the new instance
var instance = new ArrInstance
{
Enabled = newInstance.Enabled,
Name = newInstance.Name,
Url = new Uri(newInstance.Url),
ApiKey = newInstance.ApiKey,
ArrConfigId = config.Id,
};
// Add to the config's instances collection
await _dataContext.ArrInstances.AddAsync(instance);
// Save changes
await _dataContext.SaveChangesAsync();
return CreatedAtAction(nameof(GetReadarrConfig), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Readarr instance");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("readarr/instances/{id}")]
public async Task<IActionResult> UpdateReadarrInstance(Guid id, [FromBody] CreateArrInstanceDto updatedInstance)
{
await DataContext.Lock.WaitAsync();
try
{
// Get the Readarr config and find the instance
var config = await _dataContext.ArrConfigs
.Include(c => c.Instances)
.FirstAsync(x => x.Type == InstanceType.Readarr);
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance == null)
{
return NotFound($"Readarr instance with ID {id} not found");
}
// Update the instance properties
instance.Enabled = updatedInstance.Enabled;
instance.Name = updatedInstance.Name;
instance.Url = new Uri(updatedInstance.Url);
instance.ApiKey = updatedInstance.ApiKey;
await _dataContext.SaveChangesAsync();
return Ok(instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Readarr instance with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("readarr/instances/{id}")]
public async Task<IActionResult> DeleteReadarrInstance(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
// Get the Readarr config and find the instance
var config = await _dataContext.ArrConfigs
.Include(c => c.Instances)
.FirstAsync(x => x.Type == InstanceType.Readarr);
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance == null)
{
return NotFound($"Readarr instance with ID {id} not found");
}
// Remove the instance
config.Instances.Remove(instance);
await _dataContext.SaveChangesAsync();
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete Readarr instance with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
}

View File

@@ -1,125 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace Cleanuparr.Api.Controllers;
/// <summary>
/// Health check endpoints for Docker and Kubernetes
/// </summary>
[ApiController]
[Route("[controller]")]
public class HealthController : ControllerBase
{
private readonly HealthCheckService _healthCheckService;
private readonly ILogger<HealthController> _logger;
public HealthController(HealthCheckService healthCheckService, ILogger<HealthController> logger)
{
_healthCheckService = healthCheckService;
_logger = logger;
}
/// <summary>
/// Basic liveness probe - checks if the application is running
/// Used by Docker HEALTHCHECK and Kubernetes liveness probes
/// </summary>
[HttpGet]
[Route("/health")]
public async Task<IActionResult> GetHealth()
{
try
{
var result = await _healthCheckService.CheckHealthAsync(
registration => registration.Tags.Contains("liveness"));
return result.Status == HealthStatus.Healthy
? Ok(new { status = "healthy", timestamp = DateTime.UtcNow })
: StatusCode(503, new { status = "unhealthy", timestamp = DateTime.UtcNow });
}
catch (Exception ex)
{
_logger.LogError(ex, "Health check failed");
return StatusCode(503, new { status = "unhealthy", error = "Health check failed", timestamp = DateTime.UtcNow });
}
}
/// <summary>
/// Readiness probe - checks if the application is ready to serve traffic
/// Used by Kubernetes readiness probes
/// </summary>
[HttpGet]
[Route("/health/ready")]
public async Task<IActionResult> GetReadiness()
{
try
{
var result = await _healthCheckService.CheckHealthAsync(
registration => registration.Tags.Contains("readiness"));
if (result.Status == HealthStatus.Healthy)
{
return Ok(new { status = "ready", timestamp = DateTime.UtcNow });
}
// For readiness, we consider degraded as not ready
return StatusCode(503, new {
status = "not_ready",
timestamp = DateTime.UtcNow,
details = result.Entries.Where(e => e.Value.Status != HealthStatus.Healthy)
.ToDictionary(e => e.Key, e => new {
status = e.Value.Status.ToString().ToLowerInvariant(),
description = e.Value.Description
})
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Readiness check failed");
return StatusCode(503, new { status = "not_ready", error = "Readiness check failed", timestamp = DateTime.UtcNow });
}
}
/// <summary>
/// Detailed health status - for monitoring and debugging
/// </summary>
[HttpGet]
[Route("/health/detailed")]
public async Task<IActionResult> GetDetailedHealth()
{
try
{
var result = await _healthCheckService.CheckHealthAsync();
var response = new
{
status = result.Status.ToString().ToLowerInvariant(),
timestamp = DateTime.UtcNow,
totalDuration = result.TotalDuration.TotalMilliseconds,
entries = result.Entries.ToDictionary(
e => e.Key,
e => new
{
status = e.Value.Status.ToString().ToLowerInvariant(),
description = e.Value.Description,
duration = e.Value.Duration.TotalMilliseconds,
tags = e.Value.Tags,
data = e.Value.Data,
exception = e.Value.Exception?.Message
})
};
return result.Status == HealthStatus.Healthy
? Ok(response)
: StatusCode(503, response);
}
catch (Exception ex)
{
_logger.LogError(ex, "Detailed health check failed");
return StatusCode(503, new {
status = "unhealthy",
error = "Detailed health check failed",
timestamp = DateTime.UtcNow
});
}
}
}

View File

@@ -1,6 +1,6 @@
using Cleanuparr.Api.Models;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Infrastructure.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Controllers;

View File

@@ -52,10 +52,6 @@ public class StatusController : ControllerBase
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Lidarr);
var readarrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == InstanceType.Readarr);
var status = new
{
@@ -84,10 +80,6 @@ public class StatusController : ControllerBase
Lidarr = new
{
InstanceCount = lidarrConfig.Instances.Count
},
Readarr = new
{
InstanceCount = readarrConfig.Instances.Count
}
}
};

View File

@@ -40,6 +40,9 @@ public static class ApiDI
// Add health status broadcaster
services.AddHostedService<HealthStatusBroadcaster>();
// Add logging initializer service
services.AddHostedService<LoggingInitializer>();
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo

View File

@@ -83,17 +83,9 @@ public static class MainDI
/// </summary>
private static IServiceCollection AddHealthServices(this IServiceCollection services) =>
services
// Register the existing health check service for download clients
// Register the health check service
.AddSingleton<IHealthCheckService, HealthCheckService>()
// Register the background service for periodic health checks
.AddHostedService<HealthCheckBackgroundService>()
// Add ASP.NET Core health checks
.AddHealthChecks()
.AddCheck<ApplicationHealthCheck>("application", tags: ["liveness"])
.AddCheck<DatabaseHealthCheck>("database", tags: ["readiness"])
.AddCheck<FileSystemHealthCheck>("filesystem", tags: ["readiness"])
.AddCheck<DownloadClientsHealthCheck>("download_clients", tags: ["readiness"])
.Services;
.AddHostedService<HealthCheckBackgroundService>();
}

View File

@@ -12,9 +12,9 @@ using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Security;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Infrastructure.Interceptors;
using Infrastructure.Services.Interfaces;
using Infrastructure.Verticals.Files;
namespace Cleanuparr.Api.DependencyInjection;
@@ -37,7 +37,6 @@ public static class ServicesDI
.AddTransient<SonarrClient>()
.AddTransient<RadarrClient>()
.AddTransient<LidarrClient>()
.AddTransient<ReadarrClient>()
.AddTransient<ArrClientFactory>()
.AddTransient<QueueCleaner>()
.AddTransient<ContentBlocker>()

View File

@@ -1,28 +0,0 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Text;
namespace Cleanuparr.Api;
/// <summary>
/// Custom health check response writers for different formats
/// </summary>
public static class HealthCheckResponseWriter
{
/// <summary>
/// Writes a minimal plain text response suitable for Docker health checks
/// </summary>
public static async Task WriteMinimalPlaintext(HttpContext context, HealthReport report)
{
context.Response.ContentType = "text/plain";
var status = report.Status switch
{
HealthStatus.Healthy => "healthy",
HealthStatus.Degraded => "degraded",
HealthStatus.Unhealthy => "unhealthy",
_ => "unknown"
};
await context.Response.WriteAsync(status, Encoding.UTF8);
}
}

View File

@@ -175,7 +175,7 @@ public class BackgroundJobManager : IHostedService
IOperableTrigger triggerObj = (IOperableTrigger)TriggerBuilder.Create()
.WithIdentity("ValidationTrigger")
.StartNow()
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
.WithCronSchedule(cronExpression)
.Build();
IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2);
@@ -197,26 +197,26 @@ public class BackgroundJobManager : IHostedService
}
}
// Create main cron trigger with consistent naming (matches JobManagementService)
// Create cron trigger
var trigger = TriggerBuilder.Create()
.WithIdentity($"{typeName}-trigger")
.ForJob(jobKey)
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
.StartNow()
.Build();
// Schedule the main trigger
await _scheduler.ScheduleJob(trigger, cancellationToken);
// Trigger immediate execution for startup using a one-time trigger
// Create startup trigger to run immediately
var startupTrigger = TriggerBuilder.Create()
.WithIdentity($"{typeName}-startup-{DateTimeOffset.UtcNow.Ticks}")
.WithIdentity($"{typeName}-startup-trigger")
.ForJob(jobKey)
.StartNow()
.Build();
// Schedule job with both triggers
await _scheduler.ScheduleJob(trigger, cancellationToken);
await _scheduler.ScheduleJob(startupTrigger, cancellationToken);
_logger.LogInformation("Added trigger for job {name} with cron expression {CronExpression} and immediate startup execution",
_logger.LogInformation("Added triggers for job {name} with cron expression {CronExpression}",
typeName, cronExpression);
}

View File

@@ -4,8 +4,6 @@ using Cleanuparr.Api;
using Cleanuparr.Api.DependencyInjection;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
@@ -149,19 +147,6 @@ logConfig.WriteTo.Sink(signalRSink);
Log.Logger = logConfig.CreateLogger();
// Configure health check endpoints before the API configuration
app.MapHealthChecks("/health", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("liveness"),
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("readiness"),
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
});
app.ConfigureApi();
await app.RunAsync();

View File

@@ -1,9 +0,0 @@
namespace Cleanuparr.Application.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Readarr configuration basic settings (instances managed separately)
/// </summary>
public record UpdateReadarrConfigDto
{
public short FailedImportMaxStrikes { get; init; } = -1;
}

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -64,7 +63,6 @@ public sealed class ContentBlocker : GenericHandler
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
if (config.Sonarr.Enabled)
{
@@ -80,11 +78,6 @@ public sealed class ContentBlocker : GenericHandler
{
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
}
if (config.Readarr.Enabled)
{
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
}
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)

View File

@@ -1,4 +1,3 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
@@ -131,7 +130,6 @@ public sealed class DownloadCleaner : GenericHandler
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr)), InstanceType.Sonarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr)), InstanceType.Radarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr)), InstanceType.Lidarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr)), InstanceType.Readarr, true);
if (isUnlinkedEnabled && downloadServiceWithDownloads.Count > 0)
{

View File

@@ -1,4 +1,3 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
@@ -43,12 +42,10 @@ public sealed class QueueCleaner : GenericHandler
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)

View File

@@ -1,4 +1,4 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
namespace Data.Models.Arr.Queue;
public record Image
{

View File

@@ -1,4 +1,4 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
namespace Data.Models.Arr.Queue;
public record LidarrImage
{

View File

@@ -1,4 +1,4 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
namespace Data.Models.Arr.Queue;
public sealed record QueueAlbum
{

View File

@@ -1,6 +0,0 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record QueueBook
{
public List<ReadarrImage> Images { get; init; } = [];
}

View File

@@ -1,6 +1,4 @@
using Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
namespace Data.Models.Arr.Queue;
public record QueueListResponse
{

View File

@@ -1,4 +1,4 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
namespace Data.Models.Arr.Queue;
public sealed record QueueMovie
{

View File

@@ -1,6 +1,4 @@
using Data.Models.Arr.Queue;
namespace Cleanuparr.Domain.Entities.Arr.Queue;
namespace Data.Models.Arr.Queue;
public sealed record QueueRecord
{
@@ -23,13 +21,6 @@ public sealed record QueueRecord
public QueueAlbum? Album { get; init; }
// Readarr
public long AuthorId { get; init; }
public long BookId { get; init; }
public QueueBook? Book { get; init; }
// common
public required string Title { get; init; }
public string Status { get; init; }

View File

@@ -1,4 +1,4 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
namespace Data.Models.Arr.Queue;
public sealed record QueueSeries
{

View File

@@ -1,8 +0,0 @@
namespace Cleanuparr.Domain.Entities.Arr.Queue;
public sealed record ReadarrImage
{
public required string CoverType { get; init; }
public required Uri Url { get; init; }
}

View File

@@ -1,8 +0,0 @@
namespace Cleanuparr.Domain.Entities.Readarr;
public sealed record Author
{
public long Id { get; set; }
public string AuthorName { get; set; } = string.Empty;
}

View File

@@ -1,12 +0,0 @@
namespace Cleanuparr.Domain.Entities.Readarr;
public sealed record Book
{
public required long Id { get; init; }
public required string Title { get; init; }
public long AuthorId { get; set; }
public Author Author { get; set; } = new();
}

View File

@@ -1,8 +0,0 @@
namespace Cleanuparr.Domain.Entities.Readarr;
public sealed record ReadarrCommand
{
public string Name { get; set; } = string.Empty;
public List<long> BookIds { get; set; } = [];
}

View File

@@ -16,7 +16,6 @@
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MassTransit.Abstractions" Version="8.4.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />

View File

@@ -1,5 +1,6 @@
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Infrastructure.Services;
using Infrastructure.Services;
namespace Cleanuparr.Infrastructure.Extensions;

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Infrastructure.Services;
using Infrastructure.Services;
using QBittorrent.Client;
namespace Cleanuparr.Infrastructure.Extensions;

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Infrastructure.Services;
using Infrastructure.Services;
using Transmission.API.RPC.Entity;
namespace Cleanuparr.Infrastructure.Extensions;

View File

@@ -1,4 +1,3 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;

View File

@@ -8,19 +8,16 @@ public sealed class ArrClientFactory
private readonly ISonarrClient _sonarrClient;
private readonly IRadarrClient _radarrClient;
private readonly ILidarrClient _lidarrClient;
private readonly IReadarrClient _readarrClient;
public ArrClientFactory(
SonarrClient sonarrClient,
RadarrClient radarrClient,
LidarrClient lidarrClient,
ReadarrClient readarrClient
LidarrClient lidarrClient
)
{
_sonarrClient = sonarrClient;
_radarrClient = radarrClient;
_lidarrClient = lidarrClient;
_readarrClient = readarrClient;
}
public IArrClient GetClient(InstanceType type) =>
@@ -29,7 +26,6 @@ public sealed class ArrClientFactory
InstanceType.Sonarr => _sonarrClient,
InstanceType.Radarr => _radarrClient,
InstanceType.Lidarr => _lidarrClient,
InstanceType.Readarr => _readarrClient,
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
}

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr.Queue;
using Microsoft.Extensions.Logging;

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;

View File

@@ -1,5 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IReadarrClient : IArrClient
{
}

View File

@@ -1,5 +1,4 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Lidarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;

View File

@@ -1,5 +1,4 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Radarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;

View File

@@ -1,152 +0,0 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Readarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.Arr;
public class ReadarrClient : ArrClient, IReadarrClient
{
public ReadarrClient(
ILogger<ReadarrClient> logger,
IHttpClientFactory httpClientFactory,
IStriker striker,
IDryRunInterceptor dryRunInterceptor
) : base(logger, httpClientFactory, striker, dryRunInterceptor)
{
}
protected override string GetQueueUrlPath()
{
return "/api/v1/queue";
}
protected override string GetQueueUrlQuery(int page)
{
return $"page={page}&pageSize=200&includeUnknownAuthorItems=true&includeAuthor=true&includeBook=true";
}
protected override string GetQueueDeleteUrlPath(long recordId)
{
return $"/api/v1/queue/{recordId}";
}
protected override string GetQueueDeleteUrlQuery(bool removeFromClient)
{
string query = "blocklist=true&skipRedownload=true&changeCategory=false";
query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return query;
}
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)
{
return;
}
List<long> ids = items.Select(item => item.Id).ToList();
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/command";
ReadarrCommand command = new()
{
Name = "BookSearch",
BookIds = ids,
};
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent(
JsonConvert.SerializeObject(command),
Encoding.UTF8,
"application/json"
);
SetApiKey(request, arrInstance.ApiKey);
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command);
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
}
catch
{
_logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext));
throw;
}
}
public override bool IsRecordValid(QueueRecord record)
{
if (record.AuthorId is 0 || record.BookId is 0)
{
_logger.LogDebug("skip | author id and/or book id missing | {title}", record.Title);
return false;
}
return base.IsRecordValid(record);
}
private static string GetSearchLog(Uri instanceUrl, ReadarrCommand command, bool success, string? logContext)
{
string status = success ? "triggered" : "failed";
string message = logContext ?? $"book ids: {string.Join(',', command.BookIds)}";
return $"book search {status} | {instanceUrl} | {message}";
}
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, ReadarrCommand command)
{
try
{
StringBuilder log = new();
foreach (long bookId in command.BookIds)
{
Book? book = await GetBookAsync(arrInstance, bookId);
if (book is null)
{
return null;
}
log.Append($"[{book.Title}]");
}
return log.ToString();
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to compute log context");
}
return null;
}
private async Task<Book?> GetBookAsync(ArrInstance arrInstance, long bookId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/book/{bookId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<Book>(responseBody);
}
}

View File

@@ -1,5 +1,4 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Sonarr;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;

View File

@@ -95,17 +95,6 @@ public sealed class BlocklistProvider
changedCount++;
}
// Check and update Lidarr blocklist if needed
string readarrHash = GenerateSettingsHash(contentBlockerConfig.Readarr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Readarr, out string? oldReadarrHash) || readarrHash != oldReadarrHash)
{
_logger.LogDebug("Loading Readarr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Readarr, InstanceType.Readarr);
_configHashes[InstanceType.Readarr] = readarrHash;
changedCount++;
}
if (changedCount > 0)
{
_logger.LogInformation("Successfully loaded {count} blocklists", changedCount);

View File

@@ -168,7 +168,9 @@ public sealed class DelugeClient
content.Headers.ContentType = new MediaTypeWithQualityHeaderValue("application/json");
UriBuilder uriBuilder = new(_config.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/json";
uriBuilder.Path = string.IsNullOrEmpty(_config.UrlBase)
? $"{uriBuilder.Path.TrimEnd('/')}/json"
: $"{uriBuilder.Path.TrimEnd('/')}/{_config.UrlBase.TrimStart('/').TrimEnd('/')}/json";
var responseMessage = await _httpClient.PostAsync(uriBuilder.Uri, content);
responseMessage.EnsureSuccessStatusCode();

View File

@@ -238,8 +238,6 @@ public partial class QBitService
}
await _dryRunInterceptor.InterceptAsync(ChangeCategory, download.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
await _eventPublisher.PublishCategoryChanged(download.Category, downloadCleanerConfig.UnlinkedTargetCategory, downloadCleanerConfig.UnlinkedUseTag);
if (downloadCleanerConfig.UnlinkedUseTag)
{
@@ -250,6 +248,8 @@ public partial class QBitService
_logger.LogInformation("category changed for {name}", download.Name);
download.Category = downloadCleanerConfig.UnlinkedTargetCategory;
}
await _eventPublisher.PublishCategoryChanged(download.Category, downloadCleanerConfig.UnlinkedTargetCategory, downloadCleanerConfig.UnlinkedUseTag);
}
}

View File

@@ -53,7 +53,9 @@ public partial class TransmissionService : DownloadService, ITransmissionService
)
{
UriBuilder uriBuilder = new(_downloadClientConfig.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/rpc";
uriBuilder.Path = string.IsNullOrEmpty(_downloadClientConfig.UrlBase)
? $"{uriBuilder.Path.TrimEnd('/')}/rpc"
: $"{uriBuilder.Path.TrimEnd('/')}/{_downloadClientConfig.UrlBase.TrimStart('/').TrimEnd('/')}/rpc";
_client = new Client(
_httpClient,
uriBuilder.Uri.ToString(),

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Context;

View File

@@ -1,4 +1,3 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
@@ -53,6 +52,68 @@ public abstract class GenericHandler : IHandler
_dataContext = dataContext;
}
// /// <summary>
// /// Initialize download services based on configuration
// /// </summary>
// protected async Task<List<IDownloadService>> GetDownloadServices()
// {
// var clients = await _dataContext.DownloadClients
// .AsNoTracking()
// .ToListAsync();
//
// if (clients.Count is 0)
// {
// _logger.LogWarning("No download clients configured");
// return [];
// }
//
// var enabledClients = await _dataContext.DownloadClients
// .Where(c => c.Enabled)
// .ToListAsync();
//
// if (enabledClients.Count == 0)
// {
// _logger.LogWarning("No enabled download clients available");
// return [];
// }
//
// List<IDownloadService> downloadServices = [];
//
// // Add all enabled clients
// foreach (var client in enabledClients)
// {
// try
// {
// var service = _downloadServiceFactory.GetDownloadService(client);
// if (service != null)
// {
// await service.LoginAsync();
// downloadServices.Add(service);
// _logger.LogDebug("Initialized download client: {name}", client.Name);
// }
// else
// {
// _logger.LogWarning("Download client service not available for: {name}", client.Name);
// }
// }
// catch (Exception ex)
// {
// _logger.LogError(ex, "Failed to initialize download client: {name}", client.Name);
// }
// }
//
// if (downloadServices.Count == 0)
// {
// _logger.LogWarning("No valid download clients found");
// }
// else
// {
// _logger.LogDebug("Initialized {count} download clients", downloadServices.Count);
// }
//
// return downloadServices;
// }
public async Task ExecuteAsync()
{
await DataContext.Lock.WaitAsync();
@@ -69,12 +130,9 @@ public abstract class GenericHandler : IHandler
ContextProvider.Set(nameof(InstanceType.Lidarr), await _dataContext.ArrConfigs.AsNoTracking()
.Include(x => x.Instances)
.FirstAsync(x => x.Type == InstanceType.Lidarr));
ContextProvider.Set(nameof(InstanceType.Readarr), await _dataContext.ArrConfigs.AsNoTracking()
.Include(x => x.Instances)
.FirstAsync(x => x.Type == InstanceType.Readarr));
ContextProvider.Set(nameof(QueueCleanerConfig), await _dataContext.QueueCleanerConfigs.AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(ContentBlockerConfig), await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(DownloadCleanerConfig), await _dataContext.DownloadCleanerConfigs.Include(x => x.Categories).AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(DownloadCleanerConfig), await _dataContext.DownloadCleanerConfigs.AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(DownloadClientConfig), await _dataContext.DownloadClients.AsNoTracking()
.Where(x => x.Enabled)
.ToListAsync());
@@ -194,10 +252,6 @@ public abstract class GenericHandler : IHandler
{
Id = record.AlbumId
},
InstanceType.Readarr => new SearchItem
{
Id = record.BookId
},
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
}

View File

@@ -1,5 +1,4 @@
using System.Globalization;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
@@ -167,7 +166,6 @@ public class NotificationPublisher : INotificationPublisher
InstanceType.Sonarr => record.Series?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
InstanceType.Radarr => record.Movie?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
InstanceType.Lidarr => record.Album?.Images?.FirstOrDefault(x => x.CoverType == "cover")?.Url,
InstanceType.Readarr => record.Book?.Images?.FirstOrDefault(x => x.CoverType == "cover")?.Url,
_ => throw new ArgumentOutOfRangeException(nameof(instanceType))
};

View File

@@ -1,16 +0,0 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace Cleanuparr.Infrastructure.Health;
/// <summary>
/// Basic application health check that verifies the application is running
/// </summary>
public class ApplicationHealthCheck : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
// Basic liveness check - if we can execute this, the app is running
return Task.FromResult(HealthCheckResult.Healthy("Application is running"));
}
}

View File

@@ -1,50 +0,0 @@
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Health;
/// <summary>
/// Health check that verifies database connectivity
/// </summary>
public class DatabaseHealthCheck : IHealthCheck
{
private readonly DataContext _dataContext;
private readonly ILogger<DatabaseHealthCheck> _logger;
public DatabaseHealthCheck(DataContext dataContext, ILogger<DatabaseHealthCheck> logger)
{
_dataContext = dataContext;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
// Try to execute a simple query to verify database connectivity
var canConnect = await _dataContext.Database.CanConnectAsync(cancellationToken);
if (!canConnect)
{
return HealthCheckResult.Unhealthy("Cannot connect to database");
}
// Optionally check if database schema is up to date
var pendingMigrations = await _dataContext.Database.GetPendingMigrationsAsync(cancellationToken);
if (pendingMigrations.Any())
{
_logger.LogWarning("Database has pending migrations: {migrations}", string.Join(", ", pendingMigrations));
return HealthCheckResult.Degraded($"Database has {pendingMigrations.Count()} pending migrations");
}
return HealthCheckResult.Healthy("Database connection successful");
}
catch (Exception ex)
{
_logger.LogError(ex, "Database health check failed");
return HealthCheckResult.Unhealthy("Database health check failed", ex);
}
}
}

View File

@@ -1,60 +0,0 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Health;
/// <summary>
/// Health check that verifies download clients are healthy
/// </summary>
public class DownloadClientsHealthCheck : IHealthCheck
{
private readonly IHealthCheckService _healthCheckService;
private readonly ILogger<DownloadClientsHealthCheck> _logger;
public DownloadClientsHealthCheck(IHealthCheckService healthCheckService, ILogger<DownloadClientsHealthCheck> logger)
{
_healthCheckService = healthCheckService;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
// Get current health status of all clients without triggering new checks
var allClientHealth = _healthCheckService.GetAllClientHealth();
if (!allClientHealth.Any())
{
// No clients configured - this might be ok depending on the deployment
return HealthCheckResult.Healthy("No download clients configured");
}
var healthyClients = allClientHealth.Values.Where(h => h.IsHealthy).ToList();
var unhealthyClients = allClientHealth.Values.Where(h => !h.IsHealthy).ToList();
var totalClients = allClientHealth.Count;
if (unhealthyClients.Any())
{
var unhealthyNames = string.Join(", ", unhealthyClients.Select(c => c.ClientName));
var message = $"{unhealthyClients.Count}/{totalClients} download clients unhealthy: {unhealthyNames}";
// If more than half are unhealthy, consider it unhealthy
if (unhealthyClients.Count > totalClients / 2)
{
return HealthCheckResult.Unhealthy(message);
}
// Otherwise, it's degraded
return HealthCheckResult.Degraded(message);
}
return HealthCheckResult.Healthy($"All {totalClients} download clients are healthy");
}
catch (Exception ex)
{
_logger.LogError(ex, "Download clients health check failed");
return HealthCheckResult.Unhealthy("Download clients health check failed", ex);
}
}
}

View File

@@ -1,76 +0,0 @@
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Health;
/// <summary>
/// Health check that verifies file system access to critical directories
/// </summary>
public class FileSystemHealthCheck : IHealthCheck
{
private readonly ILogger<FileSystemHealthCheck> _logger;
public FileSystemHealthCheck(ILogger<FileSystemHealthCheck> logger)
{
_logger = logger;
}
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
var issues = new List<string>();
// Check config directory access
var configPath = ConfigurationPathProvider.GetConfigPath();
if (!CheckDirectoryAccess(configPath, "config"))
{
issues.Add($"Cannot access config directory: {configPath}");
}
// Check current working directory
var currentDir = Directory.GetCurrentDirectory();
if (!CheckDirectoryAccess(currentDir, "working"))
{
issues.Add($"Cannot access working directory: {currentDir}");
}
if (issues.Any())
{
var message = $"File system issues detected: {string.Join(", ", issues)}";
return Task.FromResult(HealthCheckResult.Unhealthy(message));
}
return Task.FromResult(HealthCheckResult.Healthy("File system access verified"));
}
catch (Exception ex)
{
_logger.LogError(ex, "File system health check failed");
return Task.FromResult(HealthCheckResult.Unhealthy("File system health check failed", ex));
}
}
private bool CheckDirectoryAccess(string path, string description)
{
try
{
if (!Directory.Exists(path))
{
_logger.LogWarning("Directory does not exist: {path} ({description})", path, description);
return false;
}
// Try to enumerate directory contents
_ = Directory.GetFiles(path).Take(1).ToList();
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Cannot access {description} directory: {path}", description, path);
return false;
}
}
}

View File

@@ -1,5 +1,6 @@
using System.Net;
using Cleanuparr.Infrastructure.Services;
using Infrastructure.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Options;

View File

@@ -0,0 +1,52 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Helpers;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog.Context;
namespace Cleanuparr.Infrastructure.Logging;
// TODO remove
public class LoggingInitializer : BackgroundService
{
private readonly ILogger<LoggingInitializer> _logger;
private readonly EventPublisher _eventPublisher;
private readonly Random random = new();
public LoggingInitializer(ILogger<LoggingInitializer> logger, EventPublisher eventPublisher)
{
_logger = logger;
_eventPublisher = eventPublisher;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
return;
while (true)
{
using var _ = LogContext.PushProperty(LogProperties.Category,
random.Next(0, 100) > 50 ? InstanceType.Sonarr.ToString() : InstanceType.Radarr.ToString());
try
{
await _eventPublisher.PublishAsync(
random.Next(0, 100) > 50 ? EventType.DownloadCleaned : EventType.StalledStrike,
"This is a very long message to test how it all looks in the frontend. This is just gibberish, but helps us figure out how the layout should be to display messages properly.",
EventSeverity.Important,
data: new { Hash = "hash", Name = "name", StrikeCount = "1", Type = "stalled" });
throw new Exception("test exception");
}
catch (Exception exception)
{
_logger.LogCritical("test critical");
_logger.LogTrace("test trace");
_logger.LogDebug("test debug");
_logger.LogWarning("test warn");
_logger.LogError(exception, "This is a very long message to test how it all looks in the frontend. This is just gibberish, but helps us figure out how the layout should be to display messages properly.");
}
await Task.Delay(10000, stoppingToken);
}
}
}

View File

@@ -9,3 +9,29 @@ public enum JobType
ContentBlocker,
DownloadCleaner
}
/// <summary>
/// Extension methods for JobType enum
/// </summary>
public static class JobTypeExtensions
{
/// <summary>
/// Converts a JobType enum to its string representation
/// </summary>
/// <param name="jobType">The job type to convert</param>
/// <returns>String representation of the job type</returns>
public static string ToJobName(this JobType jobType) => jobType.ToString();
/// <summary>
/// Parses a string to JobType enum
/// </summary>
/// <param name="jobName">The job name to parse</param>
/// <returns>JobType if successful, null if parsing failed</returns>
public static JobType? TryParseJobType(string jobName)
{
if (string.IsNullOrEmpty(jobName))
return null;
return Enum.TryParse<JobType>(jobName, true, out var result) ? result : null;
}
}

View File

@@ -1,7 +1,6 @@
using Cleanuparr.Infrastructure.Models;
using Quartz;
namespace Cleanuparr.Infrastructure.Services.Interfaces;
namespace Infrastructure.Services.Interfaces;
public interface IJobManagementService
{
@@ -9,9 +8,7 @@ public interface IJobManagementService
Task<bool> StopJob(JobType jobType);
Task<bool> PauseJob(JobType jobType);
Task<bool> ResumeJob(JobType jobType);
Task<bool> TriggerJobOnce(JobType jobType);
Task<IReadOnlyList<JobInfo>> GetAllJobs(IScheduler? scheduler = null);
Task<IReadOnlyList<JobInfo>> GetAllJobs();
Task<JobInfo> GetJob(JobType jobType);
Task<bool> UpdateJobSchedule(JobType jobType, JobSchedule schedule);
Task<ITrigger?> GetMainTrigger(JobType jobType);
}

View File

@@ -1,11 +1,10 @@
using System.Collections.Concurrent;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Infrastructure.Services.Interfaces;
using Microsoft.Extensions.Logging;
using Quartz;
using Quartz.Impl.Matchers;
using Quartz.Spi;
namespace Cleanuparr.Infrastructure.Services;
@@ -23,7 +22,7 @@ public class JobManagementService : IJobManagementService
public async Task<bool> StartJob(JobType jobType, JobSchedule? schedule = null, string? directCronExpression = null)
{
string jobName = jobType.ToString();
string jobName = jobType.ToJobName();
string? cronExpression = null;
// Validate and set the cron expression
@@ -60,48 +59,60 @@ public class JobManagementService : IJobManagementService
// Check if job exists, create it if it doesn't
if (!await scheduler.CheckExists(jobKey))
{
_logger.LogError("Job {name} does not exist in scheduler. " +
"Jobs should be created at startup by BackgroundJobManager.", jobName);
return false;
_logger.LogInformation("Job {name} does not exist, creating it", jobName);
// Create the job based on its type
if (!await CreateJobIfNotExists(scheduler, jobType, jobKey))
{
_logger.LogError("Failed to create job {name}", jobName);
return false;
}
}
// Store the job key for later use
_jobKeys.TryAdd(jobName, jobKey);
// Clean up all existing triggers for this job first
await CleanupAllTriggersForJob(scheduler, jobKey);
// If cron expression is provided, create and schedule the main trigger
// If cron expression is provided, update the trigger
if (!string.IsNullOrEmpty(cronExpression))
{
var triggerKey = new TriggerKey($"{jobName}-trigger");
var newTrigger = TriggerBuilder.Create()
.WithIdentity(triggerKey)
.ForJob(jobKey)
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
.Build();
var triggerKey = new TriggerKey($"{jobName}Trigger");
var existingTrigger = await scheduler.GetTrigger(triggerKey);
await scheduler.ScheduleJob(newTrigger);
// Compute next fire time for logging
IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes((IOperableTrigger)newTrigger, null, 1);
_logger.LogInformation("Job {name} scheduled with cron expression '{cronExpression}', next run at {nextRunTime}",
jobName, cronExpression, nextFireTimes.FirstOrDefault().LocalDateTime);
// Optionally trigger immediate execution for startup
// await TriggerJobImmediately(scheduler, jobKey, "startup");
if (existingTrigger != null)
{
var newTrigger = TriggerBuilder.Create()
.WithIdentity(triggerKey)
.ForJob(jobKey)
.WithCronSchedule(cronExpression)
.Build();
await scheduler.RescheduleJob(triggerKey, newTrigger);
}
else
{
var trigger = TriggerBuilder.Create()
.WithIdentity(triggerKey)
.ForJob(jobKey)
.WithCronSchedule(cronExpression)
.Build();
await scheduler.ScheduleJob(trigger);
}
}
else
{
// If no cron expression, create a one-time trigger to run now
var oneTimeTrigger = TriggerBuilder.Create()
.WithIdentity($"{jobName}-onetime-trigger")
.ForJob(jobKey)
.StartNow()
.Build();
await scheduler.ScheduleJob(oneTimeTrigger);
_logger.LogInformation("Job {name} scheduled for immediate one-time execution", jobName);
// If no trigger exists, create a simple one-time trigger
var triggers = await scheduler.GetTriggersOfJob(jobKey);
if (!triggers.Any())
{
var trigger = TriggerBuilder.Create()
.WithIdentity($"{jobName}Trigger")
.ForJob(jobKey)
.StartNow()
.Build();
await scheduler.ScheduleJob(trigger);
}
}
// Resume the job if it's paused
@@ -117,86 +128,21 @@ public class JobManagementService : IJobManagementService
}
/// <summary>
/// Cleans up all existing triggers for a job to ensure a clean state
/// Creates a job in the scheduler if it doesn't exist based on the job type.
/// Note: Since this is in the Infrastructure layer, we cannot directly reference Application layer job types.
/// Job creation is now handled at startup by BackgroundJobManager.
/// </summary>
private async Task CleanupAllTriggersForJob(IScheduler scheduler, JobKey jobKey)
private Task<bool> CreateJobIfNotExists(IScheduler scheduler, JobType jobType, JobKey jobKey)
{
try
{
var existingTriggers = await scheduler.GetTriggersOfJob(jobKey);
foreach (var trigger in existingTriggers)
{
await scheduler.UnscheduleJob(trigger.Key);
_logger.LogDebug("Removed existing trigger {triggerKey} for job {jobKey}",
trigger.Key.Name, jobKey.Name);
}
if (existingTriggers.Any())
{
_logger.LogDebug("Cleaned up {count} existing triggers for job {jobName}",
existingTriggers.Count, jobKey.Name);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error cleaning up triggers for job {jobName}", jobKey.Name);
}
_logger.LogError("Job {jobName} of type {jobType} does not exist in scheduler. " +
"Jobs should be created at startup by BackgroundJobManager, regardless of enabled status.",
jobKey.Name, jobType);
return Task.FromResult(false);
}
/// <summary>
/// Triggers a job immediately with a one-time trigger
/// </summary>
private async Task TriggerJobImmediately(IScheduler scheduler, JobKey jobKey, string reason)
{
try
{
var immediateTrigger = TriggerBuilder.Create()
.WithIdentity($"{jobKey.Name}-immediate-{reason}-{DateTimeOffset.UtcNow.Ticks}")
.ForJob(jobKey)
.StartNow()
.Build();
await scheduler.ScheduleJob(immediateTrigger);
_logger.LogDebug("Triggered job {jobName} immediately for reason: {reason}", jobKey.Name, reason);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to trigger job {jobName} immediately", jobKey.Name);
}
}
/// <summary>
/// Gets the main scheduled trigger for a job (excludes one-time triggers)
/// </summary>
public async Task<ITrigger?> GetMainTrigger(JobType jobType)
{
string jobName = jobType.ToString();
try
{
var scheduler = await _schedulerFactory.GetScheduler();
var jobKey = new JobKey(jobName);
if (!await scheduler.CheckExists(jobKey))
{
return null;
}
// Look for the main trigger (follows our naming convention)
var mainTriggerKey = new TriggerKey($"{jobName}-trigger");
return await scheduler.GetTrigger(mainTriggerKey);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting main trigger for job {jobName}", jobName);
return null;
}
}
public async Task<bool> StopJob(JobType jobType)
{
string jobName = jobType.ToString();
string jobName = jobType.ToJobName();
try
{
var scheduler = await _schedulerFactory.GetScheduler();
@@ -208,8 +154,12 @@ public class JobManagementService : IJobManagementService
return false;
}
// Clean up all triggers for this job (reuse our centralized method)
await CleanupAllTriggersForJob(scheduler, jobKey);
// Unschedule all triggers for this job
var triggers = await scheduler.GetTriggersOfJob(jobKey);
foreach (var trigger in triggers)
{
await scheduler.UnscheduleJob(trigger.Key);
}
_logger.LogInformation("Job {name} stopped successfully", jobName);
return true;
@@ -223,7 +173,7 @@ public class JobManagementService : IJobManagementService
public async Task<bool> PauseJob(JobType jobType)
{
string jobName = jobType.ToString();
string jobName = jobType.ToJobName();
try
{
var scheduler = await _schedulerFactory.GetScheduler();
@@ -248,7 +198,7 @@ public class JobManagementService : IJobManagementService
public async Task<bool> ResumeJob(JobType jobType)
{
string jobName = jobType.ToString();
string jobName = jobType.ToJobName();
try
{
var scheduler = await _schedulerFactory.GetScheduler();
@@ -271,11 +221,11 @@ public class JobManagementService : IJobManagementService
}
}
public async Task<IReadOnlyList<JobInfo>> GetAllJobs(IScheduler? scheduler = null)
public async Task<IReadOnlyList<JobInfo>> GetAllJobs()
{
try
{
scheduler ??= await _schedulerFactory.GetScheduler();
var scheduler = await _schedulerFactory.GetScheduler();
var result = new List<JobInfo>();
var jobGroups = await scheduler.GetJobGroupNames();
@@ -333,7 +283,7 @@ public class JobManagementService : IJobManagementService
public async Task<JobInfo> GetJob(JobType jobType)
{
string jobName = jobType.ToString();
string jobName = jobType.ToJobName();
try
{
var scheduler = await _schedulerFactory.GetScheduler();
@@ -389,37 +339,12 @@ public class JobManagementService : IJobManagementService
}
}
public async Task<bool> TriggerJobOnce(JobType jobType)
{
string jobName = jobType.ToString();
try
{
var scheduler = await _schedulerFactory.GetScheduler();
var jobKey = new JobKey(jobName);
if (!await scheduler.CheckExists(jobKey))
{
_logger.LogError("Job {name} does not exist", jobName);
return false;
}
await TriggerJobImmediately(scheduler, jobKey, "manual");
_logger.LogInformation("Job {name} triggered for one-time execution", jobName);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error triggering job {jobName}", jobName);
return false;
}
}
public async Task<bool> UpdateJobSchedule(JobType jobType, JobSchedule schedule)
{
if (schedule == null)
throw new ArgumentNullException(nameof(schedule));
string jobName = jobType.ToString();
string jobName = jobType.ToJobName();
string cronExpression = schedule.ToCronExpression();
try
@@ -433,18 +358,24 @@ public class JobManagementService : IJobManagementService
return false;
}
// Clean up all existing triggers for this job
await CleanupAllTriggersForJob(scheduler, jobKey);
// Create new trigger with consistent naming
var triggerKey = new TriggerKey($"{jobName}-trigger");
var triggerKey = new TriggerKey($"{jobName}Trigger");
var existingTrigger = await scheduler.GetTrigger(triggerKey);
var newTrigger = TriggerBuilder.Create()
.WithIdentity(triggerKey)
.ForJob(jobKey)
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
.WithSchedule(SimpleScheduleBuilder.RepeatSecondlyForever(10))
.WithCronSchedule(cronExpression)
.Build();
await scheduler.ScheduleJob(newTrigger);
if (existingTrigger != null)
{
await scheduler.RescheduleJob(triggerKey, newTrigger);
}
else
{
await scheduler.ScheduleJob(newTrigger);
}
_logger.LogInformation("Job {name} schedule updated successfully to {cronExpression}", jobName, cronExpression);
return true;

View File

@@ -35,7 +35,7 @@ public static class CronValidationHelper
IOperableTrigger triggerObj = (IOperableTrigger)TriggerBuilder.Create()
.WithIdentity("ValidationTrigger")
.StartNow()
.WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing())
.WithCronSchedule(cronExpression)
.Build();
IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2);

View File

@@ -77,10 +77,6 @@ public class DataContext : DbContext
{
cp.Property(s => s.BlocklistType).HasConversion<LowercaseEnumConverter<BlocklistType>>();
});
entity.ComplexProperty(e => e.Readarr, cp =>
{
cp.Property(s => s.BlocklistType).HasConversion<LowercaseEnumConverter<BlocklistType>>();
});
});
// Configure ArrConfig -> ArrInstance relationship

View File

@@ -1,620 +0,0 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
[DbContext(typeof(DataContext))]
[Migration("20250628231105_AddReadarr")]
partial class AddReadarr
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<short>("FailedImportMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_arr_configs");
b.ToTable("arr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<Guid>("ArrConfigId")
.HasColumnType("TEXT")
.HasColumnName("arr_config_id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_arr_instances");
b.HasIndex("ArrConfigId")
.HasDatabaseName("ix_arr_instances_arr_config_id");
b.ToTable("arr_instances", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("ignore_private");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("sonarr_enabled");
});
b.HasKey("Id")
.HasName("pk_content_blocker_configs");
b.ToTable("content_blocker_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("DownloadCleanerConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_cleaner_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_clean_categories");
b.HasIndex("DownloadCleanerConfigId")
.HasDatabaseName("ix_clean_categories_download_cleaner_config_id");
b.ToTable("clean_categories", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.PrimitiveCollection<string>("UnlinkedCategories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_categories");
b.Property<bool>("UnlinkedEnabled")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_enabled");
b.Property<string>("UnlinkedIgnoredRootDir")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_ignored_root_dir");
b.Property<string>("UnlinkedTargetCategory")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_target_category");
b.Property<bool>("UnlinkedUseTag")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_use_tag");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.HasKey("Id")
.HasName("pk_download_cleaner_configs");
b.ToTable("download_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Host")
.HasColumnType("TEXT")
.HasColumnName("host");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Password")
.HasColumnType("TEXT")
.HasColumnName("password");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<string>("TypeName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type_name");
b.Property<string>("UrlBase")
.HasColumnType("TEXT")
.HasColumnName("url_base");
b.Property<string>("Username")
.HasColumnType("TEXT")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_download_clients");
b.ToTable("download_clients", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("DisplaySupportBanner")
.HasColumnType("INTEGER")
.HasColumnName("display_support_banner");
b.Property<bool>("DryRun")
.HasColumnType("INTEGER")
.HasColumnName("dry_run");
b.Property<string>("EncryptionKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("encryption_key");
b.Property<string>("HttpCertificateValidation")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("http_certificate_validation");
b.Property<ushort>("HttpMaxRetries")
.HasColumnType("INTEGER")
.HasColumnName("http_max_retries");
b.Property<ushort>("HttpTimeout")
.HasColumnType("INTEGER")
.HasColumnName("http_timeout");
b.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<string>("LogLevel")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
b.Property<bool>("SearchEnabled")
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.HasKey("Id")
.HasName("pk_general_configs");
b.ToTable("general_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Key")
.HasColumnType("TEXT")
.HasColumnName("key");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.Property<string>("Url")
.HasColumnType("TEXT")
.HasColumnName("url");
b.HasKey("Id")
.HasName("pk_apprise_configs");
b.ToTable("apprise_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<string>("ChannelId")
.HasColumnType("TEXT")
.HasColumnName("channel_id");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.HasKey("Id")
.HasName("pk_notifiarr_configs");
b.ToTable("notifiarr_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_delete_private");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_ignore_private");
b1.PrimitiveCollection<string>("IgnoredPatterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_ignored_patterns");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
});
b.ComplexProperty<Dictionary<string, object>>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_delete_private");
b1.Property<string>("IgnoreAboveSize")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_ignore_above_size");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("slow_max_strikes");
b1.Property<double>("MaxTime")
.HasColumnType("REAL")
.HasColumnName("slow_max_time");
b1.Property<string>("MinSpeed")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_min_speed");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("slow_reset_strikes_on_progress");
});
b.ComplexProperty<Dictionary<string, object>>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_delete_private");
b1.Property<ushort>("DownloadingMetadataMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_downloading_metadata_max_strikes");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_max_strikes");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("stalled_reset_strikes_on_progress");
});
b.HasKey("Id")
.HasName("pk_queue_cleaner_configs");
b.ToTable("queue_cleaner_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig")
.WithMany("Instances")
.HasForeignKey("ArrConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_arr_instances_arr_configs_arr_config_id");
b.Navigation("ArrConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig")
.WithMany("Categories")
.HasForeignKey("DownloadCleanerConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id");
b.Navigation("DownloadCleanerConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
b.Navigation("Instances");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b =>
{
b.Navigation("Categories");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,57 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddReadarr : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "readarr_blocklist_path",
table: "content_blocker_configs",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "readarr_blocklist_type",
table: "content_blocker_configs",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "readarr_enabled",
table: "content_blocker_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.InsertData(
table: "arr_configs",
columns: new[] { "id", "failed_import_max_strikes", "type" },
values: new object[] { new Guid("013994ea-0a5e-4eed-91b7-271f494b6259"), (short)-1, "readarr" });
migrationBuilder.Sql("UPDATE content_blocker_configs SET readarr_blocklist_type = 'blacklist' WHERE readarr_blocklist_type = ''");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "readarr_blocklist_path",
table: "content_blocker_configs");
migrationBuilder.DropColumn(
name: "readarr_blocklist_type",
table: "content_blocker_configs");
migrationBuilder.DropColumn(
name: "readarr_enabled",
table: "content_blocker_configs");
}
}
}

View File

@@ -143,24 +143,6 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();

View File

@@ -26,14 +26,11 @@ public sealed record ContentBlockerConfig : IJobConfig
public BlocklistSettings Lidarr { get; set; } = new();
public BlocklistSettings Readarr { get; set; } = new();
public void Validate()
{
ValidateBlocklistSettings(Sonarr, "Sonarr");
ValidateBlocklistSettings(Radarr, "Radarr");
ValidateBlocklistSettings(Lidarr, "Lidarr");
ValidateBlocklistSettings(Readarr, "Readarr");
}
private static void ValidateBlocklistSettings(BlocklistSettings settings, string context)

View File

@@ -14,7 +14,6 @@ export const routes: Routes = [
{ path: 'sonarr', loadComponent: () => import('./settings/sonarr/sonarr-settings.component').then(m => m.SonarrSettingsComponent) },
{ path: 'radarr', loadComponent: () => import('./settings/radarr/radarr-settings.component').then(m => m.RadarrSettingsComponent) },
{ path: 'lidarr', loadComponent: () => import('./settings/lidarr/lidarr-settings.component').then(m => m.LidarrSettingsComponent) },
{ path: 'readarr', loadComponent: () => import('./settings/readarr/readarr-settings.component').then(m => m.ReadarrSettingsComponent) },
{ path: 'download-clients', loadComponent: () => import('./settings/download-client/download-client-settings.component').then(m => m.DownloadClientSettingsComponent) },
{ path: 'notifications', loadComponent: () => import('./settings/notification-settings/notification-settings.component').then(m => m.NotificationSettingsComponent) },
];

View File

@@ -23,10 +23,10 @@ export class ApplicationPathService {
*/
getDocumentationBaseUrl(): string {
if (isDevMode()) {
return 'http://localhost:3000/Cleanuparr';
return 'http://localhost:3000';
}
return 'https://cleanuparr.github.io/Cleanuparr';
return 'https://cleanuparr.github.io/cleanuparr';
}
/**
@@ -59,7 +59,7 @@ export class ApplicationPathService {
*/
buildDocumentationUrl(section: string, fieldAnchor?: string): string {
const baseUrl = this.getDocumentationBaseUrl();
let url = `${baseUrl}/docs/configuration/${section}`;
let url = `${baseUrl}/cleanuparr/docs/configuration/${section}`;
if (fieldAnchor) {
url += `#${fieldAnchor}`;

View File

@@ -6,7 +6,6 @@ import { ContentBlockerConfig, JobSchedule as ContentBlockerJobSchedule, Schedul
import { SonarrConfig } from "../../shared/models/sonarr-config.model";
import { RadarrConfig } from "../../shared/models/radarr-config.model";
import { LidarrConfig } from "../../shared/models/lidarr-config.model";
import { ReadarrConfig } from "../../shared/models/readarr-config.model";
import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto } from "../../shared/models/download-client-config.model";
import { ArrInstance, CreateArrInstanceDto } from "../../shared/models/arr-config.model";
import { GeneralConfig } from "../../shared/models/general-config.model";
@@ -328,29 +327,6 @@ export class ConfigurationService {
);
}
/**
* Get Readarr configuration
*/
getReadarrConfig(): Observable<ReadarrConfig> {
return this.http.get<ReadarrConfig>(this.ApplicationPathService.buildApiUrl('/configuration/readarr')).pipe(
catchError((error) => {
console.error("Error fetching Readarr config:", error);
return throwError(() => new Error("Failed to load Readarr configuration"));
})
);
}
/**
* Update Readarr configuration
*/
updateReadarrConfig(config: {failedImportMaxStrikes: number}): Observable<any> {
return this.http.put<any>(this.ApplicationPathService.buildApiUrl('/configuration/readarr'), config).pipe(
catchError((error) => {
console.error("Error updating Readarr config:", error);
return throwError(() => new Error(error.error?.error || "Failed to update Readarr configuration"));
})
);
}
/**
* Get Download Client configuration
*/
@@ -524,42 +500,4 @@ export class ConfigurationService {
})
);
}
// ===== READARR INSTANCE MANAGEMENT =====
/**
* Create a new Readarr instance
*/
createReadarrInstance(instance: CreateArrInstanceDto): Observable<ArrInstance> {
return this.http.post<ArrInstance>(this.ApplicationPathService.buildApiUrl('/configuration/readarr/instances'), instance).pipe(
catchError((error) => {
console.error("Error creating Readarr instance:", error);
return throwError(() => new Error(error.error?.error || "Failed to create Readarr instance"));
})
);
}
/**
* Update a Readarr instance by ID
*/
updateReadarrInstance(id: string, instance: CreateArrInstanceDto): Observable<ArrInstance> {
return this.http.put<ArrInstance>(this.ApplicationPathService.buildApiUrl(`/configuration/readarr/instances/${id}`), instance).pipe(
catchError((error) => {
console.error(`Error updating Readarr instance with ID ${id}:`, error);
return throwError(() => new Error(error.error?.error || `Failed to update Readarr instance with ID ${id}`));
})
);
}
/**
* Delete a Readarr instance by ID
*/
deleteReadarrInstance(id: string): Observable<void> {
return this.http.delete<void>(this.ApplicationPathService.buildApiUrl(`/configuration/readarr/instances/${id}`)).pipe(
catchError((error) => {
console.error(`Error deleting Readarr instance with ID ${id}:`, error);
return throwError(() => new Error(error.error?.error || `Failed to delete Readarr instance with ID ${id}`));
})
);
}
}

View File

@@ -12,7 +12,7 @@
<!-- Sidebar Navigation -->
<nav class="nav-menu">
<!-- Project Sponsors Link -->
<a href="https://cleanuparr.github.io/Cleanuparr/support" class="nav-item sponsor-link" target="_blank" rel="noopener noreferrer">
<a href="https://cleanuparr.github.io/cleanuparr/support" class="nav-item sponsor-link" target="_blank" rel="noopener noreferrer">
<div class="nav-icon-wrapper heart-icon">
<i class="pi pi-heart"></i>
</div>
@@ -47,12 +47,6 @@
</div>
<span>Lidarr</span>
</a>
<a [routerLink]="['/readarr']" class="nav-item" [class.active]="router.url.includes('/readarr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-book"></i>
</div>
<span>Readarr</span>
</a>
<a [routerLink]="['/download-clients']" class="nav-item" [class.active]="router.url.includes('/download-clients')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-download"></i>

View File

@@ -7,6 +7,9 @@
<h2 class="card-title m-0">Content Blocker Configuration</h2>
<span class="card-subtitle">Configure automatic content filtering and blocking</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-ban text-xl"></i>
</div>
</div>
</ng-template>
@@ -137,16 +140,7 @@
<p-accordion [multiple]="false" [value]="activeAccordionIndices" styleClass="mt-3">
<!-- Sonarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="0">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Sonarr Settings
</p-accordion-header>
<p-accordion-header>Sonarr Settings</p-accordion-header>
<p-accordion-content>
<div formGroupName="sonarr">
<div class="field-row">
@@ -207,16 +201,7 @@
<!-- Radarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="1">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Radarr Settings
</p-accordion-header>
<p-accordion-header>Radarr Settings</p-accordion-header>
<p-accordion-content>
<div formGroupName="radarr">
<div class="field-row">
@@ -277,16 +262,7 @@
<!-- Lidarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="2">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Lidarr Settings
</p-accordion-header>
<p-accordion-header>Lidarr Settings</p-accordion-header>
<p-accordion-content>
<div formGroupName="lidarr">
<div class="field-row">
@@ -344,76 +320,6 @@
</div>
</p-accordion-content>
</p-accordion-panel>
<!-- Readarr Settings -->
<p-accordion-panel [disabled]="!contentBlockerForm.get('enabled')?.value" [value]="3">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Readarr Settings
</p-accordion-header>
<p-accordion-content>
<div formGroupName="readarr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('readarr.enabled')"
title="Click for documentation"></i>
Enable Readarr Blocklist
</label>
<div class="field-input">
<p-checkbox formControlName="enabled" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, the Readarr blocklist will be used for content filtering</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('readarr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
</label>
<p-fluid>
<div class="field-input">
<input pInputText formControlName="blocklistPath" placeholder="Path to blocklist file or URL" />
</div>
<small *ngIf="hasNestedError('readarr', 'blocklistPath', 'required')" class="p-error">Path is required when Readarr blocklist is enabled</small>
<small class="form-helper-text">Path to the blocklist file or URL</small>
</p-fluid>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('readarr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
</label>
<div class="field-input">
<p-select
formControlName="blocklistType"
[options]="[
{ label: 'Blacklist', value: 'Blacklist' },
{ label: 'Whitelist', value: 'Whitelist' }
]"
optionLabel="label"
optionValue="value"
appendTo="body"
></p-select>
<small class="form-helper-text"
>Type of blocklist: Blacklist (block matches) or Whitelist (only allow matches)</small
>
</div>
</div>
</div>
</p-accordion-content>
</p-accordion-panel>
</p-accordion>
<!-- Action buttons -->

View File

@@ -149,11 +149,6 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
blocklistPath: [{ value: "", disabled: true }],
blocklistType: [{ value: BlocklistType.Blacklist, disabled: true }],
}),
readarr: this.formBuilder.group({
enabled: [{ value: false, disabled: true }],
blocklistPath: [{ value: "", disabled: true }],
blocklistType: [{ value: BlocklistType.Blacklist, disabled: true }],
}),
});
// Create an effect to update the form when the configuration changes
@@ -174,7 +169,6 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
sonarr: config.sonarr,
radarr: config.radarr,
lidarr: config.lidarr,
readarr: config.readarr,
});
// Update all form control states
@@ -284,7 +278,7 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
}
// Listen for changes to blocklist enabled states
['sonarr', 'radarr', 'lidarr', 'readarr'].forEach(arrType => {
['sonarr', 'radarr', 'lidarr'].forEach(arrType => {
const enabledControl = this.contentBlockerForm.get(`${arrType}.enabled`);
if (enabledControl) {
@@ -354,7 +348,6 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
this.updateBlocklistDependentControls('sonarr', config.sonarr?.enabled || false);
this.updateBlocklistDependentControls('radarr', config.radarr?.enabled || false);
this.updateBlocklistDependentControls('lidarr', config.lidarr?.enabled || false);
this.updateBlocklistDependentControls('readarr', config.readarr?.enabled || false);
}
}
@@ -414,18 +407,15 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
this.contentBlockerForm.get("sonarr.enabled")?.enable({ onlySelf: true });
this.contentBlockerForm.get("radarr.enabled")?.enable({ onlySelf: true });
this.contentBlockerForm.get("lidarr.enabled")?.enable({ onlySelf: true });
this.contentBlockerForm.get("readarr.enabled")?.enable({ onlySelf: true });
// Update dependent controls based on current enabled states
const sonarrEnabled = this.contentBlockerForm.get("sonarr.enabled")?.value || false;
const radarrEnabled = this.contentBlockerForm.get("radarr.enabled")?.value || false;
const lidarrEnabled = this.contentBlockerForm.get("lidarr.enabled")?.value || false;
const readarrEnabled = this.contentBlockerForm.get("readarr.enabled")?.value || false;
this.updateBlocklistDependentControls('sonarr', sonarrEnabled);
this.updateBlocklistDependentControls('radarr', radarrEnabled);
this.updateBlocklistDependentControls('lidarr', lidarrEnabled);
this.updateBlocklistDependentControls('readarr', readarrEnabled);
} else {
// Disable all scheduling controls
cronExpressionControl?.disable();
@@ -450,9 +440,6 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
this.contentBlockerForm.get("lidarr.enabled")?.disable({ onlySelf: true });
this.contentBlockerForm.get("lidarr.blocklistPath")?.disable({ onlySelf: true });
this.contentBlockerForm.get("lidarr.blocklistType")?.disable({ onlySelf: true });
this.contentBlockerForm.get("readarr.enabled")?.disable({ onlySelf: true });
this.contentBlockerForm.get("readarr.blocklistPath")?.disable({ onlySelf: true });
this.contentBlockerForm.get("readarr.blocklistType")?.disable({ onlySelf: true });
// Save current active accordion state before clearing it
this.activeAccordionIndices = [];
@@ -496,11 +483,6 @@ export class ContentBlockerSettingsComponent implements OnDestroy, CanComponentD
blocklistPath: "",
blocklistType: BlocklistType.Blacklist,
},
readarr: formValue.readarr || {
enabled: false,
blocklistPath: "",
blocklistType: BlocklistType.Blacklist,
},
};
// Save the configuration

View File

@@ -7,6 +7,9 @@
<h2 class="card-title m-0">Download Cleaner Configuration</h2>
<span class="card-subtitle">Configure automatic download cleanup</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>
@@ -110,16 +113,7 @@
<p-accordion [multiple]="false" [value]="activeAccordionIndices" styleClass="mt-3">
<!-- Seeding Settings -->
<p-accordion-panel [disabled]="!downloadCleanerForm.get('enabled')?.value" [value]="0">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Seeding Settings
</p-accordion-header>
<p-accordion-header>Seeding Settings</p-accordion-header>
<p-accordion-content>
<!-- Delete Private Option -->
<div class="field-row">
@@ -236,16 +230,7 @@
<!-- Unlinked Download Settings -->
<p-accordion-panel [disabled]="!downloadCleanerForm.get('enabled')?.value" [value]="1">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Unlinked Download Settings
</p-accordion-header>
<p-accordion-header>Unlinked Download Settings</p-accordion-header>
<p-accordion-content>
<div class="field-row">
<label class="field-label">

View File

@@ -25,6 +25,9 @@
<h2 class="card-title m-0">Download Clients</h2>
<span class="card-subtitle">Manage download client instances</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-download text-xl"></i>
</div>
</div>
</ng-template>

View File

@@ -363,9 +363,8 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
const clientType = this.clientForm.get('type')?.value;
const hostControl = this.clientForm.get('host');
const usernameControl = this.clientForm.get('username');
const urlBaseControl = this.clientForm.get('urlBase');
if (!hostControl || !usernameControl || !urlBaseControl) return;
if (!hostControl || !usernameControl) return;
hostControl.setValidators([
Validators.required,
@@ -378,11 +377,6 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
usernameControl.clearValidators();
}
// Set default URL base for Transmission
if (clientType === DownloadClientType.Transmission) {
urlBaseControl.setValue('transmission');
}
// Update validation state
hostControl.updateValueAndValidity();
usernameControl.updateValueAndValidity();

View File

@@ -7,6 +7,9 @@
<h2 class="card-title m-0">General Configuration</h2>
<span class="card-subtitle">Configure general application settings</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>

View File

@@ -22,9 +22,12 @@
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Lidarr Settings</h2>
<h2 class="card-title m-0">Global Settings</h2>
<span class="card-subtitle">Configure general Lidarr integration settings</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>
@@ -70,6 +73,9 @@
<h2 class="card-title m-0">Instances</h2>
<span class="card-subtitle">Manage Lidarr server instances</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>

View File

@@ -10,6 +10,9 @@
<h2 class="card-title m-0">Notification Configuration</h2>
<span class="card-subtitle">Configure notification settings for Notifiarr and Apprise</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-bell text-xl"></i>
</div>
</div>
</ng-template>
@@ -33,11 +36,11 @@
<!-- API Key -->
<div class="field-row">
<label class="field-label">
API Key
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('notifiarr.apiKey')"
title="View documentation for Notifiarr API key setup">
</i>
API Key
</label>
<div class="field-input">
<input type="text" pInputText formControlName="apiKey" inputId="notifiarrApiKey" placeholder="Enter Notifiarr API key" />
@@ -48,10 +51,6 @@
<!-- Channel ID -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('notifiarr.channelId')"
title="View documentation for Discord channel ID setup">
</i>
Channel ID
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('notifiarr.channelId')"
@@ -67,11 +66,11 @@
<!-- Event Triggers -->
<div class="field-row">
<label class="field-label">
Event Triggers
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('eventTriggers')"
title="View documentation for notification event types">
</i>
Event Triggers
</label>
<div class="field-input">
<div class="flex flex-column gap-2">
@@ -113,11 +112,11 @@
<!-- URL -->
<div class="field-row">
<label class="field-label">
URL
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('apprise.url')"
title="View documentation for Apprise server URL setup">
</i>
URL
</label>
<div class="field-input">
<input type="text" pInputText formControlName="url" inputId="appriseUrl" placeholder="Enter Apprise URL" />
@@ -128,11 +127,11 @@
<!-- Key -->
<div class="field-row">
<label class="field-label">
Key
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('apprise.key')"
title="View documentation for Apprise configuration key">
</i>
Key
</label>
<div class="field-input">
<input type="text" pInputText formControlName="key" inputId="appriseKey" placeholder="Enter key" />
@@ -143,11 +142,11 @@
<!-- Event Triggers -->
<div class="field-row">
<label class="field-label">
Event Triggers
<i class="pi pi-info-circle field-info-icon"
(click)="openFieldDocs('eventTriggers')"
title="View documentation for notification event types">
</i>
Event Triggers
</label>
<div class="field-input">
<div class="flex flex-column gap-2">

View File

@@ -7,6 +7,9 @@
<h2 class="card-title m-0">Queue Cleaner Configuration</h2>
<span class="card-subtitle">Configure automatic arr queue cleanup</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>
@@ -113,16 +116,7 @@
<p-accordion [multiple]="false" [value]="activeAccordionIndices" styleClass="mt-3">
<!-- Failed Import Settings -->
<p-accordion-panel [disabled]="!queueCleanerForm.get('enabled')?.value" [value]="0">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Failed Import Settings
</p-accordion-header>
<p-accordion-header>Failed Import Settings</p-accordion-header>
<p-accordion-content>
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
@@ -202,16 +196,7 @@
<!-- Stalled Settings -->
<p-accordion-panel [disabled]="!queueCleanerForm.get('enabled')?.value" [value]="1">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Stalled Download Settings
</p-accordion-header>
<p-accordion-header>Stalled Download Settings</p-accordion-header>
<p-accordion-content>
<div class="field-row" formGroupName="stalled">
<label class="field-label">
@@ -285,16 +270,7 @@
<!-- Downloading Metadata Settings -->
<p-accordion-panel [disabled]="!queueCleanerForm.get('enabled')?.value" [value]="2">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Downloading Metadata Settings (qBittorrent only)
</p-accordion-header>
<p-accordion-header>Downloading Metadata Settings (qBittorrent only)</p-accordion-header>
<p-accordion-content>
<div class="field-row" formGroupName="stalled">
<label class="field-label">
@@ -326,16 +302,7 @@
<!-- Slow Download Settings -->
<p-accordion-panel [disabled]="!queueCleanerForm.get('enabled')?.value" [value]="3">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Slow Download Settings
</p-accordion-header>
<p-accordion-header>Slow Download Settings</p-accordion-header>
<p-accordion-content>
<div class="field-row" formGroupName="slow">
<label class="field-label">

View File

@@ -22,9 +22,12 @@
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Radarr Settings</h2>
<h2 class="card-title m-0">Global Settings</h2>
<span class="card-subtitle">Configure general Radarr integration settings</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>
@@ -70,6 +73,9 @@
<h2 class="card-title m-0">Instances</h2>
<span class="card-subtitle">Manage Radarr server instances</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>

View File

@@ -1,365 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { ReadarrConfig } from '../../shared/models/readarr-config.model';
import { ConfigurationService } from '../../core/services/configuration.service';
import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs';
import { ArrInstance, CreateArrInstanceDto } from '../../shared/models/arr-config.model';
export interface ReadarrConfigState {
config: ReadarrConfig | null;
loading: boolean;
saving: boolean;
error: string | null;
instanceOperations: number;
}
const initialState: ReadarrConfigState = {
config: null,
loading: false,
saving: false,
error: null,
instanceOperations: 0
};
@Injectable()
export class ReadarrConfigStore extends signalStore(
withState(initialState),
withMethods((store, configService = inject(ConfigurationService)) => ({
/**
* Load the Readarr configuration
*/
loadConfig: rxMethod<void>(
pipe => pipe.pipe(
tap(() => patchState(store, { loading: true, error: null })),
switchMap(() => configService.getReadarrConfig().pipe(
tap({
next: (config) => patchState(store, { config, loading: false }),
error: (error) => {
patchState(store, {
loading: false,
error: error.message || 'Failed to load Readarr configuration'
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Save the Readarr global configuration
*/
saveConfig: rxMethod<{failedImportMaxStrikes: number}>(
(globalConfig$: Observable<{failedImportMaxStrikes: number}>) => globalConfig$.pipe(
tap(() => patchState(store, { saving: true, error: null })),
switchMap(globalConfig => configService.updateReadarrConfig(globalConfig).pipe(
tap({
next: () => {
const currentConfig = store.config();
if (currentConfig) {
// Update the local config with the new global settings
patchState(store, {
config: { ...currentConfig, ...globalConfig },
saving: false
});
}
},
error: (error) => {
patchState(store, {
saving: false,
error: error.message || 'Failed to save Readarr configuration'
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Save the Readarr configuration
*/
saveFullConfig: rxMethod<ReadarrConfig>(
(config$: Observable<ReadarrConfig>) => config$.pipe(
tap(() => patchState(store, { saving: true, error: null })),
switchMap(config => configService.updateReadarrConfig(config).pipe(
tap({
next: () => {
patchState(store, {
config,
saving: false
});
},
error: (error) => {
patchState(store, {
saving: false,
error: error.message || 'Failed to save Readarr configuration'
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Update config in the store without saving to the backend
*/
updateConfigLocally(config: Partial<ReadarrConfig>) {
const currentConfig = store.config();
if (currentConfig) {
patchState(store, {
config: { ...currentConfig, ...config }
});
}
},
/**
* Reset any errors
*/
resetError() {
patchState(store, { error: null });
},
// ===== INSTANCE MANAGEMENT =====
/**
* Create a new Readarr instance
*/
createInstance: rxMethod<CreateArrInstanceDto>(
(instance$: Observable<CreateArrInstanceDto>) => instance$.pipe(
tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })),
switchMap(instance => configService.createReadarrInstance(instance).pipe(
tap({
next: (newInstance) => {
const currentConfig = store.config();
if (currentConfig) {
patchState(store, {
config: { ...currentConfig, instances: [...currentConfig.instances, newInstance] },
saving: false,
instanceOperations: store.instanceOperations() - 1
});
}
},
error: (error) => {
patchState(store, {
saving: false,
instanceOperations: store.instanceOperations() - 1,
error: error.message || 'Failed to create Readarr instance'
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Update a Readarr instance by ID
*/
updateInstance: rxMethod<{ id: string, instance: CreateArrInstanceDto }>(
(params$: Observable<{ id: string, instance: CreateArrInstanceDto }>) => params$.pipe(
tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })),
switchMap(({ id, instance }) => configService.updateReadarrInstance(id, instance).pipe(
tap({
next: (updatedInstance) => {
const currentConfig = store.config();
if (currentConfig) {
const updatedInstances = currentConfig.instances.map((inst: ArrInstance) =>
inst.id === id ? updatedInstance : inst
);
patchState(store, {
config: { ...currentConfig, instances: updatedInstances },
saving: false,
instanceOperations: store.instanceOperations() - 1
});
}
},
error: (error) => {
patchState(store, {
saving: false,
instanceOperations: store.instanceOperations() - 1,
error: error.message || `Failed to update Readarr instance with ID ${id}`
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Delete a Readarr instance by ID
*/
deleteInstance: rxMethod<string>(
(id$: Observable<string>) => id$.pipe(
tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })),
switchMap(id => configService.deleteReadarrInstance(id).pipe(
tap({
next: () => {
const currentConfig = store.config();
if (currentConfig) {
const updatedInstances = currentConfig.instances.filter((inst: ArrInstance) => inst.id !== id);
patchState(store, {
config: { ...currentConfig, instances: updatedInstances },
saving: false,
instanceOperations: store.instanceOperations() - 1
});
}
},
error: (error) => {
patchState(store, {
saving: false,
instanceOperations: store.instanceOperations() - 1,
error: error.message || `Failed to delete Readarr instance with ID ${id}`
});
}
}),
catchError(() => EMPTY)
))
)
),
/**
* Save config and then process instance operations sequentially
*/
saveConfigAndInstances: rxMethod<{
config: ReadarrConfig,
instanceOperations: {
creates: CreateArrInstanceDto[],
updates: Array<{ id: string, instance: CreateArrInstanceDto }>,
deletes: string[]
}
}>(
(params$: Observable<{
config: ReadarrConfig,
instanceOperations: {
creates: CreateArrInstanceDto[],
updates: Array<{ id: string, instance: CreateArrInstanceDto }>,
deletes: string[]
}
}>) => params$.pipe(
tap(() => patchState(store, { saving: true, error: null })),
switchMap(({ config, instanceOperations }) => {
// First save the main config
return configService.updateReadarrConfig(config).pipe(
tap(() => {
patchState(store, { config });
}),
switchMap(() => {
// Then process instance operations if any
const { creates, updates, deletes } = instanceOperations;
const totalOperations = creates.length + updates.length + deletes.length;
if (totalOperations === 0) {
patchState(store, { saving: false });
return EMPTY;
}
patchState(store, { instanceOperations: totalOperations });
// Prepare all operations
const createOps = creates.map(instance =>
configService.createReadarrInstance(instance).pipe(
catchError(error => {
console.error('Failed to create Readarr instance:', error);
return of(null);
})
)
);
const updateOps = updates.map(({ id, instance }) =>
configService.updateReadarrInstance(id, instance).pipe(
catchError(error => {
console.error('Failed to update Readarr instance:', error);
return of(null);
})
)
);
const deleteOps = deletes.map(id =>
configService.deleteReadarrInstance(id).pipe(
catchError(error => {
console.error('Failed to delete Readarr instance:', error);
return of(null);
})
)
);
// Execute all operations in parallel
return forkJoin([...createOps, ...updateOps, ...deleteOps]).pipe(
tap({
next: (results) => {
const currentConfig = store.config();
if (currentConfig) {
let updatedInstances = [...currentConfig.instances];
let failedCount = 0;
// Process create results
const createResults = results.slice(0, creates.length);
const successfulCreates = createResults.filter(instance => instance !== null) as ArrInstance[];
updatedInstances = [...updatedInstances, ...successfulCreates];
failedCount += createResults.filter(instance => instance === null).length;
// Process update results
const updateResults = results.slice(creates.length, creates.length + updates.length);
updateResults.forEach((result, index) => {
if (result !== null) {
const instanceIndex = updatedInstances.findIndex(inst => inst.id === updates[index].id);
if (instanceIndex !== -1) {
updatedInstances[instanceIndex] = result as ArrInstance;
}
} else {
failedCount++;
}
});
// Process delete results
const deleteResults = results.slice(creates.length + updates.length);
deleteResults.forEach((result, index) => {
if (result !== null) {
// Delete was successful, remove from array
updatedInstances = updatedInstances.filter(inst => inst.id !== deletes[index]);
} else {
failedCount++;
}
});
patchState(store, {
config: { ...currentConfig, instances: updatedInstances },
saving: false,
instanceOperations: 0,
error: failedCount > 0 ? `${failedCount} operation(s) failed` : null
});
}
},
error: (error) => {
patchState(store, {
saving: false,
instanceOperations: 0,
error: error.message || 'Failed to process instance operations'
});
}
})
);
}),
catchError((error) => {
patchState(store, {
saving: false,
error: error.message || 'Failed to save Readarr configuration'
});
return EMPTY;
})
);
})
)
)
})),
withHooks({
onInit({ loadConfig }) {
loadConfig();
}
})
) {}

View File

@@ -1,230 +0,0 @@
<div class="settings-container">
<div class="flex align-items-center justify-content-between mb-4">
<h1>Readarr</h1>
</div>
<!-- Loading/Error State Component -->
<div class="mb-4">
<app-loading-error-state
*ngIf="readarrLoading() || readarrError()"
[loading]="readarrLoading()"
[error]="readarrError()"
loadingMessage="Loading settings..."
errorMessage="Could not connect to server"
></app-loading-error-state>
</div>
<!-- Content - only shown when not loading and no error -->
<div *ngIf="!readarrLoading() && !readarrError()">
<!-- Global Configuration Card -->
<p-card styleClass="settings-card mb-4">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Readarr Settings</h2>
<span class="card-subtitle">Configure general Readarr integration settings</span>
</div>
</div>
</ng-template>
<form [formGroup]="globalForm" class="p-fluid">
<div class="field-row">
<label class="field-label">Failed Import Max Strikes</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="failedImportMaxStrikes"
[min]="-1"
[showButtons]="true"
buttonLayout="horizontal"
incrementButtonIcon="pi pi-plus"
decrementButtonIcon="pi pi-minus"
></p-inputNumber>
</div>
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
</div>
</div>
<!-- Save Button -->
<div class="card-footer mt-3">
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary"
[disabled]="!globalForm.dirty || !hasGlobalChanges || globalForm.invalid || readarrSaving()"
[loading]="readarrSaving()"
(click)="saveGlobalConfig()"
></button>
</div>
</form>
</p-card>
<!-- Instance Management Card -->
<p-card styleClass="settings-card mb-4">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Instances</h2>
<span class="card-subtitle">Manage Readarr server instances</span>
</div>
</div>
</ng-template>
<!-- Empty state when no instances -->
<div *ngIf="instances.length === 0" class="empty-instances-message p-3 text-center">
<i class="pi pi-inbox empty-icon"></i>
<p>No Readarr instances configured</p>
<small>Add an instance to start using Readarr integration</small>
</div>
<!-- Instances List -->
<div *ngIf="instances.length > 0" class="instances-list">
<div *ngFor="let instance of instances" class="instance-item">
<div class="instance-header">
<div class="instance-title">
<i class="pi pi-server instance-icon"></i>
<span class="instance-name">{{ instance.name }}</span>
</div>
<div class="instance-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-text p-button-sm"
[disabled]="readarrSaving()"
(click)="openEditInstanceModal(instance)"
pTooltip="Edit instance"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-text p-button-sm p-button-danger"
[disabled]="readarrSaving()"
(click)="deleteInstance(instance)"
pTooltip="Delete instance"
></button>
</div>
</div>
<div class="instance-content">
<div class="instance-field">
<label>{{ instance.url }}</label>
</div>
<div class="instance-field">
<label>Status:
<span [class]="instance.enabled ? 'text-green-500' : 'text-red-500'">
{{ instance.enabled ? 'Enabled' : 'Disabled' }}
</span>
</label>
</div>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="card-footer mt-3">
<button
pButton
type="button"
icon="pi pi-plus"
label="Add Instance"
class="p-button-outlined"
[disabled]="readarrSaving()"
(click)="openAddInstanceModal()"
></button>
</div>
</p-card>
</div>
</div>
<!-- Instance Modal -->
<p-dialog
[(visible)]="showInstanceModal"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="instance-modal"
[header]="modalTitle"
(onHide)="closeInstanceModal()"
>
<form [formGroup]="instanceForm" class="p-fluid instance-form">
<div class="field flex flex-row">
<label class="field-label">Enabled</label>
<div class="field-input">
<p-checkbox formControlName="enabled" [binary]="true"></p-checkbox>
<small class="form-helper-text">Enable this Readarr instance</small>
</div>
</div>
<div class="field">
<label for="instance-name">Name *</label>
<input
id="instance-name"
type="text"
pInputText
formControlName="name"
placeholder="My Readarr Instance"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="p-error">Name is required</small>
</div>
<div class="field">
<label for="instance-url">URL *</label>
<input
id="instance-url"
type="text"
pInputText
formControlName="url"
placeholder="http://localhost:8787"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="p-error">URL is required</small>
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="p-error">URL must be a valid URL</small>
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="p-error">URL must use http or https protocol</small>
</div>
<div class="field">
<label for="instance-apikey">API Key *</label>
<input
id="instance-apikey"
type="password"
pInputText
formControlName="apiKey"
placeholder="Your Readarr API key"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="p-error">API key is required</small>
</div>
</form>
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeInstanceModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary ml-2"
[disabled]="instanceForm.invalid || readarrSaving()"
[loading]="readarrSaving()"
(click)="saveInstance()"
></button>
</div>
</ng-template>
</p-dialog>
<!-- Confirmation Dialog -->
<p-confirmDialog></p-confirmDialog>

View File

@@ -1,5 +0,0 @@
/* Readarr Settings Styles */
@use '../styles/settings-shared.scss';
@use '../styles/arr-shared.scss';
@use '../settings-page/settings-page.component.scss';

View File

@@ -1,415 +0,0 @@
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { ReadarrConfigStore } from "./readarr-config.store";
import { CanComponentDeactivate } from "../../core/guards";
import { ReadarrConfig } from "../../shared/models/readarr-config.model";
import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model";
// PrimeNG Components
import { CardModule } from "primeng/card";
import { InputTextModule } from "primeng/inputtext";
import { CheckboxModule } from "primeng/checkbox";
import { ButtonModule } from "primeng/button";
import { InputNumberModule } from "primeng/inputnumber";
import { ToastModule } from "primeng/toast";
import { DialogModule } from "primeng/dialog";
import { ConfirmDialogModule } from "primeng/confirmdialog";
import { ConfirmationService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
@Component({
selector: "app-readarr-settings",
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
CardModule,
InputTextModule,
CheckboxModule,
ButtonModule,
InputNumberModule,
ToastModule,
DialogModule,
ConfirmDialogModule,
LoadingErrorStateComponent,
],
providers: [ReadarrConfigStore, ConfirmationService],
templateUrl: "./readarr-settings.component.html",
styleUrls: ["./readarr-settings.component.scss"],
})
export class ReadarrSettingsComponent implements OnDestroy, CanComponentDeactivate {
@Output() saved = new EventEmitter<void>();
@Output() error = new EventEmitter<string>();
// Forms
globalForm: FormGroup;
instanceForm: FormGroup;
// Modal state
showInstanceModal = false;
modalMode: 'add' | 'edit' = 'add';
editingInstance: ArrInstance | null = null;
// Original form values for tracking changes
private originalGlobalValues: any;
hasGlobalChanges = false;
// Clean up subscriptions
private destroy$ = new Subject<void>();
// Services
private formBuilder = inject(FormBuilder);
private notificationService = inject(NotificationService);
private confirmationService = inject(ConfirmationService);
private readarrStore = inject(ReadarrConfigStore);
// Signals from store
readarrConfig = this.readarrStore.config;
readarrLoading = this.readarrStore.loading;
readarrError = this.readarrStore.error;
readarrSaving = this.readarrStore.saving;
/**
* Check if component can be deactivated (navigation guard)
*/
canDeactivate(): boolean {
return !this.globalForm?.dirty || !this.hasGlobalChanges;
}
constructor() {
// Initialize forms
this.globalForm = this.formBuilder.group({
failedImportMaxStrikes: [-1],
});
this.instanceForm = this.formBuilder.group({
enabled: [true],
name: ['', Validators.required],
url: ['', [Validators.required, this.uriValidator.bind(this)]],
apiKey: ['', Validators.required],
});
// Load Readarr config data
this.readarrStore.loadConfig();
// Setup effect to update form when config changes
effect(() => {
const config = this.readarrConfig();
if (config) {
this.updateGlobalFormFromConfig(config);
}
});
// Track global form changes
this.globalForm.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.hasGlobalChanges = this.globalFormValuesChanged();
});
}
/**
* Clean up subscriptions when component is destroyed
*/
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Update global form with values from the configuration
*/
private updateGlobalFormFromConfig(config: ReadarrConfig): void {
this.globalForm.patchValue({
failedImportMaxStrikes: config.failedImportMaxStrikes,
});
// Store original values for dirty checking
this.storeOriginalGlobalValues();
}
/**
* Store original global form values for dirty checking
*/
private storeOriginalGlobalValues(): void {
this.originalGlobalValues = JSON.parse(JSON.stringify(this.globalForm.value));
this.globalForm.markAsPristine();
this.hasGlobalChanges = false;
}
/**
* Check if the current global form values are different from the original values
*/
private globalFormValuesChanged(): boolean {
return !this.isEqual(this.globalForm.value, this.originalGlobalValues);
}
/**
* Deep compare two objects for equality
*/
private isEqual(obj1: any, obj2: any): boolean {
if (obj1 === obj2) return true;
if (typeof obj1 !== "object" || typeof obj2 !== "object" || obj1 == null || obj2 == null) {
return false;
}
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
const val1 = obj1[key];
const val2 = obj2[key];
const areObjects = typeof val1 === "object" && typeof val2 === "object";
if ((areObjects && !this.isEqual(val1, val2)) || (!areObjects && val1 !== val2)) {
return false;
}
}
return true;
}
/**
* Custom validator to check if the input is a valid URI
*/
private uriValidator(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null; // Let required validator handle empty values
}
try {
const url = new URL(control.value);
// Check that we have a valid protocol (http or https)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { invalidProtocol: true };
}
return null; // Valid URI
} catch (e) {
return { invalidUri: true }; // Invalid URI
}
}
/**
* Mark all controls in a form group as touched
*/
private markFormGroupTouched(formGroup: FormGroup): void {
Object.values(formGroup.controls).forEach((control) => {
control.markAsTouched();
if ((control as any).controls) {
this.markFormGroupTouched(control as FormGroup);
}
});
}
/**
* Check if a form control has an error
*/
hasError(form: FormGroup, controlName: string, errorName: string): boolean {
const control = form.get(controlName);
return control !== null && control.hasError(errorName) && control.touched;
}
/**
* Save the global Readarr configuration
*/
saveGlobalConfig(): void {
this.markFormGroupTouched(this.globalForm);
if (this.globalForm.invalid) {
this.notificationService.showError('Please fix the validation errors before saving');
return;
}
if (!this.hasGlobalChanges) {
this.notificationService.showSuccess('No changes detected');
return;
}
const updatedConfig = {
failedImportMaxStrikes: this.globalForm.get('failedImportMaxStrikes')?.value
};
this.readarrStore.saveConfig(updatedConfig);
// Monitor saving completion
this.monitorGlobalSaving();
}
/**
* Monitor global saving completion
*/
private monitorGlobalSaving(): void {
const checkSavingStatus = () => {
const saving = this.readarrSaving();
const error = this.readarrError();
if (!saving) {
if (error) {
this.notificationService.showError(`Save failed: ${error}`);
this.error.emit(error);
} else {
this.notificationService.showSuccess('Global configuration saved successfully');
this.saved.emit();
// Reset form state without reloading from backend
this.globalForm.markAsPristine();
this.hasGlobalChanges = false;
this.storeOriginalGlobalValues();
}
} else {
setTimeout(checkSavingStatus, 100);
}
};
setTimeout(checkSavingStatus, 100);
}
/**
* Get instances from current config
*/
get instances(): ArrInstance[] {
return this.readarrConfig()?.instances || [];
}
/**
* Open modal to add new instance
*/
openAddInstanceModal(): void {
this.modalMode = 'add';
this.editingInstance = null;
this.instanceForm.reset({
enabled: true,
name: '',
url: '',
apiKey: ''
});
this.showInstanceModal = true;
}
/**
* Open modal to edit existing instance
*/
openEditInstanceModal(instance: ArrInstance): void {
this.modalMode = 'edit';
this.editingInstance = instance;
this.instanceForm.patchValue({
enabled: instance.enabled,
name: instance.name,
url: instance.url,
apiKey: instance.apiKey,
});
this.showInstanceModal = true;
}
/**
* Close instance modal
*/
closeInstanceModal(): void {
this.showInstanceModal = false;
this.editingInstance = null;
this.instanceForm.reset();
}
/**
* Save instance (add or edit)
*/
saveInstance(): void {
this.markFormGroupTouched(this.instanceForm);
if (this.instanceForm.invalid) {
this.notificationService.showError('Please fix the validation errors before saving');
return;
}
const instanceData: CreateArrInstanceDto = {
enabled: this.instanceForm.get('enabled')?.value,
name: this.instanceForm.get('name')?.value,
url: this.instanceForm.get('url')?.value,
apiKey: this.instanceForm.get('apiKey')?.value,
};
if (this.modalMode === 'add') {
this.readarrStore.createInstance(instanceData);
} else if (this.editingInstance) {
this.readarrStore.updateInstance({
id: this.editingInstance.id!,
instance: instanceData
});
}
this.monitorInstanceSaving();
}
/**
* Monitor instance saving completion
*/
private monitorInstanceSaving(): void {
const checkSavingStatus = () => {
const saving = this.readarrSaving();
const error = this.readarrError();
if (!saving) {
if (error) {
this.notificationService.showError(`Operation failed: ${error}`);
} else {
const action = this.modalMode === 'add' ? 'created' : 'updated';
this.notificationService.showSuccess(`Instance ${action} successfully`);
this.closeInstanceModal();
}
} else {
setTimeout(checkSavingStatus, 100);
}
};
setTimeout(checkSavingStatus, 100);
}
/**
* Delete instance with confirmation
*/
deleteInstance(instance: ArrInstance): void {
this.confirmationService.confirm({
message: `Are you sure you want to delete the instance "${instance.name}"?`,
header: 'Confirm Deletion',
icon: 'pi pi-exclamation-triangle',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.readarrStore.deleteInstance(instance.id!);
// Monitor deletion
const checkDeletionStatus = () => {
const saving = this.readarrSaving();
const error = this.readarrError();
if (!saving) {
if (error) {
this.notificationService.showError(`Deletion failed: ${error}`);
} else {
this.notificationService.showSuccess('Instance deleted successfully');
}
} else {
setTimeout(checkDeletionStatus, 100);
}
};
setTimeout(checkDeletionStatus, 100);
}
});
}
/**
* Get modal title based on mode
*/
get modalTitle(): string {
return this.modalMode === 'add' ? 'Add Readarr Instance' : 'Edit Readarr Instance';
}
}

View File

@@ -22,9 +22,12 @@
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Sonarr Settings</h2>
<h2 class="card-title m-0">Global Settings</h2>
<span class="card-subtitle">Configure general Sonarr integration settings</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>
@@ -70,6 +73,9 @@
<h2 class="card-title m-0">Instances</h2>
<span class="card-subtitle">Manage Sonarr server instances</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
</div>
</div>
</ng-template>

View File

@@ -25,7 +25,7 @@
</div>
<!-- Donation Link Placeholder -->
<a href="https://cleanuparr.github.io/Cleanuparr/support" class="support-link donate-link" target="_blank" rel="noopener noreferrer">
<a href="#" class="support-link donate-link" (click)="onDonateClick($event)">
<div class="support-icon donate-icon">
<i class="pi pi-heart-fill"></i>
</div>

View File

@@ -17,4 +17,10 @@ import { TagModule } from 'primeng/tag';
styleUrl: './support-section.component.scss'
})
export class SupportSectionComponent {
onDonateClick(event: Event): void {
event.preventDefault();
// TODO: Navigate to donation page when implemented
console.log('Donation functionality coming soon!');
}
}

View File

@@ -41,5 +41,4 @@ export interface ContentBlockerConfig {
sonarr: BlocklistSettings;
radarr: BlocklistSettings;
lidarr: BlocklistSettings;
readarr: BlocklistSettings;
}

View File

@@ -1,14 +0,0 @@
/**
* ReadarrConfig model definitions for the UI
* These models represent the structures used in the API for Readarr configuration
*/
import { ArrInstance } from "./arr-config.model";
/**
* Main ReadarrConfig model representing the configuration for Readarr integration
*/
export interface ReadarrConfig {
failedImportMaxStrikes: number;
instances: ArrInstance[];
}

View File

@@ -39,4 +39,3 @@ $ GIT_USER=<Your GitHub username> yarn deploy
```
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.

View File

@@ -18,7 +18,7 @@ Cleanuparr integrates with popular *arr applications and download clients for co
| **Category** | **Application** | **Integration** |
|--------------|-----------------|-----------------|
| **Media Management** | Sonarr, Radarr, Lidarr, Readarr | Full API integration for queue monitoring and search triggers |
| **Media Management** | Sonarr, Radarr, Lidarr, Readarr, Whisparr | Full API integration for queue monitoring and search triggers |
| **Download Clients** | qBittorrent, Deluge, Transmission | Complete download management and monitoring |
</div>

View File

@@ -25,6 +25,7 @@ These settings need a download client to be configured.
<ConfigSection
id="enable-content-blocker"
title="Enable Content Blocker"
description="When enabled, the Content Blocker will run according to the configured schedule to automatically block or remove downloads based on the configured blocklists."
icon="🔄"
>
@@ -35,6 +36,7 @@ When enabled, the Content Blocker will run according to the configured schedule
<ConfigSection
id="scheduling-mode"
title="Scheduling Mode"
description="Choose how to configure the Content Blocker schedule"
icon="📅"
>
@@ -47,6 +49,7 @@ Choose how to configure the Content Blocker schedule:
<ConfigSection
id="cron-expression"
title="Cron Expression"
description="Enter a valid Quartz.NET cron expression to control when the Content Blocker runs"
icon="⏲️"
>
@@ -62,6 +65,7 @@ Enter a valid Quartz.NET cron expression to control when the Content Blocker run
<ConfigSection
id="ignore-private"
title="Ignore Private"
description="When enabled, private torrents will be skipped from being processed during content blocking"
icon="🔒"
>
@@ -72,6 +76,7 @@ When enabled, private torrents will be skipped from being processed during conte
<ConfigSection
id="delete-private"
title="Delete Private"
description="When enabled, private torrents that match blocklist criteria will be deleted from the download client"
icon="🗑️"
>
@@ -99,6 +104,7 @@ Setting this to true means private torrents will be permanently deleted, potenti
<ConfigSection
id="enable-blocklist"
title="Enable Blocklist"
description="When enabled, the Content Blocker will use the configured blocklist to filter content"
icon="✅"
>
@@ -109,6 +115,7 @@ When enabled, the Content Blocker will use the configured blocklist to filter co
<ConfigSection
id="blocklist-path"
title="Blocklist Path"
description="Path to the blocklist file or URL. This can be a local file path or a remote URL that will be fetched automatically"
icon="📂"
>
@@ -132,6 +139,7 @@ regex:<ANY_REGEX> // regex that needs to be marked at the start of the line wi
<ConfigSection
id="blocklist-type"
title="Blocklist Type"
description="Controls how the blocklist is interpreted"
icon="🎭"
>

View File

@@ -26,6 +26,7 @@ These settings need a download client to be configured.
<ConfigSection
id="enable-download-cleaner"
title="Enable Download Cleaner"
description="When enabled, the Download Cleaner will run according to the configured schedule to automatically clean completed downloads from your download client."
icon="🔄"
>
@@ -36,6 +37,7 @@ When enabled, the Download Cleaner will run according to the configured schedule
<ConfigSection
id="scheduling-mode"
title="Scheduling Mode"
description="Choose how to configure the Download Cleaner schedule"
icon="📅"
>
@@ -48,6 +50,7 @@ Choose how to configure the Download Cleaner schedule:
<ConfigSection
id="cron-expression"
title="Cron Expression"
description="Enter a valid Quartz.NET cron expression to control when the Download Cleaner runs"
icon="⏲️"
>
@@ -76,6 +79,7 @@ Enter a valid Quartz.NET cron expression to control when the Download Cleaner ru
<ConfigSection
id="delete-private-torrents"
title="Delete Private Torrents"
description="When enabled, private torrents will be deleted from the download client when they meet the cleanup criteria"
icon="🗑️"
>
@@ -111,6 +115,7 @@ Both Max Ratio and Max Seed Time cannot be disabled (-1) at the same time. At le
<ConfigSection
id="category-name"
title="Category Name"
description="The name of the download client category to apply these rules to"
icon="🏷️"
>
@@ -126,6 +131,7 @@ The name of the download client category to apply these rules to. Must match the
<ConfigSection
id="max-ratio"
title="Max Ratio"
description="Maximum ratio to seed before considering the download for removal"
icon="📊"
>
@@ -136,6 +142,7 @@ Maximum ratio to seed before considering the download for removal. Set to `-1` t
<ConfigSection
id="min-seed-time"
title="Min Seed Time (hours)"
description="Minimum time in hours to seed before removing a download that has reached the max ratio"
icon="⏰"
>
@@ -146,6 +153,7 @@ Minimum time in hours to seed before removing a download that has reached the ma
<ConfigSection
id="max-seed-time"
title="Max Seed Time (hours)"
description="Maximum time in hours to seed before removing a download regardless of ratio"
icon="⏳"
>
@@ -169,6 +177,7 @@ Maximum time in hours to seed before removing a download regardless of ratio. Se
<ConfigSection
id="enable-unlinked-download-handling"
title="Enable Unlinked Download Handling"
description="Enable management of downloads that have no hardlinks remaining"
icon="🔍"
>
@@ -183,6 +192,7 @@ If you are using Docker, make sure to mount the downloads directory the same way
<ConfigSection
id="target-category"
title="Target Category"
description="Category to move unlinked downloads to"
icon="🎯"
>
@@ -193,6 +203,7 @@ Category to move unlinked downloads to.
<ConfigSection
id="use-tag"
title="Use Tag"
description="When enabled, uses a tag instead of category for marking unlinked downloads (qBittorrent only)"
icon="🏷️"
>
@@ -203,6 +214,7 @@ When enabled, uses a tag instead of category for marking unlinked downloads (qBi
<ConfigSection
id="ignored-root-directory"
title="Ignored Root Directory"
description="Root directory to ignore when checking for unlinked downloads"
icon="📁"
>
@@ -224,6 +236,7 @@ For the example above, the ignored root directory should be set to `/data/downlo
<ConfigSection
id="unlinked-categories"
title="Unlinked Categories"
description="Categories to check for unlinked downloads"
icon="📋"
>

View File

@@ -18,6 +18,7 @@ Configure download client connections for torrents and usenet. Cleanuparr suppor
<ConfigSection
id="enable-download-client"
title="Enable Download Client"
description="Controls whether this download client instance is active and will be used by Cleanuparr for operations."
icon="🔄"
>
@@ -36,6 +37,7 @@ Controls whether this download client instance is active and will be used by Cle
<ConfigSection
id="client-name"
title="Client Name"
description="A descriptive name to identify this download client instance in the Cleanuparr interface."
icon="🏷️"
>
@@ -46,6 +48,7 @@ A descriptive name to identify this download client instance in the Cleanuparr i
<ConfigSection
id="client-type"
title="Client Type"
description="Specifies which download client software this configuration connects to."
icon="📱"
>
@@ -65,6 +68,7 @@ Specifies which download client software this configuration connects to.
<ConfigSection
id="client-host"
title="Client Host"
description="The complete URL to access your download client's web interface."
icon="🖥️"
>
@@ -83,6 +87,7 @@ The complete URL to access your download client's web interface.
<ConfigSection
id="url-base-path"
title="URL Base Path"
description="URL path prefix if your download client runs behind a reverse proxy with a subpath."
icon="📂"
>
@@ -112,6 +117,7 @@ URL path prefix if your download client runs behind a reverse proxy with a subpa
<ConfigSection
id="username"
title="Username"
description="Username for download client authentication if required."
icon="👤"
>
@@ -122,6 +128,7 @@ Username for download client authentication if required.
<ConfigSection
id="password"
title="Password"
description="Password for download client authentication."
icon="🔑"
>

View File

@@ -1,195 +0,0 @@
---
sidebar_position: 8
---
import { Important, Warning } from '@site/src/components/Admonition';
import {
ConfigSection,
EnhancedImportant,
EnhancedWarning,
styles
} from '@site/src/components/documentation';
# Health Checks
Cleanuparr provides comprehensive health check endpoints that are compatible with Docker health checks and Kubernetes probes. This allows for better monitoring, automated failover, and container orchestration.
<div className={styles.documentationPage}>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>🏥</span>
Health Check Endpoints
</h2>
<ConfigSection
id="health-liveness"
title="/health - Liveness Probe"
icon="💓"
>
**Purpose**: Basic liveness check to verify the application is running
**Use Case**: Docker HEALTHCHECK and Kubernetes liveness probes
**Response**: HTTP 200 (healthy) or 503 (unhealthy)
**Checks**: Application startup and basic functionality
```bash
curl http://localhost:11011/health
```
</ConfigSection>
<ConfigSection
id="health-readiness"
title="/health/ready - Readiness Probe"
icon="✅"
>
**Purpose**: Verify the application is ready to serve traffic
**Use Case**: Kubernetes readiness probes and load balancer health checks
**Response**: HTTP 200 (ready) or 503 (not ready)
**Checks**: Database connectivity, file system access, download client health
```bash
curl http://localhost:11011/health/ready
```
</ConfigSection>
<ConfigSection
id="health-detailed"
title="/health/detailed - Detailed Status"
icon="📊"
>
**Purpose**: Comprehensive health status for monitoring and debugging
**Use Case**: Monitoring systems and troubleshooting
**Response**: Detailed JSON with status of all components
**Checks**: All health checks with timing and detailed status
```bash
curl http://localhost:11011/health/detailed
```
</ConfigSection>
</div>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>🔍</span>
Health Check Components
</h2>
<ConfigSection
id="application-health"
title="Application Health"
icon="🚀"
>
- Verifies the application is running and responsive
- Basic functionality test
- Used for liveness probes
</ConfigSection>
<ConfigSection
id="database-health"
title="Database Health"
icon="🗄️"
>
- Tests database connectivity
- Checks for pending migrations
- Validates schema integrity
- Used for readiness probes
</ConfigSection>
<ConfigSection
id="filesystem-health"
title="File System Health"
icon="📁"
>
- Verifies access to configuration directories
- Validates working directory access
- Used for readiness probes
<EnhancedImportant>
File write tests are not performed during health checks to avoid creating temporary files on every check.
</EnhancedImportant>
</ConfigSection>
<ConfigSection
id="download-client-health"
title="Download Client Health"
icon="⬇️"
>
- Integrates with existing download client monitoring
- Reports status of all configured download clients
- Considers overall health based on client availability
- Used for readiness probes
</ConfigSection>
</div>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>🐳</span>
Environment Variables
</h2>
<ConfigSection
id="health-environment-variables"
title="Environment Variables"
icon="🌍"
>
Health check endpoints are affected by these environment variables:
- **`PORT`**: Application port (default: 11011)
- **`BASE_PATH`**: Base path for the application (affects health check URLs)
If you set `BASE_PATH=/cleanuparr`, health checks will be available at:
- `localhost:[PORT]/cleanuparr/health`
- `localhost:[PORT]/cleanuparr/health/ready`
- `localhost:[PORT]/cleanuparr/health/detailed`
</ConfigSection>
</div>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<span className={styles.sectionIcon}>🔧</span>
Troubleshooting
</h2>
<ConfigSection
id="health-common-issues"
title="Common Issues"
icon="⚠️"
>
**Health check timeout**
Increase timeout values if the application is slow to respond
**File system permission errors**
Check container user permissions and mount points
**Download client unavailability**
Review download client configurations and network connectivity
</ConfigSection>
</div>
</div>

View File

@@ -29,6 +29,7 @@ Configure notification services to receive alerts about Cleanuparr operations.
<ConfigSection
id="notifiarr-api-key"
title="Notifiarr API Key"
description="Your Notifiarr API key for authentication. This key is obtained from your Notifiarr dashboard."
icon="🔑"
>
@@ -43,6 +44,7 @@ Requires Notifiarr's [Passthrough](https://notifiarr.wiki/pages/integrations/pas
<ConfigSection
id="notifiarr-channel-id"
title="Notifiarr Channel ID"
description="The Discord channel ID where notifications will be sent. This determines the destination for your alerts."
icon="💬"
>
@@ -66,6 +68,7 @@ The Discord channel ID where notifications will be sent. This determines the des
<ConfigSection
id="apprise-url"
title="Apprise URL"
description="The Apprise server URL where notification requests will be sent."
icon="🌐"
>
@@ -76,6 +79,7 @@ The Apprise server URL where notification requests will be sent.
<ConfigSection
id="apprise-key"
title="Apprise Key"
description="The key that identifies your Apprise configuration. This corresponds to a configuration defined in your Apprise server."
icon="🔐"
>
@@ -95,6 +99,7 @@ The key that identifies your Apprise configuration. This corresponds to a config
<ConfigSection
id="event-failed-import-strike"
title="Failed Import Strike"
description="Triggered When: A download receives a strike for failed import."
icon="❌"
>
@@ -105,6 +110,7 @@ The key that identifies your Apprise configuration. This corresponds to a config
<ConfigSection
id="event-stalled-strike"
title="Stalled Strike"
description="Triggered When: A download receives a strike for being stalled."
icon="⏸️"
>
@@ -115,6 +121,7 @@ The key that identifies your Apprise configuration. This corresponds to a config
<ConfigSection
id="event-slow-strike"
title="Slow Strike"
description="Triggered When: A download receives a strike for slow speed."
icon="🐌"
>
@@ -125,6 +132,7 @@ The key that identifies your Apprise configuration. This corresponds to a config
<ConfigSection
id="event-queue-item-deleted"
title="Queue Item Deleted"
description="Triggered When: A download is removed from the queue."
icon="🗑️"
>
@@ -135,6 +143,7 @@ The key that identifies your Apprise configuration. This corresponds to a config
<ConfigSection
id="event-download-cleaned"
title="Download Cleaned"
description="Triggered When: Download Cleaner removes completed downloads."
icon="🧽"
>
@@ -145,6 +154,7 @@ The key that identifies your Apprise configuration. This corresponds to a config
<ConfigSection
id="event-category-changed"
title="Category Changed"
description="Triggered When: Download Cleaner changes a download's category."
icon="🏷️"
>

Some files were not shown because too many files have changed in this diff Show More