Compare commits

..

311 Commits

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
746 changed files with 17948 additions and 87790 deletions

View File

@@ -7,13 +7,6 @@ body:
attributes:
value: |
Thanks for taking the time to improve Cleanuparr!
- type: checkboxes
id: duplicate-check
attributes:
label: "Duplicate check"
options:
- label: I have searched for existing issues and confirmed this is not a duplicate.
required: true
- type: checkboxes
id: init
attributes:
@@ -21,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

@@ -7,25 +7,6 @@ body:
attributes:
value: |
Thanks for taking the time to improve Cleanuparr!
- type: checkboxes
id: duplicate-check
attributes:
label: "Duplicate check"
options:
- label: I have searched for existing issues and confirmed this is not a duplicate.
required: true
- type: checkboxes
id: init
attributes:
label: Implementation & testing support
description: The requester should help answer questions, provide support for the implementation and test changes.
options:
- label: I understand I must be available to assist with implementation questions and to test the feature before being released.
required: true
- label: I understand that joining the Discord server may be necessary for better coordination and faster communication.
required: true
- label: I understand that failure to assist in the development process of my request will result in the request being closed.
required: true
- type: textarea
id: description
attributes:

View File

@@ -7,13 +7,6 @@ body:
attributes:
value: |
If you are experiencing unexpected behavior, please consider submitting a bug report instead.
- type: checkboxes
id: duplicate-check
attributes:
label: "Duplicate check"
options:
- label: I have searched for existing issues and confirmed this is not a duplicate.
required: true
- type: checkboxes
id: init
attributes:
@@ -21,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

@@ -1,8 +1,2 @@
blank_issues_enabled: false
contact_links:
- name: Discord Community
url: https://discord.gg/SCtMCgtsc4
about: Join our Discord for real-time help and discussions
- name: Documentation
url: https://cleanuparr.github.io/Cleanuparr/
about: Check the documentation for configurations and usage guidelines
contact_links: []

View File

@@ -1,24 +0,0 @@
## Description
<!-- Brief description of what this PR does -->
## Related Issue
Closes #ISSUE_NUMBER
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Testing
<!-- Describe how you tested your changes -->
## Screenshots (if applicable)
<!-- Add screenshots here -->
## Checklist
- [ ] I have read the [Contributing Guide](../CONTRIBUTING.md)
- [ ] I have announced my intent to work on this and received approval
- [ ] My code follows the project's code standards
- [ ] I have tested my changes thoroughly
- [ ] I have updated relevant documentation

View File

@@ -1,30 +0,0 @@
name: 'Get Vault Secrets'
description: 'Retrieves secrets from HashiCorp Vault using AppRole authentication'
inputs:
vault_host:
description: 'Vault server URL'
required: true
vault_role_id:
description: 'Vault AppRole Role ID'
required: true
vault_secret_id:
description: 'Vault AppRole Secret ID'
required: true
secrets:
description: 'Secrets to retrieve (multiline string, one per line in format: path | output_name)'
required: true
default: |
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
secrets/data/github packages_pat | PACKAGES_PAT
runs:
using: "composite"
steps:
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ inputs.vault_host }}
method: approle
roleId: ${{ inputs.vault_role_id }}
secretId: ${{ inputs.vault_secret_id }}
secrets: ${{ inputs.secrets }}

View File

@@ -1,26 +1,14 @@
name: Build Docker Images
on:
push:
tags:
- "v*.*.*"
pull_request:
paths:
- 'code/**'
workflow_dispatch:
workflow_call:
inputs:
push_docker:
description: 'Push Docker image to registry'
type: boolean
required: false
default: true
app_version:
description: 'Application version'
type: string
required: false
default: ''
# Cancel in-progress runs for the same PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build_app:
@@ -39,37 +27,15 @@ jobs:
timeout-minutes: 1
run: |
githubHeadRef=${{ env.githubHeadRef }}
inputVersion="${{ inputs.app_version }}"
latestDockerTag=""
versionDockerTag=""
majorVersionDockerTag=""
minorVersionDockerTag=""
version="0.0.1"
if [[ -n "$inputVersion" ]]; then
# Version provided via input (manual release)
branch="main"
latestDockerTag="latest"
versionDockerTag="$inputVersion"
version="$inputVersion"
# Extract major and minor versions for additional tags
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
majorVersionDockerTag="${BASH_REMATCH[1]}"
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
fi
elif [[ "$githubRef" =~ ^"refs/tags/" ]]; then
# Tag push
if [[ "$githubRef" =~ ^"refs/tags/" ]]; then
branch=${githubRef##*/}
latestDockerTag="latest"
versionDockerTag=${branch#v}
version=${branch#v}
# Extract major and minor versions for additional tags
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
majorVersionDockerTag="${BASH_REMATCH[1]}"
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
fi
else
# Determine if this run is for the main branch or another branch
if [[ -z "$githubHeadRef" ]]; then
@@ -87,16 +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"
fi
if [ -n "$minorVersionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$minorVersionDockerTag"
fi
if [ -n "$majorVersionDockerTag" ]; then
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$majorVersionDockerTag"
githubTags="$githubTags,ghcr.io/cleanuparr:$versionDockerTag"
fi
# set env vars
@@ -141,7 +102,6 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push docker image
id: docker-build
timeout-minutes: 15
uses: docker/build-push-action@v6
with:
@@ -153,14 +113,13 @@ 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
platforms: |
linux/amd64
linux/arm64
push: ${{ github.event_name == 'pull_request' || inputs.push_docker == true }}
push: true
tags: |
${{ env.githubTags }}
# Enable BuildKit cache for faster builds
cache-from: type=gha
cache-to: type=gha,mode=max
${{ env.githubTags }}

View File

@@ -1,55 +1,40 @@
name: Build Executables
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
workflow_call:
inputs:
app_version:
description: 'Application version'
type: string
required: false
default: ''
jobs:
# Build for each platform in parallel using matrix strategy
build-platform:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
include:
- runtime: win-x64
platform: win-amd64
- runtime: linux-x64
platform: linux-amd64
- runtime: linux-arm64
platform: linux-arm64
- runtime: osx-x64
platform: osx-amd64
- runtime: osx-arm64
platform: osx-arm64
steps:
- name: Gate
if: ${{ !startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch' }}
run: |
echo "This workflow only runs on tag events or manual dispatch. Pipeline finished."
exit 0
- name: Set variables
run: |
repoFullName=${{ github.repository }}
ref=${{ github.ref }}
# Use input version if provided, otherwise determine from ref
if [[ -n "${{ inputs.app_version }}" ]]; then
appVersion="${{ inputs.app_version }}"
releaseVersion="v$appVersion"
elif [[ "$ref" =~ ^refs/tags/ ]]; then
# Handle both tag events and manual dispatch
if [[ "$ref" =~ ^refs/tags/ ]]; then
releaseVersion=${ref##refs/tags/}
appVersion=${releaseVersion#v}
else
# For manual dispatch, use a default version
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
appVersion="0.0.1-dev"
fi
repoFullName=${{ github.repository }}
repositoryName=${repoFullName#*/}
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
echo "githubRepositoryName=${repoFullName#*/}" >> $GITHUB_ENV
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
echo "appVersion=$appVersion" >> $GITHUB_ENV
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
@@ -73,28 +58,27 @@ jobs:
ref: ${{ github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
- name: Setup Node.js for frontend build
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: code/frontend/package-lock.json
- name: Build frontend
run: |
cd code/frontend
npm ci
npm run build
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Download frontend artifact
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: code/frontend/dist/ui/browser
- name: Install dependencies and restore
run: |
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
- name: Copy frontend to backend wwwroot
@@ -102,25 +86,92 @@ jobs:
mkdir -p code/backend/${{ env.executableName }}/wwwroot
cp -r code/frontend/dist/ui/browser/* code/backend/${{ env.executableName }}/wwwroot/
- name: Build ${{ matrix.platform }}
run: |
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
-c Release \
--runtime ${{ matrix.runtime }} \
--self-contained \
-o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }} \
/p:PublishSingleFile=true \
/p:Version=${{ env.appVersion }} \
/p:DebugSymbols=false
- name: Build win-x64
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
- name: Zip artifact
- name: Build linux-x64
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime linux-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
- name: Build linux-arm64
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime linux-arm64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
- name: Build osx-x64
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime osx-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
- 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
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}/
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64/
- name: Upload artifact
- name: Zip linux-x64
run: |
cd ./artifacts
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64/
- name: Zip linux-arm64
run: |
cd ./artifacts
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64/
- name: Zip osx-x64
run: |
cd ./artifacts
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64/
- name: Zip osx-arm64
run: |
cd ./artifacts
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64/
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: executable-${{ matrix.platform }}
path: ./artifacts/*.zip
name: cleanuparr-executables
path: |
./artifacts/*.zip
retention-days: 30
- name: Release
if: startsWith(github.ref, 'refs/tags/')
id: release
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
fail_on_unmatched_files: true
target_commitish: main
generate_release_notes: true
files: |
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64.zip
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64.zip

View File

@@ -1,46 +0,0 @@
name: Build Frontend
on:
workflow_call:
jobs:
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
- name: Checkout repository
uses: actions/checkout@v4
timeout-minutes: 1
with:
repository: ${{ github.repository }}
ref: ${{ github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: code/frontend/package-lock.json
- name: Build frontend
run: |
cd code/frontend
npm ci
npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: code/frontend/dist/ui/browser
retention-days: 1

View File

@@ -0,0 +1,376 @@
name: Build macOS ARM Installer
permissions:
contents: write
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
workflow_call:
jobs:
build-macos-arm-installer:
name: Build macOS ARM Installer
runs-on: macos-14 # ARM runner for Apple Silicon
steps:
- name: Set variables
run: |
repoFullName=${{ github.repository }}
ref=${{ github.ref }}
# Handle both tag events and manual dispatch
if [[ "$ref" =~ ^refs/tags/ ]]; then
releaseVersion=${ref##refs/tags/}
appVersion=${releaseVersion#v}
else
# For manual dispatch, use a default version
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
appVersion="0.0.1-dev"
fi
repositoryName=${repoFullName#*/}
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
echo "appVersion=$appVersion" >> $GITHUB_ENV
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
secrets/data/github packages_pat | PACKAGES_PAT
- name: Checkout repository
uses: actions/checkout@v4
with:
repository: ${{ env.githubRepository }}
ref: ${{ github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
fetch-depth: 0
- name: Setup Node.js for frontend build
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: code/frontend/package-lock.json
- name: Build frontend
run: |
cd code/frontend
npm ci
npm run build
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Restore .NET dependencies
run: |
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
- name: Build macOS ARM executable
run: |
# Clean any existing output directory
rm -rf dist
mkdir -p dist/temp
# Build to a temporary location
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
-c Release \
--runtime osx-arm64 \
--self-contained true \
-o dist/temp \
/p:PublishSingleFile=true \
/p:Version=${{ env.appVersion }} \
/p:DebugType=None \
/p:DebugSymbols=false \
/p:UseAppHost=true \
/p:EnableMacOSCodeSign=false \
/p:CodeSignOnCopy=false \
/p:_CodeSignDuringBuild=false \
/p:PublishTrimmed=false \
/p:TrimMode=link
# Create proper app bundle structure
mkdir -p dist/Cleanuparr.app/Contents/MacOS
# Copy the built executable (note: AssemblyName is "Cleanuparr" not "Cleanuparr.Api")
cp dist/temp/Cleanuparr dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
# Copy frontend directly to where it belongs in the app bundle
mkdir -p dist/Cleanuparr.app/Contents/MacOS/wwwroot
cp -r code/frontend/dist/ui/browser/* dist/Cleanuparr.app/Contents/MacOS/wwwroot/
# Copy any additional runtime files if they exist
if [ -d "dist/temp" ]; then
find dist/temp -name "*.dylib" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
find dist/temp -name "createdump" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
fi
- name: Post-build setup
run: |
# Make sure the executable is actually executable
chmod +x dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
# Remove any .pdb files that might have been created
find dist/Cleanuparr.app/Contents/MacOS -name "*.pdb" -delete 2>/dev/null || true
echo "Checking architecture of built binary:"
file dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
if command -v lipo >/dev/null 2>&1; then
lipo -info dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
fi
echo "Files in MacOS directory:"
ls -la dist/Cleanuparr.app/Contents/MacOS/
- name: Create macOS app bundle structure
run: |
# Create proper app bundle structure
mkdir -p dist/Cleanuparr.app/Contents/{MacOS,Resources,Frameworks}
# Convert ICO to ICNS for macOS app bundle
if command -v iconutil >/dev/null 2>&1; then
# Create iconset directory structure
mkdir -p Cleanuparr.iconset
# Use existing PNG files from Logo directory for different sizes
cp Logo/16.png Cleanuparr.iconset/icon_16x16.png
cp Logo/32.png Cleanuparr.iconset/icon_16x16@2x.png
cp Logo/32.png Cleanuparr.iconset/icon_32x32.png
cp Logo/64.png Cleanuparr.iconset/icon_32x32@2x.png
cp Logo/128.png Cleanuparr.iconset/icon_128x128.png
cp Logo/256.png Cleanuparr.iconset/icon_128x128@2x.png
cp Logo/256.png Cleanuparr.iconset/icon_256x256.png
cp Logo/512.png Cleanuparr.iconset/icon_256x256@2x.png
cp Logo/512.png Cleanuparr.iconset/icon_512x512.png
cp Logo/1024.png Cleanuparr.iconset/icon_512x512@2x.png
# Create ICNS file
iconutil -c icns Cleanuparr.iconset -o dist/Cleanuparr.app/Contents/Resources/Cleanuparr.icns
# Clean up iconset directory
rm -rf Cleanuparr.iconset
fi
# Create Launch Daemon plist
cat > dist/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.cleanuparr.daemon</string>
<key>ProgramArguments</key>
<array>
<string>/Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/cleanuparr.log</string>
<key>StandardErrorPath</key>
<string>/var/log/cleanuparr.error.log</string>
<key>WorkingDirectory</key>
<string>/Applications/Cleanuparr.app/Contents/MacOS</string>
<key>EnvironmentVariables</key>
<dict>
<key>HTTP_PORTS</key>
<string>11011</string>
</dict>
</dict>
</plist>
EOF
# Create Info.plist with proper configuration
cat > dist/Cleanuparr.app/Contents/Info.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>Cleanuparr</string>
<key>CFBundleIdentifier</key>
<string>com.Cleanuparr</string>
<key>CFBundleName</key>
<string>Cleanuparr</string>
<key>CFBundleDisplayName</key>
<string>Cleanuparr</string>
<key>CFBundleVersion</key>
<string>${{ env.appVersion }}</string>
<key>CFBundleShortVersionString</key>
<string>${{ env.appVersion }}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleSignature</key>
<string>CLNR</string>
<key>CFBundleIconFile</key>
<string>Cleanuparr</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSRequiresAquaSystemAppearance</key>
<false/>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSSupportsAutomaticTermination</key>
<false/>
<key>NSSupportsSuddenTermination</key>
<false/>
<key>LSBackgroundOnly</key>
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
EOF
# Clean up temp directory
rm -rf dist/temp
- name: Create PKG installer
run: |
# Create preinstall script to handle existing installations
mkdir -p scripts
cat > scripts/preinstall << 'EOF'
#!/bin/bash
# Stop and unload existing launch daemon if it exists
if launchctl list | grep -q "com.cleanuparr.daemon"; then
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
fi
# Stop any running instances of Cleanuparr
pkill -f "Cleanuparr" || true
sleep 2
# Remove old installation if it exists
if [[ -d "/Applications/Cleanuparr.app" ]]; then
rm -rf "/Applications/Cleanuparr.app"
fi
# Remove old launch daemon plist if it exists
if [[ -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist" ]]; then
rm -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist"
fi
exit 0
EOF
chmod +x scripts/preinstall
# Create postinstall script
cat > scripts/postinstall << 'EOF'
#!/bin/bash
# Set proper permissions for the app bundle
chmod -R 755 /Applications/Cleanuparr.app
chmod +x /Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr
# Install the launch daemon
cp /Applications/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist /Library/LaunchDaemons/
chown root:wheel /Library/LaunchDaemons/com.cleanuparr.daemon.plist
chmod 644 /Library/LaunchDaemons/com.cleanuparr.daemon.plist
# Load and start the service
launchctl load /Library/LaunchDaemons/com.cleanuparr.daemon.plist
launchctl start com.cleanuparr.daemon
# Wait a moment for service to start
sleep 3
# Display as system notification
osascript -e 'display notification "Cleanuparr service started! Visit http://localhost:11011 in your browser." with title "Installation Complete"' 2>/dev/null || true
exit 0
EOF
chmod +x scripts/postinstall
# Create uninstall script (optional, for user reference)
cat > scripts/uninstall_cleanuparr.sh << 'EOF'
#!/bin/bash
# Cleanuparr Uninstall Script
# Run this script with sudo to completely remove Cleanuparr
echo "Stopping Cleanuparr service..."
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
echo "Removing service files..."
rm -f /Library/LaunchDaemons/com.cleanuparr.daemon.plist
echo "Removing application..."
rm -rf /Applications/Cleanuparr.app
echo "Removing logs..."
rm -f /var/log/cleanuparr.log
rm -f /var/log/cleanuparr.error.log
echo "Cleanuparr has been completely removed."
echo "Note: Configuration files in /Applications/Cleanuparr.app/Contents/MacOS/config/ have been removed with the app."
EOF
chmod +x scripts/uninstall_cleanuparr.sh
# Copy uninstall script to app bundle for user access
cp scripts/uninstall_cleanuparr.sh dist/Cleanuparr.app/Contents/Resources/
# Determine package name
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-arm64.pkg"
else
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-arm64-dev.pkg"
fi
# Create PKG installer with better metadata
pkgbuild --root dist/ \
--scripts scripts/ \
--identifier com.Cleanuparr \
--version ${{ env.appVersion }} \
--install-location /Applications \
--ownership preserve \
${pkg_name}
echo "pkgName=${pkg_name}" >> $GITHUB_ENV
- name: Upload installer as artifact
uses: actions/upload-artifact@v4
with:
name: Cleanuparr-macos-arm64-installer
path: '${{ env.pkgName }}'
retention-days: 30
- name: Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
files: |
${{ env.pkgName }}

View File

@@ -1,47 +1,28 @@
name: Build macOS Installers
name: Build macOS Intel Installer
permissions:
contents: write
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
workflow_call:
inputs:
app_version:
description: 'Application version'
type: string
required: false
default: ''
jobs:
build-macos-installer:
name: Build macOS ${{ matrix.arch }} Installer
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: Intel
runner: macos-13
runtime: osx-x64
min_os_version: "10.15"
artifact_suffix: intel
- arch: ARM
runner: macos-14
runtime: osx-arm64
min_os_version: "11.0"
artifact_suffix: arm64
build-macos-intel-installer:
name: Build macOS Intel Installer
runs-on: macos-13 # Intel runner
steps:
- name: Set variables
run: |
repoFullName=${{ github.repository }}
ref=${{ github.ref }}
# Use input version if provided, otherwise determine from ref
if [[ -n "${{ inputs.app_version }}" ]]; then
appVersion="${{ inputs.app_version }}"
releaseVersion="v$appVersion"
elif [[ "$ref" =~ ^refs/tags/ ]]; then
# Handle both tag events and manual dispatch
if [[ "$ref" =~ ^refs/tags/ ]]; then
releaseVersion=${ref##refs/tags/}
appVersion=${releaseVersion#v}
else
@@ -49,9 +30,9 @@ jobs:
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
appVersion="0.0.1-dev"
fi
repositoryName=${repoFullName#*/}
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
@@ -77,11 +58,18 @@ jobs:
token: ${{ env.REPO_READONLY_PAT }}
fetch-depth: 0
- name: Download frontend artifact
uses: actions/download-artifact@v4
- name: Setup Node.js for frontend build
uses: actions/setup-node@v4
with:
name: frontend-dist
path: code/frontend/dist/ui/browser
node-version: '18'
cache: 'npm'
cache-dependency-path: code/frontend/package-lock.json
- name: Build frontend
run: |
cd code/frontend
npm ci
npm run build
- name: Setup .NET
uses: actions/setup-dotnet@v4
@@ -93,16 +81,16 @@ jobs:
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
- name: Build macOS ${{ matrix.arch }} executable
- name: Build macOS Intel executable
run: |
# Clean any existing output directory
rm -rf dist
mkdir -p dist/temp
# Build to a temporary location
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
-c Release \
--runtime ${{ matrix.runtime }} \
--runtime osx-x64 \
--self-contained true \
-o dist/temp \
/p:PublishSingleFile=true \
@@ -115,17 +103,17 @@ jobs:
/p:_CodeSignDuringBuild=false \
/p:PublishTrimmed=false \
/p:TrimMode=link
# Create proper app bundle structure
mkdir -p dist/Cleanuparr.app/Contents/MacOS
# Copy the built executable (note: AssemblyName is "Cleanuparr" not "Cleanuparr.Api")
cp dist/temp/Cleanuparr dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
# Copy frontend directly to where it belongs in the app bundle
mkdir -p dist/Cleanuparr.app/Contents/MacOS/wwwroot
cp -r code/frontend/dist/ui/browser/* dist/Cleanuparr.app/Contents/MacOS/wwwroot/
# Copy any additional runtime files if they exist
if [ -d "dist/temp" ]; then
find dist/temp -name "*.dylib" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
@@ -136,16 +124,16 @@ jobs:
run: |
# Make sure the executable is actually executable
chmod +x dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
# Remove any .pdb files that might have been created
find dist/Cleanuparr.app/Contents/MacOS -name "*.pdb" -delete 2>/dev/null || true
echo "Checking architecture of built binary:"
file dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
if command -v lipo >/dev/null 2>&1; then
lipo -info dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
fi
echo "Files in MacOS directory:"
ls -la dist/Cleanuparr.app/Contents/MacOS/
@@ -153,12 +141,12 @@ jobs:
run: |
# Create proper app bundle structure
mkdir -p dist/Cleanuparr.app/Contents/{MacOS,Resources,Frameworks}
# Convert ICO to ICNS for macOS app bundle
if command -v iconutil >/dev/null 2>&1; then
# Create iconset directory structure
mkdir -p Cleanuparr.iconset
# Use existing PNG files from Logo directory for different sizes
cp Logo/16.png Cleanuparr.iconset/icon_16x16.png
cp Logo/32.png Cleanuparr.iconset/icon_16x16@2x.png
@@ -170,14 +158,14 @@ jobs:
cp Logo/512.png Cleanuparr.iconset/icon_256x256@2x.png
cp Logo/512.png Cleanuparr.iconset/icon_512x512.png
cp Logo/1024.png Cleanuparr.iconset/icon_512x512@2x.png
# Create ICNS file
iconutil -c icns Cleanuparr.iconset -o dist/Cleanuparr.app/Contents/Resources/Cleanuparr.icns
# Clean up iconset directory
rm -rf Cleanuparr.iconset
fi
# Create Launch Daemon plist
cat > dist/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
@@ -208,7 +196,7 @@ jobs:
</dict>
</plist>
EOF
# Create Info.plist with proper configuration
cat > dist/Cleanuparr.app/Contents/Info.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
@@ -240,7 +228,7 @@ jobs:
<key>NSRequiresAquaSystemAppearance</key>
<false/>
<key>LSMinimumSystemVersion</key>
<string>${{ matrix.min_os_version }}</string>
<string>10.15</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSSupportsAutomaticTermination</key>
@@ -257,7 +245,7 @@ jobs:
</dict>
</plist>
EOF
# Clean up temp directory
rm -rf dist/temp
@@ -267,96 +255,96 @@ jobs:
mkdir -p scripts
cat > scripts/preinstall << 'EOF'
#!/bin/bash
# Stop and unload existing launch daemon if it exists
if launchctl list | grep -q "com.cleanuparr.daemon"; then
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
fi
# Stop any running instances of Cleanuparr
pkill -f "Cleanuparr" || true
sleep 2
# Remove old installation if it exists
if [[ -d "/Applications/Cleanuparr.app" ]]; then
rm -rf "/Applications/Cleanuparr.app"
fi
# Remove old launch daemon plist if it exists
if [[ -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist" ]]; then
rm -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist"
fi
exit 0
EOF
chmod +x scripts/preinstall
# Create postinstall script
cat > scripts/postinstall << 'EOF'
#!/bin/bash
# Set proper permissions for the app bundle
chmod -R 755 /Applications/Cleanuparr.app
chmod +x /Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr
# Install the launch daemon
cp /Applications/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist /Library/LaunchDaemons/
chown root:wheel /Library/LaunchDaemons/com.cleanuparr.daemon.plist
chmod 644 /Library/LaunchDaemons/com.cleanuparr.daemon.plist
# Load and start the service
launchctl load /Library/LaunchDaemons/com.cleanuparr.daemon.plist
launchctl start com.cleanuparr.daemon
# Wait a moment for service to start
sleep 3
# Display as system notification
osascript -e 'display notification "Cleanuparr service started! Visit http://localhost:11011 in your browser." with title "Installation Complete"' 2>/dev/null || true
exit 0
EOF
chmod +x scripts/postinstall
# Create uninstall script (optional, for user reference)
cat > scripts/uninstall_cleanuparr.sh << 'EOF'
#!/bin/bash
# Cleanuparr Uninstall Script
# Run this script with sudo to completely remove Cleanuparr
echo "Stopping Cleanuparr service..."
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
echo "Removing service files..."
rm -f /Library/LaunchDaemons/com.cleanuparr.daemon.plist
echo "Removing application..."
rm -rf /Applications/Cleanuparr.app
echo "Removing logs..."
rm -f /var/log/cleanuparr.log
rm -f /var/log/cleanuparr.error.log
echo "Cleanuparr has been completely removed."
echo "Note: Configuration files in /Applications/Cleanuparr.app/Contents/MacOS/config/ have been removed with the app."
EOF
chmod +x scripts/uninstall_cleanuparr.sh
# Copy uninstall script to app bundle for user access
cp scripts/uninstall_cleanuparr.sh dist/Cleanuparr.app/Contents/Resources/
# Determine package name - if app_version input was provided, it's a release build
if [[ -n "${{ inputs.app_version }}" ]] || [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}.pkg"
# Determine package name
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-intel.pkg"
else
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}-dev.pkg"
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-intel-dev.pkg"
fi
# Create PKG installer with better metadata
pkgbuild --root dist/ \
--scripts scripts/ \
@@ -365,12 +353,24 @@ jobs:
--install-location /Applications \
--ownership preserve \
${pkg_name}
echo "pkgName=${pkg_name}" >> $GITHUB_ENV
- name: Upload installer as artifact
uses: actions/upload-artifact@v4
with:
name: Cleanuparr-macos-${{ matrix.artifact_suffix }}-installer
name: Cleanuparr-macos-intel-installer
path: '${{ env.pkgName }}'
retention-days: 30
- name: Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
files: |
${{ env.pkgName }}

View File

@@ -1,13 +1,11 @@
name: Build Windows Installer
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
workflow_call:
inputs:
app_version:
description: 'Application version'
type: string
required: false
default: ''
jobs:
build-windows-installer:
@@ -19,13 +17,9 @@ jobs:
run: |
$repoFullName = "${{ github.repository }}"
$ref = "${{ github.ref }}"
$inputVersion = "${{ inputs.app_version }}"
# Use input version if provided, otherwise determine from ref
if ($inputVersion -ne "") {
$appVersion = $inputVersion
$releaseVersion = "v$appVersion"
} elseif ($ref -match "^refs/tags/") {
# Handle both tag events and manual dispatch
if ($ref -match "^refs/tags/") {
$releaseVersion = $ref -replace "refs/tags/", ""
$appVersion = $releaseVersion -replace "^v", ""
} else {
@@ -33,15 +27,15 @@ jobs:
$releaseVersion = "dev-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
$appVersion = "0.0.1-dev"
}
$repositoryName = $repoFullName.Split("/")[1]
echo "githubRepository=${{ github.repository }}" >> $env:GITHUB_ENV
echo "githubRepositoryName=$repositoryName" >> $env:GITHUB_ENV
echo "releaseVersion=$releaseVersion" >> $env:GITHUB_ENV
echo "appVersion=$appVersion" >> $env:GITHUB_ENV
echo "APP_VERSION=$appVersion" >> $env:GITHUB_ENV
echo "executableName=Cleanuparr.Api" >> $env:GITHUB_ENV
echo "APP_VERSION=$appVersion" >> $env:GITHUB_ENV
- name: Get vault secrets
uses: hashicorp/vault-action@v2
@@ -61,11 +55,18 @@ jobs:
ref: ${{ github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
- name: Download frontend artifact
uses: actions/download-artifact@v4
- name: Setup Node.js for frontend build
uses: actions/setup-node@v4
with:
name: frontend-dist
path: code/frontend/dist/ui/browser
node-version: '18'
cache: 'npm'
cache-dependency-path: code/frontend/package-lock.json
- name: Build frontend
run: |
cd code/frontend
npm ci
npm run build
- name: Setup .NET
uses: actions/setup-dotnet@v4
@@ -87,6 +88,19 @@ jobs:
run: |
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o dist /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugType=None /p:DebugSymbols=false
- name: Create sample configuration
shell: pwsh
run: |
# Create config directory
New-Item -ItemType Directory -Force -Path "config"
$config = @{
"HTTP_PORTS" = 11011
"BASE_PATH" = "/"
}
$config | ConvertTo-Json | Out-File -FilePath "config/cleanuparr.json" -Encoding UTF8
- name: Setup Inno Setup
shell: pwsh
run: |
@@ -144,4 +158,14 @@ jobs:
path: installer/${{ env.installerName }}
retention-days: 30
# Removed individual release step - handled by main release workflow
- name: Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
name: ${{ env.releaseVersion }}
tag_name: ${{ env.releaseVersion }}
repository: ${{ env.githubRepository }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
files: |
installer/${{ env.installerName }}

View File

@@ -1,36 +0,0 @@
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
paths:
- 'Cloudflare/**'
- 'blacklist'
- 'blacklist_permissive'
- 'whitelist'
- 'whitelist_with_subtitles'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy to Cloudflare Pages
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy root static files to Cloudflare static directory
run: |
cp blacklist Cloudflare/static/
cp blacklist_permissive Cloudflare/static/
cp whitelist Cloudflare/static/
cp whitelist_with_subtitles Cloudflare/static/
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
workingDirectory: "Cloudflare"
command: pages deploy . --project-name=cleanuparr

View File

@@ -1,30 +0,0 @@
name: Deploy to Cloudflare Pages
on:
push:
tags:
- "v*.*.*"
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy to Cloudflare Pages
steps:
- name: Create status files
run: |
mkdir -p status
echo "{ \"version\": \"${GITHUB_REF_NAME}\" }" > status/status.json
# Cache static files for 10 minutes
cat > status/_headers << 'EOF'
/*
Cache-Control: public, max-age=600, s-maxage=600
EOF
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
workingDirectory: "status"
command: pages deploy . --project-name=cleanuparr-status

View File

@@ -1,45 +0,0 @@
name: Dependency Review
on:
pull_request:
branches:
- main
# Cancel in-progress runs for the same PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Dependency Review
uses: actions/dependency-review-action@v4
with:
# Fail on critical and high severity vulnerabilities
fail-on-severity: high
# Warn on moderate vulnerabilities
warn-on-severity: moderate
# Allow licenses
# allow-licenses: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD
# Comment summarizes the vulnerabilities found
comment-summary-in-pr: on-failure
# Show dependency changes in PR
show-openssf-scorecard: true
vulnerability-check: true
- name: Upload dependency review results
uses: actions/upload-artifact@v4
with:
name: dependency-review-results
path: dependency-review-*.json
if-no-files-found: ignore
retention-days: 30

View File

@@ -2,9 +2,9 @@ name: Deploy Docusaurus to GitHub Pages
on:
push:
tags:
- "v*.*.*"
workflow_dispatch: {}
branches: [main]
paths:
- 'docs/**'
permissions:
contents: read
@@ -22,12 +22,11 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: main
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 20.x
cache: yarn
cache-dependency-path: docs/yarn.lock

View File

@@ -8,32 +8,8 @@ on:
inputs:
version:
description: 'Version to release (e.g., 1.0.0)'
required: true
runTests:
description: 'Run test suite'
type: boolean
required: false
default: true
buildDocker:
description: 'Build Docker image'
type: boolean
required: false
default: true
pushDocker:
description: 'Push Docker image to registry'
type: boolean
required: false
default: false
buildBinaries:
description: 'Build executables and installers'
type: boolean
required: false
default: true
createRelease:
description: 'Create GitHub release'
type: boolean
required: false
default: false
default: ''
jobs:
# Validate release
@@ -43,7 +19,7 @@ jobs:
app_version: ${{ steps.version.outputs.app_version }}
release_version: ${{ steps.version.outputs.release_version }}
is_tag: ${{ steps.version.outputs.is_tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -56,127 +32,55 @@ jobs:
release_version=${GITHUB_REF##refs/tags/}
app_version=${release_version#v}
is_tag=true
else
elif [[ -n "${{ github.event.inputs.version }}" ]]; then
# Manual workflow with version
app_version="${{ github.event.inputs.version }}"
# Validate version format (x.x.x)
if ! [[ "$app_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Version must be in format x.x.x (e.g., 1.0.0)"
echo "Provided version: $app_version"
exit 1
fi
release_version="v$app_version"
is_tag=false
else
# Manual workflow without version
app_version="0.0.1-dev-$(date +%Y%m%d-%H%M%S)"
release_version="v$app_version"
is_tag=false
fi
echo "app_version=$app_version" >> $GITHUB_OUTPUT
echo "release_version=$release_version" >> $GITHUB_OUTPUT
echo "is_tag=$is_tag" >> $GITHUB_OUTPUT
echo "Release Version: $release_version"
echo "App Version: $app_version"
echo "Is Tag: $is_tag"
- name: Check if release already exists
run: |
if gh release view "${{ steps.version.outputs.release_version }}" &>/dev/null; then
echo "❌ Release ${{ steps.version.outputs.release_version }} already exists. Stopping workflow."
exit 1
fi
echo "✅ Release ${{ steps.version.outputs.release_version }} does not exist. Proceeding."
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Run tests
test:
needs: validate
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
uses: ./.github/workflows/test.yml
secrets: inherit
# Build frontend once for all build jobs and cache it
build-frontend:
needs: [validate, test]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-frontend.yml
secrets: inherit
echo "🏷️ Release Version: $release_version"
echo "📱 App Version: $app_version"
echo "🔖 Is Tag: $is_tag"
# Build portable executables
build-executables:
needs: [validate, test, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-executable.yml
with:
app_version: ${{ needs.validate.outputs.app_version }}
needs: validate
uses: ./.github/workflows/build_executable.yml
secrets: inherit
# Build Windows installer
build-windows-installer:
needs: [validate, test, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
needs: validate
uses: ./.github/workflows/build-windows-installer.yml
with:
app_version: ${{ needs.validate.outputs.app_version }}
secrets: inherit
# Build macOS installers (Intel and ARM)
build-macos:
needs: [validate, test, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-macos-installer.yml
with:
app_version: ${{ needs.validate.outputs.app_version }}
# Build macOS Intel installer
build-macos-intel:
needs: validate
uses: ./.github/workflows/build-macos-intel-installer.yml
secrets: inherit
# Build and push Docker image(s)
build-docker:
needs: [validate, test]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildDocker == 'true')
uses: ./.github/workflows/build-docker.yml
with:
push_docker: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.pushDocker == 'true' }}
app_version: ${{ needs.validate.outputs.app_version }}
# Build macOS ARM installer
build-macos-arm:
needs: validate
uses: ./.github/workflows/build-macos-arm-installer.yml
secrets: inherit
# Create GitHub release
create-release:
needs: [validate, build-executables, build-windows-installer, build-macos]
needs: [validate, build-executables, build-windows-installer, build-macos-intel, build-macos-arm]
runs-on: ubuntu-latest
if: |
always() &&
needs.validate.result == 'success' &&
needs.build-executables.result == 'success' &&
needs.build-windows-installer.result == 'success' &&
needs.build-macos.result == 'success' &&
(
needs.validate.outputs.is_tag == 'true' ||
(github.event.inputs.createRelease == 'true' && github.event.inputs.buildBinaries == 'true')
)
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
steps:
- name: Get vault secrets
@@ -189,99 +93,72 @@ jobs:
secrets:
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
- name: Download executable artifacts
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
pattern: executable-*
path: ./artifacts
merge-multiple: true
- name: Download Windows installer
uses: actions/download-artifact@v4
with:
name: Cleanuparr-windows-installer
path: ./artifacts
- name: Download macOS installers
uses: actions/download-artifact@v4
with:
pattern: Cleanuparr-macos-*-installer
path: ./artifacts
merge-multiple: true
- name: List downloaded artifacts
run: |
echo "Downloaded artifacts:"
find ./artifacts -type f \( -name "*.zip" -o -name "*.pkg" -o -name "*.exe" \) | sort
echo ""
echo "Total files: $(find ./artifacts -type f \( -name "*.zip" -o -name "*.pkg" -o -name "*.exe" \) | wc -l)"
echo "📦 Downloaded artifacts:"
find ./artifacts -type f -name "*.zip" -o -name "*.pkg" -o -name "*.exe" | sort
- name: Create release
uses: softprops/action-gh-release@v2
with:
name: ${{ needs.validate.outputs.release_version }}
name: Cleanuparr ${{ needs.validate.outputs.release_version }}
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
./artifacts/*.exe
./artifacts/**/*.zip
./artifacts/**/*.pkg
./artifacts/**/*.exe
# Summary job
summary:
needs: [validate, test, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
needs: [validate, build-executables, build-windows-installer, build-macos-intel, build-macos-arm]
runs-on: ubuntu-latest
if: always()
steps:
- name: Record workflow start time
id: workflow-start
run: |
# Get workflow start time from GitHub API
workflow_start=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }} --jq '.run_started_at')
start_epoch=$(date -d "$workflow_start" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$workflow_start" +%s)
echo "start=$start_epoch" >> $GITHUB_OUTPUT
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Summary
run: |
# Calculate total workflow duration
start_time=${{ steps.workflow-start.outputs.start }}
end_time=$(date +%s)
duration=$((end_time - start_time))
minutes=$((duration / 60))
seconds=$((duration % 60))
echo "## 🏗️ Cleanuparr Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version**: ${{ needs.validate.outputs.release_version }}" >> $GITHUB_STEP_SUMMARY
echo "**App Version**: ${{ needs.validate.outputs.app_version }}" >> $GITHUB_STEP_SUMMARY
echo "**Is Tag**: ${{ needs.validate.outputs.is_tag }}" >> $GITHUB_STEP_SUMMARY
echo "**Total Duration**: ${minutes}m ${seconds}s" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Build Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Helper function to print job result
print_result() {
local name="$1"
local result="$2"
case "$result" in
success) echo "✅ **$name**: Success" >> $GITHUB_STEP_SUMMARY ;;
skipped) echo "⏭️ **$name**: Skipped" >> $GITHUB_STEP_SUMMARY ;;
*) echo "❌ **$name**: $result" >> $GITHUB_STEP_SUMMARY ;;
esac
}
print_result "Tests" "${{ needs.test.result }}"
print_result "Frontend Build" "${{ needs.build-frontend.result }}"
print_result "Portable Executables" "${{ needs.build-executables.result }}"
print_result "Windows Installer" "${{ needs.build-windows-installer.result }}"
print_result "macOS Installers (Intel & ARM)" "${{ needs.build-macos.result }}"
print_result "Docker Image Build" "${{ needs.build-docker.result }}"
# Check job results
if [[ "${{ needs.build-executables.result }}" == "success" ]]; then
echo "✅ **Portable Executables**: Success" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Portable Executables**: ${{ needs.build-executables.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.build-windows-installer.result }}" == "success" ]]; then
echo "✅ **Windows Installer**: Success" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Windows Installer**: ${{ needs.build-windows-installer.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.build-macos-intel.result }}" == "success" ]]; then
echo "✅ **macOS Intel Installer**: Success" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **macOS Intel Installer**: ${{ needs.build-macos-intel.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.build-macos-arm.result }}" == "success" ]]; then
echo "✅ **macOS ARM Installer**: Success" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **macOS ARM Installer**: ${{ needs.build-macos-arm.result }}" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "🎉 **Build completed!**" >> $GITHUB_STEP_SUMMARY

View File

@@ -1,99 +0,0 @@
name: Tests
on:
push:
branches:
- main
paths:
- 'code/backend/**'
- '.github/workflows/test.yml'
pull_request:
paths:
- 'code/backend/**'
- '.github/workflows/test.yml'
workflow_call:
# Cancel in-progress runs for the same PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
timeout-minutes: 1
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github packages_pat | PACKAGES_PAT
- name: Restore dependencies
run: |
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
dotnet restore code/backend/cleanuparr.sln
- name: Build solution
run: dotnet build code/backend/cleanuparr.sln --configuration Release --no-restore
- name: Run tests
id: run-tests
run: dotnet test code/backend/cleanuparr.sln --configuration Release --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" --settings code/backend/coverage.runsettings --results-directory ./coverage
- name: Upload test results
uses: actions/upload-artifact@v4
with:
name: test-results
path: ./coverage/*.trx
retention-days: 30
- name: Upload coverage reports
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: ./coverage/**/coverage.cobertura.xml
retention-days: 30
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: ./coverage/**/coverage.cobertura.xml
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
flags: backend
name: backend-coverage
- name: Test Summary
run: |
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.run-tests.outcome }}" == "success" ]; then
echo "✅ All tests passed!" >> $GITHUB_STEP_SUMMARY
else
echo "❌ Tests failed or were cancelled. Status: ${{ steps.run-tests.outcome }}" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "Test artifacts have been uploaded for detailed analysis." >> $GITHUB_STEP_SUMMARY

View File

@@ -1,66 +0,0 @@
name: Get Version Info
on:
workflow_call:
inputs:
manual_version:
description: 'Manual version override (e.g., 1.0.0)'
required: false
type: string
default: ''
outputs:
app_version:
description: 'Application version (without v prefix)'
value: ${{ jobs.version.outputs.app_version }}
release_version:
description: 'Release version (with v prefix)'
value: ${{ jobs.version.outputs.release_version }}
is_tag:
description: 'Whether this is a tag event'
value: ${{ jobs.version.outputs.is_tag }}
repository_name:
description: 'Repository name without owner'
value: ${{ jobs.version.outputs.repository_name }}
jobs:
version:
runs-on: ubuntu-latest
outputs:
app_version: ${{ steps.version.outputs.app_version }}
release_version: ${{ steps.version.outputs.release_version }}
is_tag: ${{ steps.version.outputs.is_tag }}
repository_name: ${{ steps.version.outputs.repository_name }}
steps:
- name: Calculate version info
id: version
run: |
repoFullName="${{ github.repository }}"
repositoryName="${repoFullName#*/}"
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
# Tag event
release_version="${GITHUB_REF##refs/tags/}"
app_version="${release_version#v}"
is_tag="true"
elif [[ -n "${{ inputs.manual_version }}" ]]; then
# Manual workflow with version
app_version="${{ inputs.manual_version }}"
release_version="v${app_version}"
is_tag="false"
else
# Development build
app_version="0.0.1-dev-$(date +%Y%m%d-%H%M%S)"
release_version="v${app_version}"
is_tag="false"
fi
echo "app_version=${app_version}" >> $GITHUB_OUTPUT
echo "release_version=${release_version}" >> $GITHUB_OUTPUT
echo "is_tag=${is_tag}" >> $GITHUB_OUTPUT
echo "repository_name=${repositoryName}" >> $GITHUB_OUTPUT
echo "📦 Repository: ${repositoryName}"
echo "🏷️ Release Version: ${release_version}"
echo "📱 App Version: ${app_version}"
echo "🔖 Is Tag: ${is_tag}"

View File

@@ -1,325 +0,0 @@
# Contributing to Cleanuparr
Thanks for your interest in contributing to Cleanuparr! This guide will help you get started with development.
## Before You Start
### Announce Your Intent
Before starting any work, please let us know what you want to contribute:
- For existing issues: Comment on the issue stating you'd like to work on it
- For new features/changes: Create a new issue first and mention that you want to work on it
This helps us avoid redundant work, git conflicts, and contributions that may not align with the project's direction.
**Wait for approval from the maintainers before proceeding with your contribution.**
## Development Setup
### Prerequisites
- [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)
- [Node.js 18+](https://nodejs.org/)
- [Git](https://git-scm.com/)
- (Optional) [Make](https://www.gnu.org/software/make/) for database migrations
- (Optional) IDE: [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio](https://visualstudio.microsoft.com/)
### Repository Setup
1. Fork the repository on GitHub
2. Clone your fork locally:
```bash
git clone https://github.com/YOUR_USERNAME/Cleanuparr.git
cd Cleanuparr
```
3. Add the upstream repository:
```bash
git remote add upstream https://github.com/Cleanuparr/Cleanuparr.git
```
## Backend Development
### Initial Setup
#### 1. Create a GitHub Personal Access Token (PAT)
Cleanuparr uses GitHub Packages for NuGet dependencies. You'll need a PAT with `read:packages` permission:
1. Go to [GitHub Settings > Developer Settings > Personal Access Tokens > Tokens (classic)](https://github.com/settings/tokens)
2. Click "Generate new token" → "Generate new token (classic)"
3. Give it a descriptive name (e.g., "Cleanuparr NuGet Access")
4. Set an expiration (recommend 90 days or longer for development)
5. Select only the `read:packages` scope
6. Click "Generate token" and copy it
#### 2. Configure NuGet Source
Add the Cleanuparr NuGet repository:
```bash
dotnet nuget add source \
--username YOUR_GITHUB_USERNAME \
--password YOUR_GITHUB_PAT \
--store-password-in-clear-text \
--name Cleanuparr \
https://nuget.pkg.github.com/Cleanuparr/index.json
```
Replace `YOUR_GITHUB_USERNAME` and `YOUR_GITHUB_PAT` with your GitHub username and the PAT you created.
### Running the Backend
#### Option 1: Using .NET CLI
Navigate to the backend directory:
```bash
cd code/backend
```
Build the application:
```bash
dotnet build Cleanuparr.Api/Cleanuparr.Api.csproj
```
Run the application:
```bash
dotnet run --project Cleanuparr.Api/Cleanuparr.Api.csproj
```
Run tests:
```bash
dotnet test
```
The API will be available at http://localhost:5000
#### Option 2: Using an IDE
For JetBrains Rider or Visual Studio:
1. Open the solution file: `code/backend/cleanuparr.sln`
2. Set `Cleanuparr.Api` as the startup project
3. Press `F5` to start the application
### Database Migrations
Cleanuparr uses two separate database contexts: `DataContext` and `EventsContext`.
#### Prerequisites
Install Make if not already installed:
- Windows: Install via [Chocolatey](https://chocolatey.org/) (`choco install make`) or use [WSL](https://docs.microsoft.com/windows/wsl/)
- macOS: Install via Homebrew (`brew install make`)
- Linux: Usually pre-installed, or install via package manager (`apt install make`, `yum install make`, etc.)
#### Creating Migrations
From the `code` directory:
For data migrations (DataContext):
```bash
make migrate-data name=YourMigrationName
```
For events migrations (EventsContext):
```bash
make migrate-events name=YourMigrationName
```
Example:
```bash
make migrate-data name=AddUserPreferences
make migrate-events name=AddAuditLogEvents
```
## Frontend Development
### Setup
1. Navigate to the frontend directory:
```bash
cd code/frontend
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm start
```
The UI will be available at http://localhost:4200
## Documentation Development
### Setup
1. Navigate to the docs directory:
```bash
cd docs
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm start
```
The documentation site will be available at http://localhost:3000
## Building with Docker
### Building a Local Docker Image
To build the Docker image locally for testing:
1. Navigate to the `code` directory:
```bash
cd code
```
2. Build the image:
```bash
docker build \
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
-t cleanuparr:local \
-f Dockerfile .
```
Replace `YOUR_GITHUB_USERNAME` and `YOUR_GITHUB_PAT` with your credentials.
3. Run the container:
```bash
docker run -d \
--name cleanuparr-dev \
-p 11011:11011 \
-v /path/to/config:/config \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=Etc/UTC \
cleanuparr:local
```
4. Access the application at http://localhost:11011
### Building for Multiple Architectures
Use Docker Buildx for multi-platform builds:
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
-t cleanuparr:local \
-f Dockerfile .
```
## Code Standards
### Backend (.NET/C#)
- Follow existing conventions and [Microsoft C# Coding Conventions](https://docs.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
- Use meaningful variable and method names
- Add XML documentation comments for public APIs
- Write unit tests whenever possible
### Frontend (Angular/TypeScript)
- Follow existing conventions and the [Angular Style Guide](https://angular.io/guide/styleguide)
- Use TypeScript strict mode
- Write unit tests whenever possible
### Documentation
- Use clear, concise language
- Include code examples where appropriate
- Update relevant documentation when adding/changing features
- Check for spelling and grammar
## Submitting Your Contribution
### 1. Create a Feature Branch
```bash
git checkout -b feature/your-feature-name
# or
git checkout -b fix/your-bug-fix-name
```
### 2. Make Your Changes
- Write clean, well-documented code
- Follow the code standards outlined above
- **Test your changes thoroughly!**
### 3. Commit Your Changes
Write clear, descriptive commit messages:
```bash
git add .
git commit -m "Add feature: brief description of your changes"
```
### 4. Keep Your Branch Updated
```bash
git fetch upstream
git rebase upstream/main
```
### 5. Push to Your Fork
```bash
git push origin feature/your-feature-name
```
### 6. Create a Pull Request
1. Go to the [Cleanuparr repository](https://github.com/Cleanuparr/Cleanuparr)
2. Click "New Pull Request"
3. Select your fork and branch
4. Fill out the PR template with:
- A descriptive title (e.g., "Add support for Prowlarr integration" or "Fix memory leak in download client polling")
- Description of changes
- Related issue number
- Testing performed
- Screenshots (if applicable)
### 7. Code Review Process
- Maintainers will review your PR
- Address any feedback or requested changes
- Once approved, your PR will be merged
## Other Ways to Contribute
### Help Test New Features
We're always looking for testers to help validate new features before they are released. If you'd like to help test upcoming changes:
1. Join our [Discord community](https://discord.gg/SCtMCgtsc4)
2. Let us know you're interested in testing
3. We'll provide you with pre-release builds and testing instructions
Your feedback helps us catch issues early and deliver better releases.
## Getting Help
- Discord: Join our [Discord community](https://discord.gg/SCtMCgtsc4) for real-time help
- Issues: Check existing [GitHub issues](https://github.com/Cleanuparr/Cleanuparr/issues) or create a new one
- Documentation: Review the [complete documentation](https://cleanuparr.github.io/Cleanuparr/)
## License
By contributing to Cleanuparr, you agree that your contributions will be licensed under the same license as the project.
---
Thanks for contributing to Cleanuparr!

View File

@@ -1,3 +0,0 @@
# Cache static files for 5 minutes
/static/*
Cache-Control: public, max-age=300, s-maxage=300

View File

@@ -1,2 +0,0 @@
thepirateheaven.org
RARBG.work

View File

@@ -2,11 +2,6 @@ _Love this project? Give it a ⭐️ and let others know!_
# <img width="24px" src="./Logo/256.png" alt="Cleanuparr"></img> Cleanuparr
![Version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fcleanuparr-status.pages.dev%2Fstatus.json&query=%24.version&logo=git&label=version&color=blue)
![Total Downloads](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2FCleanuparr%2FCleanuparr%2Fcleanuparr&query=%24.downloadCount&style=flat&logo=docker&label=Total%20Downloads&color=blue)
[![Tests](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml)
[![Discord](https://img.shields.io/discord/1306721212587573389?color=7289DA&label=Discord&style=for-the-badge&logo=discord)](https://discord.gg/SCtMCgtsc4)
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, and supported download clients like qBittorrent. It removes incomplete or blocked downloads, updates queues, and enforces blacklists or whitelists to manage file selection. After removing blocked content, Cleanuparr can also trigger a search to replace the deleted shows/movies.
@@ -17,86 +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 **Malware Blocker**.
> - Remove and block known malware based on patterns found by the community.
> - 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.
## Screenshots
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.
https://cleanuparr.github.io/Cleanuparr/docs/screenshots
## Quick Start
## 🎯 Supported Applications
> [!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).
### *Arr Applications
- **Sonarr**
- **Radarr**
- **Lidarr**
- **Readarr**
- **Whisparr**
# Docs
### Download Clients
- **qBittorrent**
- **Transmission**
- **Deluge**
- **µTorrent**
### Platforms
- **Docker**
- **Windows**
- **macOS**
- **Linux**
- **Unraid**
## 🚀 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 the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed), but not before reading the [Prerequisites](https://cleanuparr.github.io/Cleanuparr/docs/installation/).
### 🌐 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
## 🤝 Contributing
We welcome contributions from the community! Whether it's bug fixes, new features, documentation improvements, or testing, your help is appreciated.
**Before contributing:** Please read our [Contributing Guide](CONTRIBUTING.md) and announce your intent to work on an issue before starting.
- **[Contributing Guide](CONTRIBUTING.md)** - Learn how to set up your development environment and submit contributions
- **[Report Issues](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Found a bug? Let us know!
- **[Feature Requests](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Share your ideas for new features
- **[Help Test Features](https://discord.gg/SCtMCgtsc4)** - Join Discord to test pre-release features and provide feedback
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>

346
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,86 +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
*.sql
*.sqf
*.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
@@ -684,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
@@ -724,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
@@ -772,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
@@ -804,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

@@ -1,12 +1,10 @@
# Build Angular frontend
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-build
FROM --platform=$BUILDPLATFORM node:18-alpine AS frontend-build
WORKDIR /app
# Copy package files first for better layer caching
COPY frontend/package*.json ./
# Use cache mount for npm to speed up builds
RUN --mount=type=cache,target=/root/.npm \
npm ci && npm install -g @angular/cli
RUN npm ci && npm install -g @angular/cli
# Copy source code
COPY frontend/ .
@@ -23,20 +21,21 @@ ARG PACKAGES_PAT
WORKDIR /app
EXPOSE 11011
# Copy solution and project files first for better layer caching
# COPY backend/*.sln ./backend/
# COPY backend/*/*.csproj ./backend/*/
# Copy source code
COPY backend/ ./backend/
# Add NuGet source
# Restore dependencies
RUN dotnet nuget add source --username ${PACKAGES_USERNAME} --password ${PACKAGES_PAT} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
# Restore and publish with cache mount
RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
dotnet restore ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj -a $TARGETARCH && \
dotnet publish ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj \
# Build and publish
RUN dotnet publish ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj \
-a $TARGETARCH \
-c Release \
-o /app/publish \
--no-restore \
/p:Version=${VERSION} \
/p:PublishSingleFile=true \
/p:DebugSymbols=false
@@ -44,21 +43,12 @@ RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
# Install required packages for user management, timezone support, and Python for Apprise CLI
# Install required packages for user management and timezone support
RUN apt-get update && apt-get install -y \
curl \
tzdata \
gosu \
python3 \
python3-venv \
&& rm -rf /var/lib/apt/lists/*
# Create virtual environment and install Apprise CLI
ENV VIRTUAL_ENV=/opt/apprise-venv
RUN python3 -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN pip install --no-cache-dir apprise==1.9.6
ENV PUID=1000 \
PGID=1000 \
UMASK=022 \

View File

@@ -19,28 +19,31 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Cleanuparr.Application\Cleanuparr.Application.csproj" />
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MassTransit" Version="8.5.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
<PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<!-- API-related packages -->
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
</Project>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -87,6 +87,10 @@ public class EventsController : ControllerBase
.Take(pageSize)
.ToListAsync();
events = events
.OrderBy(e => e.Timestamp)
.ToList();
// Return paginated result
var result = new PaginatedResult<AppEvent>
{

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;
@@ -76,23 +76,63 @@ public class JobsController : ControllerBase
}
}
[HttpPost("{jobType}/trigger")]
public async Task<IActionResult> TriggerJob(JobType jobType)
[HttpPost("{jobType}/stop")]
public async Task<IActionResult> StopJob(JobType jobType)
{
try
{
var result = await _jobManagementService.TriggerJobOnce(jobType);
var result = await _jobManagementService.StopJob(jobType);
if (!result)
{
return BadRequest($"Failed to trigger job '{jobType}' - job may not exist or be configured");
return BadRequest($"Failed to stop job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' triggered successfully for one-time execution" });
return Ok(new { Message = $"Job '{jobType}' stopped successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error triggering job {jobType}", jobType);
return StatusCode(500, $"An error occurred while triggering job '{jobType}'");
_logger.LogError(ex, "Error stopping job {jobType}", jobType);
return StatusCode(500, $"An error occurred while stopping job '{jobType}'");
}
}
[HttpPost("{jobType}/pause")]
public async Task<IActionResult> PauseJob(JobType jobType)
{
try
{
var result = await _jobManagementService.PauseJob(jobType);
if (!result)
{
return BadRequest($"Failed to pause job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' paused successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error pausing job {jobType}", jobType);
return StatusCode(500, $"An error occurred while pausing job '{jobType}'");
}
}
[HttpPost("{jobType}/resume")]
public async Task<IActionResult> ResumeJob(JobType jobType)
{
try
{
var result = await _jobManagementService.ResumeJob(jobType);
if (!result)
{
return BadRequest($"Failed to resume job '{jobType}'");
}
return Ok(new { Message = $"Job '{jobType}' resumed successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error resuming job {jobType}", jobType);
return StatusCode(500, $"An error occurred while resuming job '{jobType}'");
}
}

View File

@@ -1,180 +0,0 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ManualEventsController : ControllerBase
{
private readonly EventsContext _context;
public ManualEventsController(EventsContext context)
{
_context = context;
}
/// <summary>
/// Gets manual events with pagination and filtering
/// </summary>
[HttpGet]
public async Task<ActionResult<PaginatedResult<ManualEvent>>> GetManualEvents(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 100,
[FromQuery] bool? isResolved = null,
[FromQuery] string? severity = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] string? search = null)
{
// Validate pagination parameters
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 100;
if (pageSize > 1000) pageSize = 1000; // Cap at 1000 for performance
var query = _context.ManualEvents.AsQueryable();
// Apply filters
if (isResolved.HasValue)
{
query = query.Where(e => e.IsResolved == isResolved.Value);
}
if (!string.IsNullOrWhiteSpace(severity))
{
if (Enum.TryParse<EventSeverity>(severity, true, out var severityEnum))
query = query.Where(e => e.Severity == severityEnum);
}
// Apply date range filters
if (fromDate.HasValue)
{
query = query.Where(e => e.Timestamp >= fromDate.Value);
}
if (toDate.HasValue)
{
query = query.Where(e => e.Timestamp <= toDate.Value);
}
// Apply search filter if provided
if (!string.IsNullOrWhiteSpace(search))
{
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e =>
EF.Functions.Like(e.Message, pattern) ||
EF.Functions.Like(e.Data, pattern)
);
}
// Count total matching records for pagination
var totalCount = await query.CountAsync();
// Calculate pagination
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var skip = (page - 1) * pageSize;
// Get paginated data
var events = await query
.OrderByDescending(e => e.Timestamp)
.Skip(skip)
.Take(pageSize)
.ToListAsync();
// Return paginated result
var result = new PaginatedResult<ManualEvent>
{
Items = events,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = totalPages
};
return Ok(result);
}
/// <summary>
/// Gets a specific manual event by ID
/// </summary>
[HttpGet("{id}")]
public async Task<ActionResult<ManualEvent>> GetManualEvent(Guid id)
{
var eventEntity = await _context.ManualEvents.FindAsync(id);
if (eventEntity == null)
return NotFound();
return Ok(eventEntity);
}
/// <summary>
/// Marks a manual event as resolved
/// </summary>
[HttpPost("{id}/resolve")]
public async Task<ActionResult> ResolveManualEvent(Guid id)
{
var eventEntity = await _context.ManualEvents.FindAsync(id);
if (eventEntity == null)
return NotFound();
eventEntity.IsResolved = true;
await _context.SaveChangesAsync();
return Ok();
}
/// <summary>
/// Gets manual event statistics
/// </summary>
[HttpGet("stats")]
public async Task<ActionResult<object>> GetManualEventStats()
{
var stats = new
{
TotalEvents = await _context.ManualEvents.CountAsync(),
UnresolvedEvents = await _context.ManualEvents.CountAsync(e => !e.IsResolved),
ResolvedEvents = await _context.ManualEvents.CountAsync(e => e.IsResolved),
EventsBySeverity = await _context.ManualEvents
.GroupBy(e => e.Severity)
.Select(g => new { Severity = g.Key.ToString(), Count = g.Count() })
.ToListAsync(),
UnresolvedBySeverity = await _context.ManualEvents
.Where(e => !e.IsResolved)
.GroupBy(e => e.Severity)
.Select(g => new { Severity = g.Key.ToString(), Count = g.Count() })
.ToListAsync()
};
return Ok(stats);
}
/// <summary>
/// Gets unique severities for manual events
/// </summary>
[HttpGet("severities")]
public async Task<ActionResult<List<string>>> GetSeverities()
{
var severities = Enum.GetNames(typeof(EventSeverity)).ToList();
return Ok(severities);
}
/// <summary>
/// Manually triggers cleanup of old resolved events
/// </summary>
[HttpPost("cleanup")]
public async Task<ActionResult<object>> CleanupOldResolvedEvents([FromQuery] int retentionDays = 30)
{
var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
var deletedCount = await _context.ManualEvents
.Where(e => e.IsResolved && e.Timestamp < cutoffDate)
.ExecuteDeleteAsync();
return Ok(new { DeletedCount = deletedCount });
}
}

View File

@@ -1 +0,0 @@
// Queue rules endpoints have moved to Cleanuparr.Api.Features.QueueCleaner.Controllers

View File

@@ -1,7 +1,6 @@
using System.Diagnostics;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Persistence;
using Microsoft.AspNetCore.Mvc;
@@ -15,15 +14,18 @@ public class StatusController : ControllerBase
{
private readonly ILogger<StatusController> _logger;
private readonly DataContext _dataContext;
private readonly IArrClientFactory _arrClientFactory;
private readonly DownloadServiceFactory _downloadServiceFactory;
private readonly ArrClientFactory _arrClientFactory;
public StatusController(
ILogger<StatusController> logger,
DataContext dataContext,
IArrClientFactory arrClientFactory)
DownloadServiceFactory downloadServiceFactory,
ArrClientFactory arrClientFactory)
{
_logger = logger;
_dataContext = dataContext;
_downloadServiceFactory = downloadServiceFactory;
_arrClientFactory = arrClientFactory;
}
@@ -50,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
{
@@ -82,10 +80,6 @@ public class StatusController : ControllerBase
Lidarr = new
{
InstanceCount = lidarrConfig.Instances.Count
},
Readarr = new
{
InstanceCount = readarrConfig.Instances.Count
}
}
};

View File

@@ -1,10 +1,11 @@
using System.Text.Json.Serialization;
using Cleanuparr.Api.Middleware;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Logging;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.OpenApi.Models;
using System.Text;
using Cleanuparr.Api.Middleware;
using Microsoft.Extensions.Options;
namespace Cleanuparr.Api.DependencyInjection;
@@ -14,21 +15,15 @@ public static class ApiDI
{
services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.PropertyNameCaseInsensitive = true;
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
});
// Make JsonSerializerOptions available for injection
services.AddSingleton(sp =>
sp.GetRequiredService<IOptions<JsonOptions>>().Value.SerializerOptions);
// Add API-specific services
services
.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
});
@@ -39,12 +34,28 @@ public static class ApiDI
.AddSignalR()
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.PropertyNameCaseInsensitive = true;
options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
// Add health status broadcaster
services.AddHostedService<HealthStatusBroadcaster>();
// Add logging initializer service
services.AddHostedService<LoggingInitializer>();
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Cleanuparr API",
Version = "v1",
Description = "API for managing media downloads and cleanups",
Contact = new OpenApiContact
{
Name = "Cleanuparr Team"
}
});
});
return services;
}
@@ -59,15 +70,33 @@ public static class ApiDI
// Serve static files with caching
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = _ => {}
OnPrepareResponse = ctx =>
{
// Cache static assets for 30 days
// if (ctx.File.Name.EndsWith(".js") || ctx.File.Name.EndsWith(".css"))
// {
// ctx.Context.Response.Headers.CacheControl = "public,max-age=2592000";
// }
}
});
// Add the global exception handling middleware first
app.UseMiddleware<ExceptionMiddleware>();
app.UseCors("Any");
app.UseRouting();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("v1/swagger.json", "Cleanuparr API v1");
options.RoutePrefix = "swagger";
options.DocumentTitle = "Cleanuparr API Documentation";
});
}
app.UseAuthorization();
app.MapControllers();
@@ -113,38 +142,6 @@ public static class ApiDI
// Map SignalR hubs
app.MapHub<HealthStatusHub>("/api/hubs/health");
app.MapHub<AppHub>("/api/hubs/app");
app.MapGet("/manifest.webmanifest", (HttpContext context) =>
{
var basePath = context.Request.PathBase.HasValue
? context.Request.PathBase.Value
: "/";
var manifest = new
{
name = "Cleanuparr",
short_name = "Cleanuparr",
start_url = basePath,
display = "standalone",
background_color = "#ffffff",
theme_color = "#ffffff",
icons = new[]
{
new {
src = "icons/icon-192x192.png",
sizes = "192x192",
type = "image/png"
},
new {
src = "icons/icon-512x512.png",
sizes = "512x512",
type = "image/png"
}
}
};
return Results.Json(manifest, contentType: "application/manifest+json");
});
return app;
}

View File

@@ -1,5 +1,10 @@
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Shared.Helpers;
using Serilog;
using Serilog.Events;
using Serilog.Templates;
using Serilog.Templates.Themes;
namespace Cleanuparr.Api.DependencyInjection;
@@ -7,10 +12,82 @@ public static class LoggingDI
{
public static ILoggingBuilder AddLogging(this ILoggingBuilder builder)
{
Log.Logger = LoggingConfigManager
.CreateLoggerConfiguration()
.CreateLogger();
Log.Logger = GetDefaultLoggerConfiguration().CreateLogger();
return builder.ClearProviders().AddSerilog();
}
public static LoggerConfiguration GetDefaultLoggerConfiguration()
{
LoggerConfiguration logConfig = new();
const string categoryTemplate = "{#if Category is not null} {Concat('[',Category,']'),CAT_PAD}{#end}";
const string jobNameTemplate = "{#if JobName is not null} {Concat('[',JobName,']'),JOB_PAD}{#end}";
const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m}}\n{{@x}}";
const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m:lj}}\n{{@x}}";
// Determine job name padding
List<string> jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.ContentBlocker), nameof(JobType.DownloadCleaner)];
int jobPadding = jobNames.Max(x => x.Length) + 2;
// Determine instance name padding
List<string> categoryNames = [
InstanceType.Sonarr.ToString(),
InstanceType.Radarr.ToString(),
InstanceType.Lidarr.ToString(),
InstanceType.Readarr.ToString(),
InstanceType.Whisparr.ToString(),
"SYSTEM"
];
int catPadding = categoryNames.Max(x => x.Length) + 2;
// Apply padding values to templates
string consoleTemplate = consoleOutputTemplate
.Replace("JOB_PAD", jobPadding.ToString())
.Replace("CAT_PAD", catPadding.ToString());
string fileTemplate = fileOutputTemplate
.Replace("JOB_PAD", jobPadding.ToString())
.Replace("CAT_PAD", catPadding.ToString());
// Configure base logger with dynamic level control
logConfig
.MinimumLevel.Is(LogEventLevel.Information)
.Enrich.FromLogContext()
.WriteTo.Console(new ExpressionTemplate(consoleTemplate, theme: TemplateTheme.Literate));
// Create the logs directory
string logsPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "logs");
if (!Directory.Exists(logsPath))
{
try
{
Directory.CreateDirectory(logsPath);
}
catch (Exception exception)
{
throw new Exception($"Failed to create log directory | {logsPath}", exception);
}
}
// Add main log file
logConfig.WriteTo.File(
path: Path.Combine(logsPath, "cleanuparr-.txt"),
formatter: new ExpressionTemplate(fileTemplate),
fileSizeLimitBytes: 10L * 1024 * 1024,
rollingInterval: RollingInterval.Day,
rollOnFileSizeLimit: true,
shared: true
);
logConfig
.MinimumLevel.Override("MassTransit", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
.Enrich.WithProperty("ApplicationName", "Cleanuparr");
return logConfig;
}
}

View File

@@ -1,13 +1,11 @@
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Data.Models.Arr;
using Infrastructure.Verticals.Notifications.Models;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
@@ -17,26 +15,22 @@ public static class MainDI
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
services
.AddLogging(builder => builder.ClearProviders().AddConsole())
.AddHttpClients(configuration)
.AddSingleton<MemoryCache>()
.AddSingleton<IMemoryCache>(serviceProvider => serviceProvider.GetRequiredService<MemoryCache>())
.AddServices()
.AddHealthServices()
.AddQuartzServices(configuration)
.AddNotifications()
.AddNotifications(configuration)
.AddMassTransit(config =>
{
config.DisableUsageTelemetry();
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();
config.AddConsumer<DownloadHunterConsumer<SeriesSearchItem>>();
config.AddConsumer<DownloadRemoverConsumer<SonarrSearchItem>>();
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowTimeStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowStrikeNotification>>();
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
@@ -45,7 +39,6 @@ public static class MainDI
{
cfg.ConfigureJsonSerializerOptions(options =>
{
options.PropertyNameCaseInsensitive = true;
options.Converters.Add(new JsonStringEnumConverter());
options.ReferenceHandler = ReferenceHandler.IgnoreCycles;
@@ -55,15 +48,7 @@ public static class MainDI
cfg.ReceiveEndpoint("download-remover-queue", e =>
{
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
e.ConfigureConsumer<DownloadRemoverConsumer<SeriesSearchItem>>(context);
e.ConcurrentMessageLimit = 2;
e.PrefetchCount = 2;
});
cfg.ReceiveEndpoint("download-hunter-queue", e =>
{
e.ConfigureConsumer<DownloadHunterConsumer<SearchItem>>(context);
e.ConfigureConsumer<DownloadHunterConsumer<SeriesSearchItem>>(context);
e.ConfigureConsumer<DownloadRemoverConsumer<SonarrSearchItem>>(context);
e.ConcurrentMessageLimit = 1;
e.PrefetchCount = 1;
});
@@ -72,8 +57,7 @@ public static class MainDI
{
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<SlowTimeStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<SlowStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);
@@ -99,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

@@ -1,24 +1,20 @@
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
using Infrastructure.Verticals.Notifications;
namespace Cleanuparr.Api.DependencyInjection;
public static class NotificationsDI
{
public static IServiceCollection AddNotifications(this IServiceCollection services) =>
public static IServiceCollection AddNotifications(this IServiceCollection services, IConfiguration configuration) =>
services
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
.AddScoped<IAppriseProxy, AppriseProxy>()
.AddScoped<IAppriseCliProxy, AppriseCliProxy>()
.AddSingleton<IAppriseCliDetector, AppriseCliDetector>()
.AddScoped<INtfyProxy, NtfyProxy>()
.AddScoped<IPushoverProxy, PushoverProxy>()
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
.AddScoped<NotificationProviderFactory>()
.AddScoped<INotificationPublisher, NotificationPublisher>()
.AddScoped<NotificationService>();
// Notification configs are now managed through ConfigManager
.AddTransient<INotifiarrProxy, NotifiarrProxy>()
.AddTransient<INotificationProvider, NotifiarrProvider>()
.AddTransient<IAppriseProxy, AppriseProxy>()
.AddTransient<INotificationProvider, AppriseProvider>()
.AddTransient<INotificationPublisher, NotificationPublisher>()
.AddTransient<INotificationFactory, NotificationFactory>()
.AddTransient<NotificationService>();
}

View File

@@ -1,22 +1,21 @@
using Cleanuparr.Application.Features.ContentBlocker;
using Cleanuparr.Application.Features.DownloadCleaner;
using Cleanuparr.Application.Features.QueueCleaner;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadHunter;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
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;
@@ -24,38 +23,31 @@ public static class ServicesDI
{
public static IServiceCollection AddServices(this IServiceCollection services) =>
services
.AddScoped<EventsContext>()
.AddScoped<DataContext>()
.AddScoped<IEventPublisher, EventPublisher>()
.AddSingleton<IEncryptionService, AesEncryptionService>()
.AddTransient<SensitiveDataJsonConverter>()
.AddTransient<EventsContext>()
.AddTransient<DataContext>()
.AddTransient<EventPublisher>()
.AddHostedService<EventCleanupService>()
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
.AddScoped<CertificateValidationService>()
.AddScoped<ISonarrClient, SonarrClient>()
.AddScoped<IRadarrClient, RadarrClient>()
.AddScoped<ILidarrClient, LidarrClient>()
.AddScoped<IReadarrClient, ReadarrClient>()
.AddScoped<IWhisparrClient, WhisparrClient>()
.AddScoped<IArrClientFactory, ArrClientFactory>()
.AddScoped<QueueCleaner>()
.AddScoped<BlacklistSynchronizer>()
.AddScoped<MalwareBlocker>()
.AddScoped<DownloadCleaner>()
.AddScoped<IQueueItemRemover, QueueItemRemover>()
.AddScoped<IDownloadHunter, DownloadHunter>()
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
.AddScoped<IHardLinkFileService, HardLinkFileService>()
.AddScoped<UnixHardLinkFileService>()
.AddScoped<WindowsHardLinkFileService>()
.AddScoped<IArrQueueIterator, ArrQueueIterator>()
.AddScoped<IDownloadServiceFactory, DownloadServiceFactory>()
.AddScoped<IStriker, Striker>()
.AddScoped<FileReader>()
.AddScoped<IRuleManager, RuleManager>()
.AddScoped<IRuleEvaluator, RuleEvaluator>()
.AddScoped<IRuleIntervalValidator, RuleIntervalValidator>()
// API services
.AddSingleton<IJobManagementService, JobManagementService>()
.AddSingleton<IBlocklistProvider, BlocklistProvider>()
.AddSingleton(TimeProvider.System)
.AddSingleton<AppStatusSnapshot>()
.AddHostedService<AppStatusRefreshService>();
// Core services
.AddTransient<IDryRunInterceptor, DryRunInterceptor>()
.AddTransient<CertificateValidationService>()
.AddTransient<SonarrClient>()
.AddTransient<RadarrClient>()
.AddTransient<LidarrClient>()
.AddTransient<ArrClientFactory>()
.AddTransient<QueueCleaner>()
.AddTransient<ContentBlocker>()
.AddTransient<DownloadCleaner>()
.AddTransient<IQueueItemRemover, QueueItemRemover>()
.AddTransient<IFilenameEvaluator, FilenameEvaluator>()
.AddTransient<IHardLinkFileService, HardLinkFileService>()
.AddTransient<UnixHardLinkFileService>()
.AddTransient<WindowsHardLinkFileService>()
.AddTransient<ArrQueueIterator>()
.AddTransient<DownloadServiceFactory>()
.AddTransient<IStriker, Striker>()
.AddSingleton<BlocklistProvider>();
}

View File

@@ -1,37 +0,0 @@
using System;
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Persistence.Models.Configuration.Arr;
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
public sealed record ArrInstanceRequest
{
public bool Enabled { get; init; } = true;
[Required]
public required string Name { get; init; }
[Required]
public required string Url { get; init; }
[Required]
public required string ApiKey { get; init; }
public ArrInstance ToEntity(Guid configId) => new()
{
Enabled = Enabled,
Name = Name,
Url = new Uri(Url),
ApiKey = ApiKey,
ArrConfigId = configId,
};
public void ApplyTo(ArrInstance instance)
{
instance.Enabled = Enabled;
instance.Name = Name;
instance.Url = new Uri(Url);
instance.ApiKey = ApiKey;
}
}

View File

@@ -1,6 +0,0 @@
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
public sealed record UpdateArrConfigRequest
{
public short FailedImportMaxStrikes { get; init; } = -1;
}

View File

@@ -1,272 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Mapster;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.Arr.Controllers;
[ApiController]
[Route("api/configuration")]
public sealed class ArrConfigController : ControllerBase
{
private readonly ILogger<ArrConfigController> _logger;
private readonly DataContext _dataContext;
public ArrConfigController(
ILogger<ArrConfigController> logger,
DataContext dataContext)
{
_logger = logger;
_dataContext = dataContext;
}
[HttpGet("sonarr")]
public Task<IActionResult> GetSonarrConfig() => GetArrConfig(InstanceType.Sonarr);
[HttpGet("radarr")]
public Task<IActionResult> GetRadarrConfig() => GetArrConfig(InstanceType.Radarr);
[HttpGet("lidarr")]
public Task<IActionResult> GetLidarrConfig() => GetArrConfig(InstanceType.Lidarr);
[HttpGet("readarr")]
public Task<IActionResult> GetReadarrConfig() => GetArrConfig(InstanceType.Readarr);
[HttpGet("whisparr")]
public Task<IActionResult> GetWhisparrConfig() => GetArrConfig(InstanceType.Whisparr);
[HttpPut("sonarr")]
public Task<IActionResult> UpdateSonarrConfig([FromBody] UpdateArrConfigRequest request)
=> UpdateArrConfig(InstanceType.Sonarr, request);
[HttpPut("radarr")]
public Task<IActionResult> UpdateRadarrConfig([FromBody] UpdateArrConfigRequest request)
=> UpdateArrConfig(InstanceType.Radarr, request);
[HttpPut("lidarr")]
public Task<IActionResult> UpdateLidarrConfig([FromBody] UpdateArrConfigRequest request)
=> UpdateArrConfig(InstanceType.Lidarr, request);
[HttpPut("readarr")]
public Task<IActionResult> UpdateReadarrConfig([FromBody] UpdateArrConfigRequest request)
=> UpdateArrConfig(InstanceType.Readarr, request);
[HttpPut("whisparr")]
public Task<IActionResult> UpdateWhisparrConfig([FromBody] UpdateArrConfigRequest request)
=> UpdateArrConfig(InstanceType.Whisparr, request);
[HttpPost("sonarr/instances")]
public Task<IActionResult> CreateSonarrInstance([FromBody] ArrInstanceRequest request)
=> CreateArrInstance(InstanceType.Sonarr, request);
[HttpPut("sonarr/instances/{id}")]
public Task<IActionResult> UpdateSonarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
=> UpdateArrInstance(InstanceType.Sonarr, id, request);
[HttpDelete("sonarr/instances/{id}")]
public Task<IActionResult> DeleteSonarrInstance(Guid id)
=> DeleteArrInstance(InstanceType.Sonarr, id);
[HttpPost("radarr/instances")]
public Task<IActionResult> CreateRadarrInstance([FromBody] ArrInstanceRequest request)
=> CreateArrInstance(InstanceType.Radarr, request);
[HttpPut("radarr/instances/{id}")]
public Task<IActionResult> UpdateRadarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
=> UpdateArrInstance(InstanceType.Radarr, id, request);
[HttpDelete("radarr/instances/{id}")]
public Task<IActionResult> DeleteRadarrInstance(Guid id)
=> DeleteArrInstance(InstanceType.Radarr, id);
[HttpPost("lidarr/instances")]
public Task<IActionResult> CreateLidarrInstance([FromBody] ArrInstanceRequest request)
=> CreateArrInstance(InstanceType.Lidarr, request);
[HttpPut("lidarr/instances/{id}")]
public Task<IActionResult> UpdateLidarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
=> UpdateArrInstance(InstanceType.Lidarr, id, request);
[HttpDelete("lidarr/instances/{id}")]
public Task<IActionResult> DeleteLidarrInstance(Guid id)
=> DeleteArrInstance(InstanceType.Lidarr, id);
[HttpPost("readarr/instances")]
public Task<IActionResult> CreateReadarrInstance([FromBody] ArrInstanceRequest request)
=> CreateArrInstance(InstanceType.Readarr, request);
[HttpPut("readarr/instances/{id}")]
public Task<IActionResult> UpdateReadarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
=> UpdateArrInstance(InstanceType.Readarr, id, request);
[HttpDelete("readarr/instances/{id}")]
public Task<IActionResult> DeleteReadarrInstance(Guid id)
=> DeleteArrInstance(InstanceType.Readarr, id);
[HttpPost("whisparr/instances")]
public Task<IActionResult> CreateWhisparrInstance([FromBody] ArrInstanceRequest request)
=> CreateArrInstance(InstanceType.Whisparr, request);
[HttpPut("whisparr/instances/{id}")]
public Task<IActionResult> UpdateWhisparrInstance(Guid id, [FromBody] ArrInstanceRequest request)
=> UpdateArrInstance(InstanceType.Whisparr, id, request);
[HttpDelete("whisparr/instances/{id}")]
public Task<IActionResult> DeleteWhisparrInstance(Guid id)
=> DeleteArrInstance(InstanceType.Whisparr, id);
private async Task<IActionResult> GetArrConfig(InstanceType type)
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstAsync(x => x.Type == type);
config.Instances = config.Instances
.OrderBy(i => i.Name)
.ToList();
return Ok(config.Adapt<ArrConfigDto>());
}
finally
{
DataContext.Lock.Release();
}
}
private async Task<IActionResult> UpdateArrConfig(InstanceType type, UpdateArrConfigRequest request)
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ArrConfigs
.FirstAsync(x => x.Type == type);
config.FailedImportMaxStrikes = request.FailedImportMaxStrikes;
config.Validate();
await _dataContext.SaveChangesAsync();
return Ok(new { Message = $"{type} configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save {Type} configuration", type);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private async Task<IActionResult> CreateArrInstance(InstanceType type, ArrInstanceRequest request)
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ArrConfigs
.FirstAsync(x => x.Type == type);
var instance = request.ToEntity(config.Id);
await _dataContext.ArrInstances.AddAsync(instance);
await _dataContext.SaveChangesAsync();
return CreatedAtAction(GetConfigActionName(type), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create {Type} instance", type);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private async Task<IActionResult> UpdateArrInstance(InstanceType type, Guid id, ArrInstanceRequest request)
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ArrConfigs
.Include(c => c.Instances)
.FirstAsync(x => x.Type == type);
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance is null)
{
return NotFound($"{type} instance with ID {id} not found");
}
request.ApplyTo(instance);
await _dataContext.SaveChangesAsync();
return Ok(instance.Adapt<ArrInstanceDto>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update {Type} instance with ID {Id}", type, id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private async Task<IActionResult> DeleteArrInstance(InstanceType type, Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ArrConfigs
.Include(c => c.Instances)
.FirstAsync(x => x.Type == type);
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
if (instance is null)
{
return NotFound($"{type} instance with ID {id} not found");
}
config.Instances.Remove(instance);
await _dataContext.SaveChangesAsync();
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete {Type} instance with ID {Id}", type, id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private static string GetConfigActionName(InstanceType type) => type switch
{
InstanceType.Sonarr => nameof(GetSonarrConfig),
InstanceType.Radarr => nameof(GetRadarrConfig),
InstanceType.Lidarr => nameof(GetLidarrConfig),
InstanceType.Readarr => nameof(GetReadarrConfig),
InstanceType.Whisparr => nameof(GetWhisparrConfig),
_ => nameof(GetSonarrConfig),
};
}

View File

@@ -1,26 +0,0 @@
using System;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
namespace Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
public sealed record UpdateBlacklistSyncConfigRequest
{
public bool Enabled { get; init; }
public string? BlacklistPath { get; init; }
/// <summary>
/// Applies the request to the provided configuration instance.
/// </summary>
public BlacklistSyncConfig ApplyTo(BlacklistSyncConfig config)
{
config.Enabled = Enabled;
config.BlacklistPath = BlacklistPath;
return config;
}
public bool HasPathChanged(string? currentPath)
=> !string.Equals(currentPath, BlacklistPath, StringComparison.InvariantCultureIgnoreCase);
}

View File

@@ -1,100 +0,0 @@
using System;
using System.Threading.Tasks;
using Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.BlacklistSync.Controllers;
[ApiController]
[Route("api/configuration")]
public sealed class BlacklistSyncConfigController : ControllerBase
{
private readonly ILogger<BlacklistSyncConfigController> _logger;
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
public BlacklistSyncConfigController(
ILogger<BlacklistSyncConfigController> logger,
DataContext dataContext,
IJobManagementService jobManagementService)
{
_logger = logger;
_dataContext = dataContext;
_jobManagementService = jobManagementService;
}
[HttpGet("blacklist_sync")]
public async Task<IActionResult> GetBlacklistSyncConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.BlacklistSyncConfigs
.AsNoTracking()
.FirstAsync();
return Ok(config);
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("blacklist_sync")]
public async Task<IActionResult> UpdateBlacklistSyncConfig([FromBody] UpdateBlacklistSyncConfigRequest request)
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.BlacklistSyncConfigs
.FirstAsync();
bool enabledChanged = config.Enabled != request.Enabled;
bool becameEnabled = !config.Enabled && request.Enabled;
bool pathChanged = request.HasPathChanged(config.BlacklistPath);
request.ApplyTo(config);
config.Validate();
await _dataContext.SaveChangesAsync();
if (enabledChanged)
{
if (becameEnabled)
{
_logger.LogInformation("BlacklistSynchronizer enabled, starting job");
await _jobManagementService.StartJob(JobType.BlacklistSynchronizer, null, config.CronExpression);
await _jobManagementService.TriggerJobOnce(JobType.BlacklistSynchronizer);
}
else
{
_logger.LogInformation("BlacklistSynchronizer disabled, stopping the job");
await _jobManagementService.StopJob(JobType.BlacklistSynchronizer);
}
}
else if (pathChanged && config.Enabled)
{
_logger.LogDebug("BlacklistSynchronizer path changed");
await _jobManagementService.TriggerJobOnce(JobType.BlacklistSynchronizer);
}
return Ok(new { Message = "BlacklistSynchronizer configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save BlacklistSync configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
}

View File

@@ -1,55 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
public record UpdateDownloadCleanerConfigRequest
{
public bool Enabled { get; init; }
public string CronExpression { get; init; } = "0 0 * * * ?";
/// <summary>
/// Indicates whether to use the CronExpression directly or convert from a user-friendly schedule.
/// </summary>
public bool UseAdvancedScheduling { get; init; }
public List<CleanCategoryRequest> Categories { get; init; } = [];
public bool DeletePrivate { get; init; }
/// <summary>
/// Indicates whether unlinked download handling is enabled.
/// </summary>
public bool UnlinkedEnabled { get; init; }
public string UnlinkedTargetCategory { get; init; } = "cleanuparr-unlinked";
public bool UnlinkedUseTag { get; init; }
public string UnlinkedIgnoredRootDir { get; init; } = string.Empty;
public List<string> UnlinkedCategories { get; init; } = [];
public List<string> IgnoredDownloads { get; init; } = [];
}
public record CleanCategoryRequest
{
[Required]
public string Name { get; init; } = string.Empty;
/// <summary>
/// Max ratio before removing a download.
/// </summary>
public double MaxRatio { get; init; } = -1;
/// <summary>
/// Min number of hours to seed before removing a download, if the ratio has been met.
/// </summary>
public double MinSeedTime { get; init; }
/// <summary>
/// Number of hours to seed before removing a download.
/// </summary>
public double MaxSeedTime { get; init; } = -1;
}

View File

@@ -1,148 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
[ApiController]
[Route("api/configuration")]
public sealed class DownloadCleanerConfigController : ControllerBase
{
private readonly ILogger<DownloadCleanerConfigController> _logger;
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
public DownloadCleanerConfigController(
ILogger<DownloadCleanerConfigController> logger,
DataContext dataContext,
IJobManagementService jobManagementService)
{
_logger = logger;
_dataContext = dataContext;
_jobManagementService = jobManagementService;
}
[HttpGet("download_cleaner")]
public async Task<IActionResult> GetDownloadCleanerConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.DownloadCleanerConfigs
.Include(x => x.Categories)
.AsNoTracking()
.FirstAsync();
return Ok(config);
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("download_cleaner")]
public async Task<IActionResult> UpdateDownloadCleanerConfig([FromBody] UpdateDownloadCleanerConfigRequest newConfigDto)
{
await DataContext.Lock.WaitAsync();
try
{
if (newConfigDto is null)
{
throw new ValidationException("Request body cannot be null");
}
// Validate cron expression format
if (!string.IsNullOrEmpty(newConfigDto.CronExpression))
{
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
}
// Get existing configuration
var oldConfig = await _dataContext.DownloadCleanerConfigs
.Include(x => x.Categories)
.FirstAsync();
oldConfig.Enabled = newConfigDto.Enabled;
oldConfig.CronExpression = newConfigDto.CronExpression;
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
oldConfig.DeletePrivate = newConfigDto.DeletePrivate;
oldConfig.UnlinkedEnabled = newConfigDto.UnlinkedEnabled;
oldConfig.UnlinkedTargetCategory = newConfigDto.UnlinkedTargetCategory;
oldConfig.UnlinkedUseTag = newConfigDto.UnlinkedUseTag;
oldConfig.UnlinkedIgnoredRootDir = newConfigDto.UnlinkedIgnoredRootDir;
oldConfig.UnlinkedCategories = newConfigDto.UnlinkedCategories;
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
oldConfig.Categories.Clear();
_dataContext.CleanCategories.RemoveRange(oldConfig.Categories);
_dataContext.DownloadCleanerConfigs.Update(oldConfig);
foreach (var categoryDto in newConfigDto.Categories)
{
_dataContext.CleanCategories.Add(new CleanCategory
{
Name = categoryDto.Name,
MaxRatio = categoryDto.MaxRatio,
MinSeedTime = categoryDto.MinSeedTime,
MaxSeedTime = categoryDto.MaxSeedTime,
DownloadCleanerConfigId = oldConfig.Id
});
}
oldConfig.Validate();
await _dataContext.SaveChangesAsync();
await UpdateJobSchedule(oldConfig, JobType.DownloadCleaner);
return Ok(new { Message = "DownloadCleaner configuration updated successfully" });
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save DownloadCleaner configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
{
if (config.Enabled)
{
if (!string.IsNullOrEmpty(config.CronExpression))
{
_logger.LogInformation("{name} is enabled, updating job schedule with cron expression: {CronExpression}",
jobType.ToString(), config.CronExpression);
await _jobManagementService.StartJob(jobType, null, config.CronExpression);
}
else
{
_logger.LogWarning("{name} is enabled, but no cron expression was found in the configuration", jobType.ToString());
}
return;
}
_logger.LogInformation("{name} is disabled, stopping the job", jobType.ToString());
await _jobManagementService.StopJob(jobType);
}
}

View File

@@ -1,51 +0,0 @@
using System;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
public sealed record CreateDownloadClientRequest
{
public bool Enabled { get; init; }
public string Name { get; init; } = string.Empty;
public DownloadClientTypeName TypeName { get; init; }
public DownloadClientType Type { get; init; }
public Uri? Host { get; init; }
public string? Username { get; init; }
public string? Password { get; init; }
public string? UrlBase { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
{
throw new ValidationException("Client name cannot be empty");
}
if (Host is null)
{
throw new ValidationException("Host cannot be empty");
}
}
public DownloadClientConfig ToEntity() => new()
{
Enabled = Enabled,
Name = Name,
TypeName = TypeName,
Type = Type,
Host = Host,
Username = Username,
Password = Password,
UrlBase = UrlBase,
};
}

View File

@@ -1,51 +0,0 @@
using System;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
public sealed record UpdateDownloadClientRequest
{
public bool Enabled { get; init; }
public string Name { get; init; } = string.Empty;
public DownloadClientTypeName TypeName { get; init; }
public DownloadClientType Type { get; init; }
public Uri? Host { get; init; }
public string? Username { get; init; }
public string? Password { get; init; }
public string? UrlBase { get; init; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
{
throw new ValidationException("Client name cannot be empty");
}
if (Host is null)
{
throw new ValidationException("Host cannot be empty");
}
}
public DownloadClientConfig ApplyTo(DownloadClientConfig existing) => existing with
{
Enabled = Enabled,
Name = Name,
TypeName = TypeName,
Type = Type,
Host = Host,
Username = Username,
Password = Password,
UrlBase = UrlBase,
};
}

View File

@@ -1,149 +0,0 @@
using System;
using System.Linq;
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.DownloadClient.Controllers;
[ApiController]
[Route("api/configuration")]
public sealed class DownloadClientController : ControllerBase
{
private readonly ILogger<DownloadClientController> _logger;
private readonly DataContext _dataContext;
private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory;
public DownloadClientController(
ILogger<DownloadClientController> logger,
DataContext dataContext,
IDynamicHttpClientFactory dynamicHttpClientFactory)
{
_logger = logger;
_dataContext = dataContext;
_dynamicHttpClientFactory = dynamicHttpClientFactory;
}
[HttpGet("download_client")]
public async Task<IActionResult> GetDownloadClientConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var clients = await _dataContext.DownloadClients
.AsNoTracking()
.ToListAsync();
clients = clients
.OrderBy(c => c.TypeName)
.ThenBy(c => c.Name)
.ToList();
return Ok(new { clients });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("download_client")]
public async Task<IActionResult> CreateDownloadClientConfig([FromBody] CreateDownloadClientRequest newClient)
{
await DataContext.Lock.WaitAsync();
try
{
newClient.Validate();
var clientConfig = newClient.ToEntity();
_dataContext.DownloadClients.Add(clientConfig);
await _dataContext.SaveChangesAsync();
return CreatedAtAction(nameof(GetDownloadClientConfig), new { id = clientConfig.Id }, clientConfig);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create download client");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("download_client/{id}")]
public async Task<IActionResult> UpdateDownloadClientConfig(Guid id, [FromBody] UpdateDownloadClientRequest updatedClient)
{
await DataContext.Lock.WaitAsync();
try
{
updatedClient.Validate();
var existingClient = await _dataContext.DownloadClients
.FirstOrDefaultAsync(c => c.Id == id);
if (existingClient is null)
{
return NotFound($"Download client with ID {id} not found");
}
var clientToPersist = updatedClient.ApplyTo(existingClient);
_dataContext.Entry(existingClient).CurrentValues.SetValues(clientToPersist);
await _dataContext.SaveChangesAsync();
return Ok(clientToPersist);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update download client with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("download_client/{id}")]
public async Task<IActionResult> DeleteDownloadClientConfig(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
var existingClient = await _dataContext.DownloadClients
.FirstOrDefaultAsync(c => c.Id == id);
if (existingClient is null)
{
return NotFound($"Download client with ID {id} not found");
}
_dataContext.DownloadClients.Remove(existingClient);
await _dataContext.SaveChangesAsync();
var clientName = $"DownloadClient_{id}";
_dynamicHttpClientFactory.UnregisterConfiguration(clientName);
_logger.LogInformation("Removed HTTP client configuration for deleted download client {ClientName}", clientName);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete download client with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
}

View File

@@ -1,131 +0,0 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Shared.Helpers;
using Serilog.Events;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Api.Features.General.Contracts.Requests;
public sealed record UpdateGeneralConfigRequest
{
public bool DisplaySupportBanner { get; init; } = true;
public bool DryRun { get; init; }
public ushort HttpMaxRetries { get; init; }
public ushort HttpTimeout { get; init; } = 100;
public CertificateValidationType HttpCertificateValidation { get; init; } = CertificateValidationType.Enabled;
public bool SearchEnabled { get; init; } = true;
public ushort SearchDelay { get; init; } = Constants.DefaultSearchDelaySeconds;
public string EncryptionKey { get; init; } = Guid.NewGuid().ToString();
public List<string> IgnoredDownloads { get; init; } = [];
public UpdateLoggingConfigRequest Log { get; init; } = new();
public GeneralConfig ApplyTo(GeneralConfig existingConfig, IServiceProvider services, ILogger logger)
{
existingConfig.DisplaySupportBanner = DisplaySupportBanner;
existingConfig.DryRun = DryRun;
existingConfig.HttpMaxRetries = HttpMaxRetries;
existingConfig.HttpTimeout = HttpTimeout;
existingConfig.HttpCertificateValidation = HttpCertificateValidation;
existingConfig.SearchEnabled = SearchEnabled;
existingConfig.SearchDelay = SearchDelay;
existingConfig.EncryptionKey = EncryptionKey;
existingConfig.IgnoredDownloads = IgnoredDownloads;
bool loggingChanged = Log.ApplyTo(existingConfig.Log);
Validate(existingConfig);
ApplySideEffects(existingConfig, services, logger, loggingChanged);
return existingConfig;
}
private static void Validate(GeneralConfig config)
{
if (config.HttpTimeout is 0)
{
throw new ValidationException("HTTP_TIMEOUT must be greater than 0");
}
config.Log.Validate();
}
private void ApplySideEffects(GeneralConfig config, IServiceProvider services, ILogger logger, bool loggingChanged)
{
var dynamicHttpClientFactory = services.GetRequiredService<IDynamicHttpClientFactory>();
dynamicHttpClientFactory.UpdateAllClientsFromGeneralConfig(config);
logger.LogInformation("Updated all HTTP client configurations with new general settings");
if (!loggingChanged)
{
return;
}
if (Log.LevelOnlyChange)
{
logger.LogCritical("Setting global log level to {level}", config.Log.Level);
LoggingConfigManager.SetLogLevel(config.Log.Level);
return;
}
logger.LogCritical("Reconfiguring logger due to configuration changes");
LoggingConfigManager.ReconfigureLogging(config);
}
}
public sealed record UpdateLoggingConfigRequest
{
public LogEventLevel Level { get; init; } = LogEventLevel.Information;
public ushort RollingSizeMB { get; init; } = 10;
public ushort RetainedFileCount { get; init; } = 5;
public ushort TimeLimitHours { get; init; } = 24;
public bool ArchiveEnabled { get; init; } = true;
public ushort ArchiveRetainedCount { get; init; } = 60;
public ushort ArchiveTimeLimitHours { get; init; } = 24 * 30;
public bool ApplyTo(LoggingConfig existingConfig)
{
bool levelChanged = existingConfig.Level != Level;
bool otherPropertiesChanged =
existingConfig.RollingSizeMB != RollingSizeMB ||
existingConfig.RetainedFileCount != RetainedFileCount ||
existingConfig.TimeLimitHours != TimeLimitHours ||
existingConfig.ArchiveEnabled != ArchiveEnabled ||
existingConfig.ArchiveRetainedCount != ArchiveRetainedCount ||
existingConfig.ArchiveTimeLimitHours != ArchiveTimeLimitHours;
existingConfig.Level = Level;
existingConfig.RollingSizeMB = RollingSizeMB;
existingConfig.RetainedFileCount = RetainedFileCount;
existingConfig.TimeLimitHours = TimeLimitHours;
existingConfig.ArchiveEnabled = ArchiveEnabled;
existingConfig.ArchiveRetainedCount = ArchiveRetainedCount;
existingConfig.ArchiveTimeLimitHours = ArchiveTimeLimitHours;
existingConfig.Validate();
LevelOnlyChange = levelChanged && !otherPropertiesChanged;
return levelChanged || otherPropertiesChanged;
}
public bool LevelOnlyChange { get; private set; }
}

View File

@@ -1,115 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Cleanuparr.Api.Features.General.Contracts.Requests;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.General;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.General.Controllers;
[ApiController]
[Route("api/configuration")]
public sealed class GeneralConfigController : ControllerBase
{
private readonly ILogger<GeneralConfigController> _logger;
private readonly DataContext _dataContext;
private readonly MemoryCache _cache;
public GeneralConfigController(
ILogger<GeneralConfigController> logger,
DataContext dataContext,
MemoryCache cache)
{
_logger = logger;
_dataContext = dataContext;
_cache = cache;
}
[HttpGet("general")]
public async Task<IActionResult> GetGeneralConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
return Ok(config);
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("general")]
public async Task<IActionResult> UpdateGeneralConfig([FromBody] UpdateGeneralConfigRequest request)
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.GeneralConfigs
.FirstAsync();
bool wasDryRun = config.DryRun;
request.ApplyTo(config, HttpContext.RequestServices, _logger);
await _dataContext.SaveChangesAsync();
ClearStrikesCacheIfNeeded(wasDryRun, config.DryRun);
return Ok(new { Message = "General configuration updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save General configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private void ClearStrikesCacheIfNeeded(bool wasDryRun, bool isDryRun)
{
if (!wasDryRun || isDryRun)
{
return;
}
List<object> keys;
// Remove strikes
foreach (string strikeType in Enum.GetNames(typeof(StrikeType)))
{
keys = _cache.Keys
.Where(key => key.ToString()?.StartsWith(strikeType, StringComparison.InvariantCultureIgnoreCase) is true)
.ToList();
foreach (object key in keys)
{
_cache.Remove(key);
}
_logger.LogTrace("Removed all cache entries for strike type: {StrikeType}", strikeType);
}
// Remove strike cache items
keys = _cache.Keys
.Where(key => key.ToString()?.StartsWith("item_", StringComparison.InvariantCultureIgnoreCase) is true)
.ToList();
foreach (object key in keys)
{
_cache.Remove(key);
}
}
}

View File

@@ -1,50 +0,0 @@
using System.Collections.Generic;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
namespace Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
public sealed record UpdateMalwareBlockerConfigRequest
{
public bool Enabled { get; init; }
public string CronExpression { get; init; } = "0/5 * * * * ?";
public bool UseAdvancedScheduling { get; init; }
public bool IgnorePrivate { get; init; }
public bool DeletePrivate { get; init; }
public bool DeleteKnownMalware { get; init; }
public BlocklistSettings Sonarr { get; init; } = new();
public BlocklistSettings Radarr { get; init; } = new();
public BlocklistSettings Lidarr { get; init; } = new();
public BlocklistSettings Readarr { get; init; } = new();
public BlocklistSettings Whisparr { get; init; } = new();
public List<string> IgnoredDownloads { get; init; } = [];
public ContentBlockerConfig ApplyTo(ContentBlockerConfig config)
{
config.Enabled = Enabled;
config.CronExpression = CronExpression;
config.UseAdvancedScheduling = UseAdvancedScheduling;
config.IgnorePrivate = IgnorePrivate;
config.DeletePrivate = DeletePrivate;
config.DeleteKnownMalware = DeleteKnownMalware;
config.Sonarr = Sonarr;
config.Radarr = Radarr;
config.Lidarr = Lidarr;
config.Readarr = Readarr;
config.Whisparr = Whisparr;
config.IgnoredDownloads = IgnoredDownloads;
return config;
}
}

View File

@@ -1,112 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.MalwareBlocker.Controllers;
[ApiController]
[Route("api/configuration")]
public sealed class MalwareBlockerConfigController : ControllerBase
{
private readonly ILogger<MalwareBlockerConfigController> _logger;
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
public MalwareBlockerConfigController(
ILogger<MalwareBlockerConfigController> logger,
DataContext dataContext,
IJobManagementService jobManagementService)
{
_logger = logger;
_dataContext = dataContext;
_jobManagementService = jobManagementService;
}
[HttpGet("malware_blocker")]
public async Task<IActionResult> GetMalwareBlockerConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.ContentBlockerConfigs
.AsNoTracking()
.FirstAsync();
return Ok(config);
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("malware_blocker")]
public async Task<IActionResult> UpdateMalwareBlockerConfig([FromBody] UpdateMalwareBlockerConfigRequest request)
{
await DataContext.Lock.WaitAsync();
try
{
if (!string.IsNullOrEmpty(request.CronExpression))
{
CronValidationHelper.ValidateCronExpression(request.CronExpression, JobType.MalwareBlocker);
}
var config = await _dataContext.ContentBlockerConfigs
.FirstAsync();
request.ApplyTo(config);
config.Validate();
await _dataContext.SaveChangesAsync();
await UpdateJobSchedule(config, JobType.MalwareBlocker);
return Ok(new { Message = "MalwareBlocker configuration updated successfully" });
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save MalwareBlocker configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
{
if (config.Enabled)
{
if (!string.IsNullOrEmpty(config.CronExpression))
{
_logger.LogInformation("{name} is enabled, updating job schedule with cron expression: {CronExpression}",
jobType.ToString(), config.CronExpression);
await _jobManagementService.StartJob(jobType, null, config.CronExpression);
}
else
{
_logger.LogWarning("{name} is enabled, but no cron expression was found in the configuration", jobType.ToString());
}
return;
}
_logger.LogInformation("{name} is disabled, stopping the job", jobType.ToString());
await _jobManagementService.StopJob(jobType);
}
}

View File

@@ -1,18 +0,0 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreateAppriseProviderRequest : CreateNotificationProviderRequestBase
{
public AppriseMode Mode { get; init; } = AppriseMode.Api;
// API mode fields
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
// CLI mode fields
public string? ServiceUrls { get; init; }
}

View File

@@ -1,8 +0,0 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreateNotifiarrProviderRequest : CreateNotificationProviderRequestBase
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
}

View File

@@ -1,20 +0,0 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public abstract record CreateNotificationProviderRequestBase
{
public string Name { get; init; } = string.Empty;
public bool IsEnabled { get; init; } = true;
public bool OnFailedImportStrike { get; init; }
public bool OnStalledStrike { get; init; }
public bool OnSlowStrike { get; init; }
public bool OnQueueItemDeleted { get; init; }
public bool OnDownloadCleaned { get; init; }
public bool OnCategoryChanged { get; init; }
}

View File

@@ -1,22 +0,0 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreateNtfyProviderRequest : CreateNotificationProviderRequestBase
{
public string ServerUrl { get; init; } = string.Empty;
public List<string> Topics { get; init; } = [];
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
public string Username { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
public string AccessToken { get; init; } = string.Empty;
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
public List<string> Tags { get; init; } = [];
}

View File

@@ -1,22 +0,0 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreatePushoverProviderRequest : CreateNotificationProviderRequestBase
{
public string ApiToken { get; init; } = string.Empty;
public string UserKey { get; init; } = string.Empty;
public List<string> Devices { get; init; } = [];
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
public string? Sound { get; init; }
public int? Retry { get; init; }
public int? Expire { get; init; }
public List<string> Tags { get; init; } = [];
}

View File

@@ -1,18 +0,0 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestAppriseProviderRequest
{
public AppriseMode Mode { get; init; } = AppriseMode.Api;
// API mode fields
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
// CLI mode fields
public string? ServiceUrls { get; init; }
}

View File

@@ -1,8 +0,0 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestNotifiarrProviderRequest
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
}

View File

@@ -1,22 +0,0 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestNtfyProviderRequest
{
public string ServerUrl { get; init; } = string.Empty;
public List<string> Topics { get; init; } = [];
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
public string Username { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
public string AccessToken { get; init; } = string.Empty;
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
public List<string> Tags { get; init; } = [];
}

View File

@@ -1,22 +0,0 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestPushoverProviderRequest
{
public string ApiToken { get; init; } = string.Empty;
public string UserKey { get; init; } = string.Empty;
public List<string> Devices { get; init; } = [];
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
public string? Sound { get; init; }
public int? Retry { get; init; }
public int? Expire { get; init; }
public List<string> Tags { get; init; } = [];
}

View File

@@ -1,18 +0,0 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdateAppriseProviderRequest : UpdateNotificationProviderRequestBase
{
public AppriseMode Mode { get; init; } = AppriseMode.Api;
// API mode fields
public string Url { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public string Tags { get; init; } = string.Empty;
// CLI mode fields
public string? ServiceUrls { get; init; }
}

View File

@@ -1,8 +0,0 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdateNotifiarrProviderRequest : UpdateNotificationProviderRequestBase
{
public string ApiKey { get; init; } = string.Empty;
public string ChannelId { get; init; } = string.Empty;
}

View File

@@ -1,20 +0,0 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public abstract record UpdateNotificationProviderRequestBase
{
public string Name { get; init; } = string.Empty;
public bool IsEnabled { get; init; }
public bool OnFailedImportStrike { get; init; }
public bool OnStalledStrike { get; init; }
public bool OnSlowStrike { get; init; }
public bool OnQueueItemDeleted { get; init; }
public bool OnDownloadCleaned { get; init; }
public bool OnCategoryChanged { get; init; }
}

View File

@@ -1,22 +0,0 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdateNtfyProviderRequest : UpdateNotificationProviderRequestBase
{
public string ServerUrl { get; init; } = string.Empty;
public List<string> Topics { get; init; } = [];
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
public string Username { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
public string AccessToken { get; init; } = string.Empty;
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
public List<string> Tags { get; init; } = [];
}

View File

@@ -1,22 +0,0 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdatePushoverProviderRequest : UpdateNotificationProviderRequestBase
{
public string ApiToken { get; init; } = string.Empty;
public string UserKey { get; init; } = string.Empty;
public List<string> Devices { get; init; } = [];
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
public string? Sound { get; init; }
public int? Retry { get; init; }
public int? Expire { get; init; }
public List<string> Tags { get; init; } = [];
}

View File

@@ -1,19 +0,0 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Cleanuparr.Api.Features.Notifications.Contracts.Responses;
public sealed record NotificationProviderResponse
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
public NotificationProviderType Type { get; init; }
public bool IsEnabled { get; init; }
public NotificationEventFlags Events { get; init; } = new();
public object Configuration { get; init; } = new();
}

View File

@@ -1,6 +0,0 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Responses;
public sealed record NotificationProvidersResponse
{
public List<NotificationProviderResponse> Providers { get; init; } = [];
}

View File

@@ -1,935 +0,0 @@
using System.Net;
using Cleanuparr.Api.Features.Notifications.Contracts.Requests;
using Cleanuparr.Api.Features.Notifications.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Notifications.Controllers;
[ApiController]
[Route("api/configuration/notification_providers")]
public sealed class NotificationProvidersController : ControllerBase
{
private readonly ILogger<NotificationProvidersController> _logger;
private readonly DataContext _dataContext;
private readonly INotificationConfigurationService _notificationConfigurationService;
private readonly NotificationService _notificationService;
private readonly IAppriseCliDetector _appriseCliDetector;
public NotificationProvidersController(
ILogger<NotificationProvidersController> logger,
DataContext dataContext,
INotificationConfigurationService notificationConfigurationService,
NotificationService notificationService,
IAppriseCliDetector appriseCliDetector)
{
_logger = logger;
_dataContext = dataContext;
_notificationConfigurationService = notificationConfigurationService;
_notificationService = notificationService;
_appriseCliDetector = appriseCliDetector;
}
[HttpGet]
public async Task<IActionResult> GetNotificationProviders()
{
await DataContext.Lock.WaitAsync();
try
{
var providers = await _dataContext.NotificationConfigs
.Include(p => p.NotifiarrConfiguration)
.Include(p => p.AppriseConfiguration)
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.AsNoTracking()
.ToListAsync();
var providerDtos = providers
.Select(p => new NotificationProviderResponse
{
Id = p.Id,
Name = p.Name,
Type = p.Type,
IsEnabled = p.IsEnabled,
Events = new NotificationEventFlags
{
OnFailedImportStrike = p.OnFailedImportStrike,
OnStalledStrike = p.OnStalledStrike,
OnSlowStrike = p.OnSlowStrike,
OnQueueItemDeleted = p.OnQueueItemDeleted,
OnDownloadCleaned = p.OnDownloadCleaned,
OnCategoryChanged = p.OnCategoryChanged
},
Configuration = p.Type switch
{
NotificationProviderType.Notifiarr => p.NotifiarrConfiguration ?? new object(),
NotificationProviderType.Apprise => p.AppriseConfiguration ?? new object(),
NotificationProviderType.Ntfy => p.NtfyConfiguration ?? new object(),
NotificationProviderType.Pushover => p.PushoverConfiguration ?? new object(),
_ => new object()
}
})
.OrderBy(x => x.Type.ToString())
.ThenBy(x => x.Name)
.ToList();
var response = new NotificationProvidersResponse { Providers = providerDtos };
return Ok(response);
}
finally
{
DataContext.Lock.Release();
}
}
[HttpGet("apprise/cli-status")]
public async Task<IActionResult> GetAppriseCliStatus()
{
string? version = await _appriseCliDetector.GetAppriseVersionAsync();
return Ok(new
{
Available = version is not null,
Version = version
});
}
[HttpPost("notifiarr")]
public async Task<IActionResult> CreateNotifiarrProvider([FromBody] CreateNotifiarrProviderRequest newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var notifiarrConfig = new NotifiarrConfig
{
ApiKey = newProvider.ApiKey,
ChannelId = newProvider.ChannelId
};
notifiarrConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Notifiarr,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
NotifiarrConfiguration = notifiarrConfig
};
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Notifiarr provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("apprise")]
public async Task<IActionResult> CreateAppriseProvider([FromBody] CreateAppriseProviderRequest newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var appriseConfig = new AppriseConfig
{
Mode = newProvider.Mode,
Url = newProvider.Url,
Key = newProvider.Key,
Tags = newProvider.Tags,
ServiceUrls = newProvider.ServiceUrls
};
appriseConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Apprise,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
AppriseConfiguration = appriseConfig
};
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Apprise provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("ntfy")]
public async Task<IActionResult> CreateNtfyProvider([FromBody] CreateNtfyProviderRequest newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var ntfyConfig = new NtfyConfig
{
ServerUrl = newProvider.ServerUrl,
Topics = newProvider.Topics,
AuthenticationType = newProvider.AuthenticationType,
Username = newProvider.Username,
Password = newProvider.Password,
AccessToken = newProvider.AccessToken,
Priority = newProvider.Priority,
Tags = newProvider.Tags
};
ntfyConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Ntfy,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
NtfyConfiguration = ntfyConfig
};
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Ntfy provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("notifiarr/{id:guid}")]
public async Task<IActionResult> UpdateNotifiarrProvider(Guid id, [FromBody] UpdateNotifiarrProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.NotifiarrConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Notifiarr);
if (existingProvider == null)
{
return NotFound($"Notifiarr provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var notifiarrConfig = new NotifiarrConfig
{
ApiKey = updatedProvider.ApiKey,
ChannelId = updatedProvider.ChannelId
};
if (existingProvider.NotifiarrConfiguration != null)
{
notifiarrConfig = notifiarrConfig with { Id = existingProvider.NotifiarrConfiguration.Id };
}
notifiarrConfig.Validate();
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
NotifiarrConfiguration = notifiarrConfig,
UpdatedAt = DateTime.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Notifiarr provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("apprise/{id:guid}")]
public async Task<IActionResult> UpdateAppriseProvider(Guid id, [FromBody] UpdateAppriseProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.AppriseConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Apprise);
if (existingProvider == null)
{
return NotFound($"Apprise provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var appriseConfig = new AppriseConfig
{
Mode = updatedProvider.Mode,
Url = updatedProvider.Url,
Key = updatedProvider.Key,
Tags = updatedProvider.Tags,
ServiceUrls = updatedProvider.ServiceUrls
};
if (existingProvider.AppriseConfiguration != null)
{
appriseConfig = appriseConfig with { Id = existingProvider.AppriseConfiguration.Id };
}
appriseConfig.Validate();
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
AppriseConfiguration = appriseConfig,
UpdatedAt = DateTime.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Apprise provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("ntfy/{id:guid}")]
public async Task<IActionResult> UpdateNtfyProvider(Guid id, [FromBody] UpdateNtfyProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.NtfyConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Ntfy);
if (existingProvider == null)
{
return NotFound($"Ntfy provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var ntfyConfig = new NtfyConfig
{
ServerUrl = updatedProvider.ServerUrl,
Topics = updatedProvider.Topics,
AuthenticationType = updatedProvider.AuthenticationType,
Username = updatedProvider.Username,
Password = updatedProvider.Password,
AccessToken = updatedProvider.AccessToken,
Priority = updatedProvider.Priority,
Tags = updatedProvider.Tags
};
if (existingProvider.NtfyConfiguration != null)
{
ntfyConfig = ntfyConfig with { Id = existingProvider.NtfyConfiguration.Id };
}
ntfyConfig.Validate();
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
NtfyConfiguration = ntfyConfig,
UpdatedAt = DateTime.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Ntfy provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteNotificationProvider(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.NotifiarrConfiguration)
.Include(p => p.AppriseConfiguration)
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.FirstOrDefaultAsync(p => p.Id == id);
if (existingProvider == null)
{
return NotFound($"Notification provider with ID {id} not found");
}
_dataContext.NotificationConfigs.Remove(existingProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
_logger.LogInformation("Removed notification provider {ProviderName} with ID {ProviderId}",
existingProvider.Name, existingProvider.Id);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete notification provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("notifiarr/test")]
public async Task<IActionResult> TestNotifiarrProvider([FromBody] TestNotifiarrProviderRequest testRequest)
{
try
{
var notifiarrConfig = new NotifiarrConfig
{
ApiKey = testRequest.ApiKey,
ChannelId = testRequest.ChannelId
};
notifiarrConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Notifiarr,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = notifiarrConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Notifiarr provider");
throw;
}
}
[HttpPost("apprise/test")]
public async Task<IActionResult> TestAppriseProvider([FromBody] TestAppriseProviderRequest testRequest)
{
try
{
var appriseConfig = new AppriseConfig
{
Mode = testRequest.Mode,
Url = testRequest.Url,
Key = testRequest.Key,
Tags = testRequest.Tags,
ServiceUrls = testRequest.ServiceUrls
};
appriseConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Apprise,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = appriseConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
}
catch (AppriseException exception)
{
return StatusCode((int)HttpStatusCode.InternalServerError, exception.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Apprise provider");
throw;
}
}
[HttpPost("ntfy/test")]
public async Task<IActionResult> TestNtfyProvider([FromBody] TestNtfyProviderRequest testRequest)
{
try
{
var ntfyConfig = new NtfyConfig
{
ServerUrl = testRequest.ServerUrl,
Topics = testRequest.Topics,
AuthenticationType = testRequest.AuthenticationType,
Username = testRequest.Username,
Password = testRequest.Password,
AccessToken = testRequest.AccessToken,
Priority = testRequest.Priority,
Tags = testRequest.Tags
};
ntfyConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Ntfy,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = ntfyConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Ntfy provider");
throw;
}
}
private static NotificationProviderResponse MapProvider(NotificationConfig provider)
{
return new NotificationProviderResponse
{
Id = provider.Id,
Name = provider.Name,
Type = provider.Type,
IsEnabled = provider.IsEnabled,
Events = new NotificationEventFlags
{
OnFailedImportStrike = provider.OnFailedImportStrike,
OnStalledStrike = provider.OnStalledStrike,
OnSlowStrike = provider.OnSlowStrike,
OnQueueItemDeleted = provider.OnQueueItemDeleted,
OnDownloadCleaned = provider.OnDownloadCleaned,
OnCategoryChanged = provider.OnCategoryChanged
},
Configuration = provider.Type switch
{
NotificationProviderType.Notifiarr => provider.NotifiarrConfiguration ?? new object(),
NotificationProviderType.Apprise => provider.AppriseConfiguration ?? new object(),
NotificationProviderType.Ntfy => provider.NtfyConfiguration ?? new object(),
NotificationProviderType.Pushover => provider.PushoverConfiguration ?? new object(),
_ => new object()
}
};
}
[HttpPost("pushover")]
public async Task<IActionResult> CreatePushoverProvider([FromBody] CreatePushoverProviderRequest newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var pushoverConfig = new PushoverConfig
{
ApiToken = newProvider.ApiToken,
UserKey = newProvider.UserKey,
Devices = newProvider.Devices,
Priority = newProvider.Priority,
Sound = newProvider.Sound,
Retry = newProvider.Retry,
Expire = newProvider.Expire,
Tags = newProvider.Tags
};
pushoverConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Pushover,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
PushoverConfiguration = pushoverConfig
};
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Pushover provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("pushover/{id:guid}")]
public async Task<IActionResult> UpdatePushoverProvider(Guid id, [FromBody] UpdatePushoverProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.PushoverConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Pushover);
if (existingProvider == null)
{
return NotFound($"Pushover provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var pushoverConfig = new PushoverConfig
{
ApiToken = updatedProvider.ApiToken,
UserKey = updatedProvider.UserKey,
Devices = updatedProvider.Devices,
Priority = updatedProvider.Priority,
Sound = updatedProvider.Sound,
Retry = updatedProvider.Retry,
Expire = updatedProvider.Expire,
Tags = updatedProvider.Tags
};
if (existingProvider.PushoverConfiguration != null)
{
pushoverConfig = pushoverConfig with { Id = existingProvider.PushoverConfiguration.Id };
}
pushoverConfig.Validate();
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
PushoverConfiguration = pushoverConfig,
UpdatedAt = DateTime.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Pushover provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("pushover/test")]
public async Task<IActionResult> TestPushoverProvider([FromBody] TestPushoverProviderRequest testRequest)
{
try
{
var pushoverConfig = new PushoverConfig
{
ApiToken = testRequest.ApiToken,
UserKey = testRequest.UserKey,
Devices = testRequest.Devices,
Priority = testRequest.Priority,
Sound = testRequest.Sound,
Retry = testRequest.Retry,
Expire = testRequest.Expire,
Tags = testRequest.Tags
};
pushoverConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Pushover,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = pushoverConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully", Success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Pushover provider");
throw;
}
}
}

View File

@@ -1,27 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
public abstract record QueueRuleDto
{
public Guid? Id { get; set; }
[Required]
public string Name { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
[Range(3, int.MaxValue, ErrorMessage = "Max strikes must be at least 3")]
public int MaxStrikes { get; set; } = 3;
public TorrentPrivacyType PrivacyType { get; set; } = TorrentPrivacyType.Public;
[Range(0, 100, ErrorMessage = "Minimum completion percentage must be between 0 and 100")]
public ushort MinCompletionPercentage { get; set; }
[Range(0, 100, ErrorMessage = "Maximum completion percentage must be between 0 and 100")]
public ushort MaxCompletionPercentage { get; set; }
public bool DeletePrivateTorrentsFromClient { get; set; } = false;
}

View File

@@ -1,15 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
public sealed record SlowRuleDto : QueueRuleDto
{
public bool ResetStrikesOnProgress { get; set; } = true;
public string MinSpeed { get; set; } = string.Empty;
[Range(0, double.MaxValue, ErrorMessage = "Maximum time cannot be negative")]
public double MaxTimeHours { get; set; } = 0;
public string? IgnoreAboveSize { get; set; }
}

View File

@@ -1,8 +0,0 @@
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
public sealed record StallRuleDto : QueueRuleDto
{
public bool ResetStrikesOnProgress { get; set; } = true;
public string? MinimumProgress { get; set; }
}

View File

@@ -1,18 +0,0 @@
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
namespace Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
public sealed record UpdateQueueCleanerConfigRequest
{
public bool Enabled { get; init; }
public string CronExpression { get; init; } = "0 0/5 * * * ?";
public bool UseAdvancedScheduling { get; init; }
public FailedImportConfig FailedImport { get; init; } = new();
public ushort DownloadingMetadataMaxStrikes { get; init; }
public List<string> IgnoredDownloads { get; set; } = [];
}

View File

@@ -1,117 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.QueueCleaner.Controllers;
[ApiController]
[Route("api/configuration")]
public sealed class QueueCleanerConfigController : ControllerBase
{
private readonly ILogger<QueueCleanerConfigController> _logger;
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
public QueueCleanerConfigController(
ILogger<QueueCleanerConfigController> logger,
DataContext dataContext,
IJobManagementService jobManagementService)
{
_logger = logger;
_dataContext = dataContext;
_jobManagementService = jobManagementService;
}
[HttpGet("queue_cleaner")]
public async Task<IActionResult> GetQueueCleanerConfig()
{
await DataContext.Lock.WaitAsync();
try
{
var config = await _dataContext.QueueCleanerConfigs
.AsNoTracking()
.FirstAsync();
return Ok(config);
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("queue_cleaner")]
public async Task<IActionResult> UpdateQueueCleanerConfig([FromBody] UpdateQueueCleanerConfigRequest newConfigDto)
{
await DataContext.Lock.WaitAsync();
try
{
if (!string.IsNullOrEmpty(newConfigDto.CronExpression))
{
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
}
var oldConfig = await _dataContext.QueueCleanerConfigs
.FirstAsync();
oldConfig.Enabled = newConfigDto.Enabled;
oldConfig.CronExpression = newConfigDto.CronExpression;
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
oldConfig.FailedImport = newConfigDto.FailedImport;
oldConfig.DownloadingMetadataMaxStrikes = newConfigDto.DownloadingMetadataMaxStrikes;
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
oldConfig.Validate();
await _dataContext.SaveChangesAsync();
await UpdateJobSchedule(oldConfig, JobType.QueueCleaner);
return Ok(new { Message = "QueueCleaner configuration updated successfully" });
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save QueueCleaner configuration");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
{
if (config.Enabled)
{
if (!string.IsNullOrEmpty(config.CronExpression))
{
_logger.LogInformation("{name} is enabled, updating job schedule with cron expression: {CronExpression}",
jobType.ToString(), config.CronExpression);
await _jobManagementService.StartJob(jobType, null, config.CronExpression);
}
else
{
_logger.LogWarning("{name} is enabled, but no cron expression was found in the configuration", jobType.ToString());
}
return;
}
_logger.LogInformation("{name} is disabled, stopping the job", jobType.ToString());
await _jobManagementService.StopJob(jobType);
}
}

View File

@@ -1,437 +0,0 @@
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Api.Features.QueueCleaner.Controllers;
[ApiController]
[Route("api/queue-rules")]
public class QueueRulesController : ControllerBase
{
private readonly ILogger<QueueRulesController> _logger;
private readonly DataContext _dataContext;
private readonly IRuleIntervalValidator _ruleIntervalValidator;
public QueueRulesController(
ILogger<QueueRulesController> logger,
DataContext dataContext,
IRuleIntervalValidator ruleIntervalValidator)
{
_logger = logger;
_dataContext = dataContext;
_ruleIntervalValidator = ruleIntervalValidator;
}
[HttpGet("stall")]
public async Task<IActionResult> GetStallRules()
{
await DataContext.Lock.WaitAsync();
try
{
var rules = await _dataContext.StallRules
.OrderBy(r => r.MinCompletionPercentage)
.ThenBy(r => r.Name)
.AsNoTracking()
.ToListAsync();
return Ok(rules);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve stall rules");
return StatusCode(500, new { Message = "Failed to retrieve stall rules", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("stall")]
public async Task<IActionResult> CreateStallRule([FromBody] StallRuleDto ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
var queueCleanerConfig = await _dataContext.QueueCleanerConfigs
.FirstAsync();
var existingRule = await _dataContext.StallRules
.FirstOrDefaultAsync(r => r.Name.ToLower() == ruleDto.Name.ToLower());
if (existingRule != null)
{
return BadRequest(new { Message = "A stall rule with this name already exists" });
}
var rule = new StallRule
{
Id = Guid.NewGuid(),
QueueCleanerConfigId = queueCleanerConfig.Id,
Name = ruleDto.Name.Trim(),
Enabled = ruleDto.Enabled,
MaxStrikes = ruleDto.MaxStrikes,
PrivacyType = ruleDto.PrivacyType,
MinCompletionPercentage = ruleDto.MinCompletionPercentage,
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
MinimumProgress = ruleDto.MinimumProgress?.Trim(),
};
var existingRules = await _dataContext.StallRules.ToListAsync();
var intervalValidationResult = _ruleIntervalValidator.ValidateStallRuleIntervals(rule, existingRules);
if (!intervalValidationResult.IsValid)
{
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
}
rule.Validate();
_dataContext.StallRules.Add(rule);
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Created stall rule: {RuleName} with ID: {RuleId}", rule.Name, rule.Id);
return CreatedAtAction(nameof(GetStallRules), new { id = rule.Id }, rule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for stall rule creation: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create stall rule: {RuleName}", ruleDto.Name);
return StatusCode(500, new { Message = "Failed to create stall rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("stall/{id}")]
public async Task<IActionResult> UpdateStallRule(Guid id, [FromBody] StallRuleDto ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
var existingRule = await _dataContext.StallRules
.FirstOrDefaultAsync(r => r.Id == id);
if (existingRule == null)
{
return NotFound(new { Message = $"Stall rule with ID {id} not found" });
}
var duplicateRule = await _dataContext.StallRules
.FirstOrDefaultAsync(r => r.Id != id && r.Name.ToLower() == ruleDto.Name.ToLower());
if (duplicateRule != null)
{
return BadRequest(new { Message = "A stall rule with this name already exists" });
}
var updatedRule = existingRule with
{
Name = ruleDto.Name.Trim(),
Enabled = ruleDto.Enabled,
MaxStrikes = ruleDto.MaxStrikes,
PrivacyType = ruleDto.PrivacyType,
MinCompletionPercentage = ruleDto.MinCompletionPercentage,
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
MinimumProgress = ruleDto.MinimumProgress?.Trim(),
};
var existingRules = await _dataContext.StallRules
.Where(r => r.Id != id)
.ToListAsync();
var intervalValidationResult = _ruleIntervalValidator.ValidateStallRuleIntervals(updatedRule, existingRules);
if (!intervalValidationResult.IsValid)
{
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
}
updatedRule.Validate();
_dataContext.Entry(existingRule).CurrentValues.SetValues(updatedRule);
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Updated stall rule: {RuleName} with ID: {RuleId}", updatedRule.Name, id);
return Ok(updatedRule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for stall rule update: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update stall rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to update stall rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("stall/{id}")]
public async Task<IActionResult> DeleteStallRule(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
var existingRule = await _dataContext.StallRules
.FirstOrDefaultAsync(r => r.Id == id);
if (existingRule == null)
{
return NotFound(new { Message = $"Stall rule with ID {id} not found" });
}
_dataContext.StallRules.Remove(existingRule);
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Deleted stall rule: {RuleName} with ID: {RuleId}", existingRule.Name, id);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete stall rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to delete stall rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpGet("slow")]
public async Task<IActionResult> GetSlowRules()
{
await DataContext.Lock.WaitAsync();
try
{
var rules = await _dataContext.SlowRules
.OrderBy(r => r.MinCompletionPercentage)
.ThenBy(r => r.Name)
.AsNoTracking()
.ToListAsync();
return Ok(rules);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve slow rules");
return StatusCode(500, new { Message = "Failed to retrieve slow rules", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("slow")]
public async Task<IActionResult> CreateSlowRule([FromBody] SlowRuleDto ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
var queueCleanerConfig = await _dataContext.QueueCleanerConfigs
.FirstAsync();
var existingRule = await _dataContext.SlowRules
.FirstOrDefaultAsync(r => r.Name.ToLower() == ruleDto.Name.ToLower());
if (existingRule != null)
{
return BadRequest(new { Message = "A slow rule with this name already exists" });
}
var rule = new SlowRule
{
Id = Guid.NewGuid(),
QueueCleanerConfigId = queueCleanerConfig.Id,
Name = ruleDto.Name.Trim(),
Enabled = ruleDto.Enabled,
MaxStrikes = ruleDto.MaxStrikes,
PrivacyType = ruleDto.PrivacyType,
MinCompletionPercentage = ruleDto.MinCompletionPercentage,
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
MinSpeed = ruleDto.MinSpeed?.Trim() ?? string.Empty,
MaxTimeHours = ruleDto.MaxTimeHours,
IgnoreAboveSize = ruleDto.IgnoreAboveSize,
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
};
var existingRules = await _dataContext.SlowRules.ToListAsync();
var intervalValidationResult = _ruleIntervalValidator.ValidateSlowRuleIntervals(rule, existingRules);
if (!intervalValidationResult.IsValid)
{
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
}
rule.Validate();
_dataContext.SlowRules.Add(rule);
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Created slow rule: {RuleName} with ID: {RuleId}", rule.Name, rule.Id);
return CreatedAtAction(nameof(GetSlowRules), new { id = rule.Id }, rule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for slow rule creation: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create slow rule: {RuleName}", ruleDto.Name);
return StatusCode(500, new { Message = "Failed to create slow rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("slow/{id}")]
public async Task<IActionResult> UpdateSlowRule(Guid id, [FromBody] SlowRuleDto ruleDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await DataContext.Lock.WaitAsync();
try
{
var existingRule = await _dataContext.SlowRules
.FirstOrDefaultAsync(r => r.Id == id);
if (existingRule == null)
{
return NotFound(new { Message = $"Slow rule with ID {id} not found" });
}
var duplicateRule = await _dataContext.SlowRules
.FirstOrDefaultAsync(r => r.Id != id && r.Name.ToLower() == ruleDto.Name.ToLower());
if (duplicateRule != null)
{
return BadRequest(new { Message = "A slow rule with this name already exists" });
}
var updatedRule = existingRule with
{
Name = ruleDto.Name.Trim(),
Enabled = ruleDto.Enabled,
MaxStrikes = ruleDto.MaxStrikes,
PrivacyType = ruleDto.PrivacyType,
MinCompletionPercentage = ruleDto.MinCompletionPercentage,
MaxCompletionPercentage = ruleDto.MaxCompletionPercentage,
ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress,
MinSpeed = ruleDto.MinSpeed?.Trim() ?? string.Empty,
MaxTimeHours = ruleDto.MaxTimeHours,
IgnoreAboveSize = ruleDto.IgnoreAboveSize,
DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient,
};
var existingRules = await _dataContext.SlowRules
.Where(r => r.Id != id)
.ToListAsync();
var intervalValidationResult = _ruleIntervalValidator.ValidateSlowRuleIntervals(updatedRule, existingRules);
if (!intervalValidationResult.IsValid)
{
return BadRequest(new { Message = intervalValidationResult.ErrorMessage });
}
updatedRule.Validate();
_dataContext.Entry(existingRule).CurrentValues.SetValues(updatedRule);
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Updated slow rule: {RuleName} with ID: {RuleId}", updatedRule.Name, id);
return Ok(updatedRule);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed for slow rule update: {Message}", ex.Message);
return BadRequest(new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update slow rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to update slow rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("slow/{id}")]
public async Task<IActionResult> DeleteSlowRule(Guid id)
{
await DataContext.Lock.WaitAsync();
try
{
var existingRule = await _dataContext.SlowRules
.FirstOrDefaultAsync(r => r.Id == id);
if (existingRule == null)
{
return NotFound(new { Message = $"Slow rule with ID {id} not found" });
}
_dataContext.SlowRules.Remove(existingRule);
await _dataContext.SaveChangesAsync();
_logger.LogInformation("Deleted slow rule: {RuleName} with ID: {RuleId}", existingRule.Name, id);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete slow rule with ID: {RuleId}", id);
return StatusCode(500, new { Message = "Failed to delete slow rule", Error = ex.Message });
}
finally
{
DataContext.Lock.Release();
}
}
}

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

@@ -1,7 +1,4 @@
using System.Reflection;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Infrastructure.Services;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -9,61 +6,33 @@ namespace Cleanuparr.Api;
public static class HostExtensions
{
public static IHost Init(this WebApplication app)
public static async Task<IHost> Init(this WebApplication app)
{
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
AppStatusSnapshot statusSnapshot = app.Services.GetRequiredService<AppStatusSnapshot>();
Version? version = Assembly.GetExecutingAssembly().GetName().Version;
string? formattedVersion = FormatVersion(version);
if (statusSnapshot.UpdateCurrentVersion(formattedVersion, out _))
{
logger.LogDebug("App status current version set to {Version}", formattedVersion);
}
logger.LogInformation(
version is null
? "Cleanuparr version not detected"
: $"Cleanuparr {formattedVersion}"
: $"Cleanuparr v{version.Major}.{version.Minor}.{version.Build}"
);
logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName);
return app;
}
private static string? FormatVersion(Version? version)
{
if (version is null)
{
return null;
}
if (version.Build >= 0)
{
return $"v{version.Major}.{version.Minor}.{version.Build}";
}
return $"v{version.Major}.{version.Minor}";
}
public static async Task<WebApplicationBuilder> InitAsync(this WebApplicationBuilder builder)
{
// Apply events db migrations
await using var eventsContext = EventsContext.CreateStaticInstance();
// Apply db migrations
var eventsContext = app.Services.GetRequiredService<EventsContext>();
if ((await eventsContext.Database.GetPendingMigrationsAsync()).Any())
{
await eventsContext.Database.MigrateAsync();
}
// Apply data db migrations
await using var configContext = DataContext.CreateStaticInstance();
var configContext = app.Services.GetRequiredService<DataContext>();
if ((await configContext.Database.GetPendingMigrationsAsync()).Any())
{
await configContext.Database.MigrateAsync();
}
return builder;
return app;
}
}

View File

@@ -1,11 +1,13 @@
using Cleanuparr.Application.Features.ContentBlocker;
using Cleanuparr.Application.Features.DownloadCleaner;
using Cleanuparr.Application.Features.QueueCleaner;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Quartz;
@@ -20,18 +22,18 @@ namespace Cleanuparr.Api.Jobs;
public class BackgroundJobManager : IHostedService
{
private readonly ISchedulerFactory _schedulerFactory;
private readonly IServiceScopeFactory _scopeFactory;
private readonly DataContext _dataContext;
private readonly ILogger<BackgroundJobManager> _logger;
private IScheduler? _scheduler;
public BackgroundJobManager(
ISchedulerFactory schedulerFactory,
IServiceScopeFactory scopeFactory,
DataContext dataContext,
ILogger<BackgroundJobManager> logger
)
{
_schedulerFactory = schedulerFactory;
_scopeFactory = scopeFactory;
_dataContext = dataContext;
_logger = logger;
}
@@ -43,12 +45,12 @@ public class BackgroundJobManager : IHostedService
{
try
{
_logger.LogDebug("Starting BackgroundJobManager");
_logger.LogInformation("Starting BackgroundJobManager");
_scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
await InitializeJobsFromConfiguration(cancellationToken);
_logger.LogDebug("BackgroundJobManager started");
_logger.LogInformation("BackgroundJobManager started");
}
catch (Exception ex)
{
@@ -62,15 +64,15 @@ public class BackgroundJobManager : IHostedService
/// </summary>
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Stopping BackgroundJobManager");
_logger.LogInformation("Stopping BackgroundJobManager");
if (_scheduler != null)
{
// Don't shut down the scheduler as it's managed by QuartzHostedService
// Don't shutdown the scheduler as it's managed by QuartzHostedService
await _scheduler.Standby(cancellationToken);
}
_logger.LogDebug("BackgroundJobManager stopped");
_logger.LogInformation("BackgroundJobManager stopped");
}
/// <summary>
@@ -84,28 +86,21 @@ public class BackgroundJobManager : IHostedService
throw new InvalidOperationException("Scheduler not initialized");
}
await using var scope = _scopeFactory.CreateAsyncScope();
await using var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
// Get configurations from db
QueueCleanerConfig queueCleanerConfig = await dataContext.QueueCleanerConfigs
QueueCleanerConfig queueCleanerConfig = await _dataContext.QueueCleanerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
ContentBlockerConfig malwareBlockerConfig = await dataContext.ContentBlockerConfigs
ContentBlockerConfig contentBlockerConfig = await _dataContext.ContentBlockerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
DownloadCleanerConfig downloadCleanerConfig = await dataContext.DownloadCleanerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
BlacklistSyncConfig blacklistSyncConfig = await dataContext.BlacklistSyncConfigs
DownloadCleanerConfig downloadCleanerConfig = await _dataContext.DownloadCleanerConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
// Always register jobs, regardless of enabled status
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
await RegisterContentBlockerJob(contentBlockerConfig, cancellationToken);
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
}
/// <summary>
@@ -121,24 +116,24 @@ public class BackgroundJobManager : IHostedService
// Only add triggers if the job is enabled
if (config.Enabled)
{
await AddTriggersForJob<QueueCleaner>(config.CronExpression, cancellationToken);
await AddTriggersForJob<QueueCleaner>(config, config.CronExpression, cancellationToken);
}
}
/// <summary>
/// Registers the QueueCleaner job and optionally adds triggers based on configuration.
/// </summary>
public async Task RegisterMalwareBlockerJob(
public async Task RegisterContentBlockerJob(
ContentBlockerConfig config,
CancellationToken cancellationToken = default)
{
// Always register the job definition
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken);
await AddJobWithoutTrigger<ContentBlocker>(cancellationToken);
// Only add triggers if the job is enabled
if (config.Enabled)
{
await AddTriggersForJob<MalwareBlocker>(config.CronExpression, cancellationToken);
await AddTriggersForJob<ContentBlocker>(config, config.CronExpression, cancellationToken);
}
}
@@ -153,21 +148,7 @@ public class BackgroundJobManager : IHostedService
// Only add triggers if the job is enabled
if (config.Enabled)
{
await AddTriggersForJob<DownloadCleaner>(config.CronExpression, cancellationToken);
}
}
/// <summary>
/// Registers the BlacklistSync job and optionally adds triggers based on general configuration.
/// </summary>
public async Task RegisterBlacklistSyncJob(BlacklistSyncConfig config, CancellationToken cancellationToken = default)
{
// Always register the job definition
await AddJobWithoutTrigger<BlacklistSynchronizer>(cancellationToken);
if (config.Enabled)
{
await AddTriggersForJob<BlacklistSynchronizer>(config.CronExpression, cancellationToken);
await AddTriggersForJob<DownloadCleaner>(config, config.CronExpression, cancellationToken);
}
}
@@ -175,9 +156,10 @@ public class BackgroundJobManager : IHostedService
/// Helper method to add triggers for an existing job.
/// </summary>
private async Task AddTriggersForJob<T>(
IJobConfig config,
string cronExpression,
CancellationToken cancellationToken = default)
where T : IHandler
where T : GenericHandler
{
if (_scheduler == null)
{
@@ -193,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);
@@ -204,7 +186,7 @@ public class BackgroundJobManager : IHostedService
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
}
if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit)
if (typeof(T) != typeof(ContentBlocker) && triggerValue < Constants.TriggerMinLimit)
{
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
}
@@ -215,17 +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);
// Create startup trigger to run immediately
var startupTrigger = TriggerBuilder.Create()
.WithIdentity($"{typeName}-startup-trigger")
.ForJob(jobKey)
.StartNow()
.Build();
_logger.LogInformation("Added trigger for job {name} with cron expression {CronExpression}",
// Schedule job with both triggers
await _scheduler.ScheduleJob(trigger, cancellationToken);
await _scheduler.ScheduleJob(startupTrigger, cancellationToken);
_logger.LogInformation("Added triggers for job {name} with cron expression {CronExpression}",
typeName, cronExpression);
}
@@ -233,7 +224,7 @@ public class BackgroundJobManager : IHostedService
/// Helper method to add a job without a trigger (for chained jobs).
/// </summary>
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
where T : IHandler
where T : GenericHandler
{
if (_scheduler == null)
{
@@ -259,6 +250,6 @@ public class BackgroundJobManager : IHostedService
// Add job to scheduler
await _scheduler.AddJob(jobDetail, true, cancellationToken);
_logger.LogDebug("Registered job {name} without trigger", typeName);
_logger.LogInformation("Registered job {name} without trigger", typeName);
}
}

View File

@@ -1,8 +1,4 @@
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Microsoft.AspNetCore.SignalR;
using Quartz;
using Serilog.Context;
@@ -13,12 +9,12 @@ public sealed class GenericJob<T> : IJob
where T : IHandler
{
private readonly ILogger<GenericJob<T>> _logger;
private readonly IServiceScopeFactory _scopeFactory;
public GenericJob(ILogger<GenericJob<T>> logger, IServiceScopeFactory scopeFactory)
private readonly T _handler;
public GenericJob(ILogger<GenericJob<T>> logger, T handler)
{
_logger = logger;
_scopeFactory = scopeFactory;
_handler = handler;
}
public async Task Execute(IJobExecutionContext context)
@@ -27,40 +23,11 @@ public sealed class GenericJob<T> : IJob
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<AppHub>>();
var jobManagementService = scope.ServiceProvider.GetRequiredService<IJobManagementService>();
await BroadcastJobStatus(hubContext, jobManagementService, false);
var handler = scope.ServiceProvider.GetRequiredService<T>();
await handler.ExecuteAsync();
await BroadcastJobStatus(hubContext, jobManagementService, true);
await _handler.ExecuteAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "{name} failed", typeof(T).Name);
}
}
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, bool isFinished)
{
try
{
JobType jobType = Enum.Parse<JobType>(typeof(T).Name);
JobInfo jobInfo = await jobManagementService.GetJob(jobType);
if (isFinished)
{
jobInfo.Status = "Scheduled";
}
await hubContext.Clients.All.SendAsync("JobStatusUpdate", jobInfo);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to broadcast job status update");
}
}
}

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using CreateAppriseProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateAppriseProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateAppriseProviderRequest instead.")]
public sealed record CreateAppriseProviderDto : CreateAppriseProviderRequest;

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using CreateNotifiarrProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNotifiarrProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNotifiarrProviderRequest instead.")]
public sealed record CreateNotifiarrProviderDto : CreateNotifiarrProviderRequest;

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using CreateNtfyProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNtfyProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.CreateNtfyProviderRequest instead.")]
public sealed record CreateNtfyProviderDto : CreateNtfyProviderRequest;

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using TestAppriseProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestAppriseProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestAppriseProviderRequest instead.")]
public sealed record TestAppriseProviderDto : TestAppriseProviderRequest;

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using TestNotifiarrProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNotifiarrProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNotifiarrProviderRequest instead.")]
public sealed record TestNotifiarrProviderDto : TestNotifiarrProviderRequest;

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using TestNtfyProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNtfyProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.TestNtfyProviderRequest instead.")]
public sealed record TestNtfyProviderDto : TestNtfyProviderRequest;

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using UpdateAppriseProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateAppriseProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateAppriseProviderRequest instead.")]
public sealed record UpdateAppriseProviderDto : UpdateAppriseProviderRequest;

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using UpdateNotifiarrProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNotifiarrProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNotifiarrProviderRequest instead.")]
public sealed record UpdateNotifiarrProviderDto : UpdateNotifiarrProviderRequest;

View File

@@ -1,8 +0,0 @@
using System;
namespace Cleanuparr.Api.Models.NotificationProviders;
using UpdateNtfyProviderRequest = Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNtfyProviderRequest;
[Obsolete("Use Cleanuparr.Api.Features.Notifications.Contracts.Requests.UpdateNtfyProviderRequest instead.")]
public sealed record UpdateNtfyProviderDto : UpdateNtfyProviderRequest;

View File

@@ -1 +0,0 @@
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests

View File

@@ -1 +0,0 @@
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests

View File

@@ -1 +0,0 @@
// Moved to Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests

View File

@@ -1,23 +1,53 @@
using System.Diagnostics.CodeAnalysis;
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Api.Models;
/// <summary>
/// Legacy namespace shim; prefer <see cref="UpdateDownloadCleanerConfigRequest"/> from
/// <c>Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests</c>.
/// </summary>
[Obsolete("Use Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests.UpdateDownloadCleanerConfigRequest instead")]
[SuppressMessage("Design", "CA1000", Justification = "Temporary alias during refactor")]
[SuppressMessage("Usage", "CA2225", Justification = "Alias type")]
public record UpdateDownloadCleanerConfigDto : UpdateDownloadCleanerConfigRequest;
public class UpdateDownloadCleanerConfigDto
{
public bool Enabled { get; set; }
/// <summary>
/// Legacy namespace shim; prefer <see cref="CleanCategoryRequest"/> from
/// <c>Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests</c>.
/// </summary>
[Obsolete("Use Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests.CleanCategoryRequest instead")]
[SuppressMessage("Design", "CA1000", Justification = "Temporary alias during refactor")]
[SuppressMessage("Usage", "CA2225", Justification = "Alias type")]
public record CleanCategoryDto : CleanCategoryRequest;
public string CronExpression { get; set; } = "0 0 * * * ?";
/// <summary>
/// Indicates whether to use the CronExpression directly or convert from a user-friendly schedule
/// </summary>
public bool UseAdvancedScheduling { get; set; }
public List<CleanCategoryDto> Categories { get; set; } = [];
public bool DeletePrivate { get; set; }
/// <summary>
/// Indicates whether unlinked download handling is enabled
/// </summary>
public bool UnlinkedEnabled { get; set; } = false;
public string UnlinkedTargetCategory { get; set; } = "cleanuparr-unlinked";
public bool UnlinkedUseTag { get; set; }
public string UnlinkedIgnoredRootDir { get; set; } = string.Empty;
public List<string> UnlinkedCategories { get; set; } = [];
}
public class CleanCategoryDto
{
[Required]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Max ratio before removing a download.
/// </summary>
public double MaxRatio { get; set; } = -1;
/// <summary>
/// Min number of hours to seed before removing a download, if the ratio has been met.
/// </summary>
public double MinSeedTime { get; set; }
/// <summary>
/// Number of hours to seed before removing a download.
/// </summary>
public double MaxSeedTime { get; set; } = -1;
}

View File

@@ -2,18 +2,12 @@ using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
using Cleanuparr.Api;
using Cleanuparr.Api.DependencyInjection;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Logging;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.SignalR;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
await builder.InitAsync();
builder.Logging.AddLogging();
// Fix paths for single-file deployment on macOS
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
@@ -50,7 +44,6 @@ builder.Services.AddResponseCompression(options =>
// Configure JSON options to serialize enums as strings
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNameCaseInsensitive = true;
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
@@ -73,6 +66,14 @@ builder.Services.AddCors(options =>
});
});
// Register services needed for logging first
builder.Services
.AddTransient<LoggingConfigManager>()
.AddSingleton<SignalRLogSink>();
// Add logging with proper service provider
builder.Logging.AddLogging();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
builder.Host.UseWindowsService(options =>
@@ -127,30 +128,25 @@ if (basePath is not null)
logger.LogInformation("Server configuration: PORT={port}, BASE_PATH={basePath}", port, basePath ?? "/");
// Initialize the host
app.Init();
await app.Init();
// Configure the app hub for SignalR
var appHub = app.Services.GetRequiredService<IHubContext<AppHub>>();
SignalRLogSink.Instance.SetAppHubContext(appHub);
// Get LoggingConfigManager (will be created if not already registered)
var configManager = app.Services.GetRequiredService<LoggingConfigManager>();
// Get the dynamic level switch for controlling log levels
var levelSwitch = configManager.GetLevelSwitch();
// Get the SignalRLogSink instance
var signalRSink = app.Services.GetRequiredService<SignalRLogSink>();
// Configure health check endpoints before the API configuration
app.MapHealthChecks("/health", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("liveness"),
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
});
var logConfig = LoggingDI.GetDefaultLoggerConfiguration();
logConfig.MinimumLevel.ControlledBy(levelSwitch);
// Add to Serilog pipeline
logConfig.WriteTo.Sink(signalRSink);
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("readiness"),
ResponseWriter = HealthCheckResponseWriter.WriteMinimalPlaintext
});
Log.Logger = logConfig.CreateLogger();
app.ConfigureApi();
await app.RunAsync();
await Log.CloseAndFlushAsync();
// Make Program class accessible for testing
public partial class Program { }
await app.RunAsync();

View File

@@ -1,3 +0,0 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Cleanuparr.Api.Tests")]

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Cleanuparr.Domain\Cleanuparr.Domain.csproj" />
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
<ProjectReference Include="..\Cleanuparr.Persistence\Cleanuparr.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MassTransit" Version="8.4.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,6 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
namespace Cleanuparr.Application.Features.Arr.Dtos;
public class ArrConfigDto
{

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
namespace Cleanuparr.Application.Features.Arr.Dtos;
/// <summary>
/// DTO for creating new Arr instances without requiring an ID

View File

@@ -1,4 +1,4 @@
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
namespace Cleanuparr.Application.Features.Arr.Dtos;
/// <summary>
/// DTO for updating Lidarr configuration basic settings (instances managed separately)

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