Compare commits

..

311 Commits
v2.0.12 ... dev

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

View File

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

View File

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

View File

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

View File

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

@@ -363,4 +363,14 @@ jobs:
path: '${{ env.pkgName }}'
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: |
${{ env.pkgName }}

View File

@@ -363,4 +363,14 @@ jobs:
path: '${{ env.pkgName }}'
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: |
${{ env.pkgName }}

View File

@@ -88,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: |
@@ -145,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

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

View File

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

347
blacklist
View File

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

View File

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

View File

@@ -45,7 +45,6 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
# Install required packages for user management and timezone support
RUN apt-get update && apt-get install -y \
curl \
tzdata \
gosu \
&& rm -rf /var/lib/apt/lists/*

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,9 @@ public static class ApiDI
// Add health status broadcaster
services.AddHostedService<HealthStatusBroadcaster>();
// Add logging initializer service
services.AddHostedService<LoggingInitializer>();
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
@@ -139,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 = "assets/icons/icon-192x192.png",
sizes = "192x192",
type = "image/png"
},
new {
src = "assets/icons/icon-512x512.png",
sizes = "512x512",
type = "image/png"
}
}
};
return Results.Json(manifest, contentType: "application/manifest+json");
});
return app;
}

View File

@@ -1,12 +1,11 @@
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Entities.Arr;
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;
@@ -27,7 +26,7 @@ public static class MainDI
.AddMassTransit(config =>
{
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
config.AddConsumer<DownloadRemoverConsumer<SonarrSearchItem>>();
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
@@ -49,7 +48,7 @@ public static class MainDI
cfg.ReceiveEndpoint("download-remover-queue", e =>
{
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
e.ConfigureConsumer<DownloadRemoverConsumer<SeriesSearchItem>>(context);
e.ConfigureConsumer<DownloadRemoverConsumer<SonarrSearchItem>>(context);
e.ConcurrentMessageLimit = 1;
e.PrefetchCount = 1;
});
@@ -84,17 +83,9 @@ public static class MainDI
/// </summary>
private static IServiceCollection AddHealthServices(this IServiceCollection services) =>
services
// Register the existing health check service for download clients
// Register the health check service
.AddSingleton<IHealthCheckService, HealthCheckService>()
// Register the background service for periodic health checks
.AddHostedService<HealthCheckBackgroundService>()
// Add ASP.NET Core health checks
.AddHealthChecks()
.AddCheck<ApplicationHealthCheck>("application", tags: ["liveness"])
.AddCheck<DatabaseHealthCheck>("database", tags: ["readiness"])
.AddCheck<FileSystemHealthCheck>("filesystem", tags: ["readiness"])
.AddCheck<DownloadClientsHealthCheck>("download_clients", tags: ["readiness"])
.Services;
.AddHostedService<HealthCheckBackgroundService>();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -13,6 +12,7 @@ using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.ContentBlocker;
using Cleanuparr.Persistence.Models.Configuration.General;
using Data.Models.Arr.Queue;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -63,8 +63,6 @@ public sealed class ContentBlocker : GenericHandler
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
if (config.Sonarr.Enabled)
{
@@ -80,16 +78,6 @@ public sealed class ContentBlocker : GenericHandler
{
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
}
if (config.Readarr.Enabled)
{
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
}
if (config.Whisparr.Enabled)
{
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
}
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)

View File

@@ -1,4 +1,3 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
@@ -11,6 +10,7 @@ using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Data.Models.Arr.Queue;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -130,9 +130,7 @@ public sealed class DownloadCleaner : GenericHandler
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr)), InstanceType.Sonarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr)), InstanceType.Radarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr)), InstanceType.Lidarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr)), InstanceType.Readarr, true);
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr)), InstanceType.Whisparr, true);
if (isUnlinkedEnabled && downloadServiceWithDownloads.Count > 0)
{
_logger.LogInformation("Found {count} potential downloads to change category", downloadServiceWithDownloads.Sum(x => x.Item2.Count));

View File

@@ -1,4 +1,3 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
@@ -12,6 +11,7 @@ using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Data.Models.Arr.Queue;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -42,14 +42,10 @@ public sealed class QueueCleaner : GenericHandler
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
await ProcessArrConfigAsync(sonarrConfig, InstanceType.Sonarr);
await ProcessArrConfigAsync(radarrConfig, InstanceType.Radarr);
await ProcessArrConfigAsync(lidarrConfig, InstanceType.Lidarr);
await ProcessArrConfigAsync(readarrConfig, InstanceType.Readarr);
await ProcessArrConfigAsync(whisparrConfig, InstanceType.Whisparr);
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
@@ -146,7 +142,7 @@ public sealed class QueueCleaner : GenericHandler
var config = ContextProvider.Get<QueueCleanerConfig>();
// failed import check
bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, instance.ArrConfig.FailedImportMaxStrikes);
bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, config.FailedImport.MaxStrikes);
DeleteReason deleteReason = downloadCheckResult.ShouldRemove ? downloadCheckResult.DeleteReason : DeleteReason.FailedImport;
if (!shouldRemoveFromArr && !downloadCheckResult.ShouldRemove)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,16 @@
using Cleanuparr.Domain.Enums;
using Data.Models.Arr;
namespace Cleanuparr.Domain.Entities.Arr;
namespace Data.Models.Arr;
public sealed class SeriesSearchItem : SearchItem
public sealed class SonarrSearchItem : SearchItem
{
public long SeriesId { get; set; }
public SeriesSearchType SearchType { get; set; }
public SonarrSearchType SearchType { get; set; }
public override bool Equals(object? obj)
{
if (obj is not SeriesSearchItem other)
if (obj is not SonarrSearchItem other)
{
return false;
}

View File

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

View File

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

View File

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

View File

@@ -12,5 +12,5 @@ public sealed record SonarrCommand
public List<long>? EpisodeIds { get; set; }
public SeriesSearchType SearchType { get; set; }
public SonarrSearchType SearchType { get; set; }
}

View File

@@ -1,16 +0,0 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Domain.Entities.Whisparr;
public sealed record WhisparrCommand
{
public string Name { get; set; }
public long? SeriesId { get; set; }
public long? SeasonNumber { get; set; }
public List<long>? EpisodeIds { get; set; }
public SeriesSearchType SearchType { get; set; }
}

View File

@@ -1,6 +1,6 @@
namespace Cleanuparr.Domain.Enums;
public enum SeriesSearchType
public enum SonarrSearchType
{
Episode,
Season,

View File

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

View File

@@ -1,7 +1,4 @@
using System.Dynamic;
using System.Text.Json;
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Notifications;
@@ -50,10 +47,7 @@ public class EventPublisher
EventType = eventType,
Message = message,
Severity = severity,
Data = data != null ? JsonSerializer.Serialize(data, new JsonSerializerOptions
{
Converters = { new JsonStringEnumConverter() }
}) : null,
Data = data != null ? JsonSerializer.Serialize(data) : null,
TrackingId = trackingId
};
@@ -81,37 +75,12 @@ public class EventPublisher
StrikeType.SlowTime => EventType.SlowTimeStrike,
};
dynamic data;
if (strikeType is StrikeType.FailedImport)
{
QueueRecord record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
data = new
{
hash,
itemName,
strikeCount,
strikeType,
failedImportReasons = record.StatusMessages ?? [],
};
}
else
{
data = new
{
hash,
itemName,
strikeCount,
strikeType,
};
}
// Publish the event
await PublishAsync(
eventType,
$"Item '{itemName}' has been struck {strikeCount} times for reason '{strikeType}'",
EventSeverity.Important,
data: data);
data: new { hash, itemName, strikeCount, strikeType });
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyStrike(strikeType, strikeCount);

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
@@ -7,6 +6,7 @@ using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Shared.Helpers;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
@@ -65,7 +65,7 @@ public abstract class ArrClient : IArrClient
return queueResponse;
}
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes)
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, ushort arrMaxStrikes)
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
@@ -105,12 +105,6 @@ public abstract class ArrClient : IArrClient
ushort maxStrikes = arrMaxStrikes > 0 ? (ushort)arrMaxStrikes : queueCleanerConfig.FailedImport.MaxStrikes;
_logger.LogInformation(
"Item {title} has failed import status with the following reason(s):\n{messages}",
record.Title,
string.Join("\n", record.StatusMessages?.Select(JsonConvert.SerializeObject) ?? [])
);
return await _striker.StrikeAndCheckLimit(
record.DownloadId,
record.Title,
@@ -212,7 +206,7 @@ public abstract class ArrClient : IArrClient
return response;
}
private static bool HasIgnoredPatterns(QueueRecord record)
private bool HasIgnoredPatterns(QueueRecord record)
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -9,7 +9,7 @@ public interface IArrClient
{
Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page);
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes);
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, ushort arrMaxStrikes);
Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason);

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Lidarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

View File

@@ -1,10 +1,10 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Radarr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;

View File

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

View File

@@ -1,12 +1,11 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Sonarr;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
@@ -58,7 +57,7 @@ public class SonarrClient : ArrClient, ISonarrClient
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
foreach (SonarrCommand command in GetSearchCommands(items.Cast<SeriesSearchItem>().ToHashSet()))
foreach (SonarrCommand command in GetSearchCommands(items.Cast<SonarrSearchItem>().ToHashSet()))
{
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent(
@@ -97,7 +96,7 @@ public class SonarrClient : ArrClient, ISonarrClient
}
private static string GetSearchLog(
SeriesSearchType searchType,
SonarrSearchType searchType,
Uri instanceUrl,
SonarrCommand command,
bool success,
@@ -108,22 +107,22 @@ public class SonarrClient : ArrClient, ISonarrClient
return searchType switch
{
SeriesSearchType.Episode =>
SonarrSearchType.Episode =>
$"episodes search {status} | {instanceUrl} | {logContext ?? $"episode ids: {string.Join(',', command.EpisodeIds)}"}",
SeriesSearchType.Season =>
SonarrSearchType.Season =>
$"season search {status} | {instanceUrl} | {logContext ?? $"season: {command.SeasonNumber} series id: {command.SeriesId}"}",
SeriesSearchType.Series => $"series search {status} | {instanceUrl} | {logContext ?? $"series id: {command.SeriesId}"}",
SonarrSearchType.Series => $"series search {status} | {instanceUrl} | {logContext ?? $"series id: {command.SeriesId}"}",
_ => throw new ArgumentOutOfRangeException(nameof(searchType), searchType, null)
};
}
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, SonarrCommand command, SeriesSearchType searchType)
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, SonarrCommand command, SonarrSearchType searchType)
{
try
{
StringBuilder log = new();
if (searchType is SeriesSearchType.Episode)
if (searchType is SonarrSearchType.Episode)
{
var episodes = await GetEpisodesAsync(arrInstance, command.EpisodeIds);
@@ -165,7 +164,7 @@ public class SonarrClient : ArrClient, ISonarrClient
}
}
if (searchType is SeriesSearchType.Season)
if (searchType is SonarrSearchType.Season)
{
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
@@ -177,7 +176,7 @@ public class SonarrClient : ArrClient, ISonarrClient
log.Append($"[{show.Title} season {command.SeasonNumber}]");
}
if (searchType is SeriesSearchType.Series)
if (searchType is SonarrSearchType.Series)
{
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
@@ -230,7 +229,7 @@ public class SonarrClient : ArrClient, ISonarrClient
return JsonConvert.DeserializeObject<Series>(responseBody);
}
private List<SonarrCommand> GetSearchCommands(HashSet<SeriesSearchItem> items)
private List<SonarrCommand> GetSearchCommands(HashSet<SonarrSearchItem> items)
{
const string episodeSearch = "EpisodeSearch";
const string seasonSearch = "SeasonSearch";
@@ -238,29 +237,29 @@ public class SonarrClient : ArrClient, ISonarrClient
List<SonarrCommand> commands = new();
foreach (SeriesSearchItem item in items)
foreach (SonarrSearchItem item in items)
{
SonarrCommand command = item.SearchType is SeriesSearchType.Episode
SonarrCommand command = item.SearchType is SonarrSearchType.Episode
? commands.FirstOrDefault() ?? new() { Name = episodeSearch, EpisodeIds = new() }
: new();
switch (item.SearchType)
{
case SeriesSearchType.Episode when command.EpisodeIds is null:
case SonarrSearchType.Episode when command.EpisodeIds is null:
command.EpisodeIds = [item.Id];
break;
case SeriesSearchType.Episode when command.EpisodeIds is not null:
case SonarrSearchType.Episode when command.EpisodeIds is not null:
command.EpisodeIds.Add(item.Id);
break;
case SeriesSearchType.Season:
case SonarrSearchType.Season:
command.Name = seasonSearch;
command.SeasonNumber = item.Id;
command.SeriesId = ((SeriesSearchItem)item).SeriesId;
command.SeriesId = ((SonarrSearchItem)item).SeriesId;
break;
case SeriesSearchType.Series:
case SonarrSearchType.Series:
command.Name = seriesSearch;
command.SeriesId = item.Id;
break;
@@ -269,7 +268,7 @@ public class SonarrClient : ArrClient, ISonarrClient
throw new ArgumentOutOfRangeException(nameof(item.SearchType), item.SearchType, null);
}
if (item.SearchType is SeriesSearchType.Episode && commands.Count > 0)
if (item.SearchType is SonarrSearchType.Episode && commands.Count > 0)
{
// only one command will be generated for episodes search
continue;

View File

@@ -1,282 +0,0 @@
using System.Text;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Entities.Sonarr;
using Cleanuparr.Domain.Entities.Whisparr;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Infrastructure.Interceptors;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.Arr;
public class WhisparrClient : ArrClient, IWhisparrClient
{
public WhisparrClient(
ILogger<WhisparrClient> logger,
IHttpClientFactory httpClientFactory,
IStriker striker,
IDryRunInterceptor dryRunInterceptor
) : base(logger, httpClientFactory, striker, dryRunInterceptor)
{
}
protected override string GetQueueUrlPath()
{
return "/api/v3/queue";
}
protected override string GetQueueUrlQuery(int page)
{
return $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
}
protected override string GetQueueDeleteUrlPath(long recordId)
{
return $"/api/v3/queue/{recordId}";
}
protected override string GetQueueDeleteUrlQuery(bool removeFromClient)
{
string query = "blocklist=true&skipRedownload=true&changeCategory=false";
query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return query;
}
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)
{
return;
}
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
foreach (WhisparrCommand command in GetSearchCommands(items.Cast<SeriesSearchItem>().ToHashSet()))
{
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent(
JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }),
Encoding.UTF8,
"application/json"
);
SetApiKey(request, arrInstance.ApiKey);
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command, command.SearchType);
try
{
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, true, logContext));
}
catch
{
_logger.LogError("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, false, logContext));
throw;
}
}
}
public override bool IsRecordValid(QueueRecord record)
{
if (record.EpisodeId is 0 || record.SeriesId is 0)
{
_logger.LogDebug("skip | episode id and/or series id missing | {title}", record.Title);
return false;
}
return base.IsRecordValid(record);
}
private static string GetSearchLog(
SeriesSearchType searchType,
Uri instanceUrl,
WhisparrCommand command,
bool success,
string? logContext
)
{
string status = success ? "triggered" : "failed";
return searchType switch
{
SeriesSearchType.Episode =>
$"episodes search {status} | {instanceUrl} | {logContext ?? $"episode ids: {string.Join(',', command.EpisodeIds)}"}",
SeriesSearchType.Season =>
$"season search {status} | {instanceUrl} | {logContext ?? $"season: {command.SeasonNumber} series id: {command.SeriesId}"}",
SeriesSearchType.Series => $"series search {status} | {instanceUrl} | {logContext ?? $"series id: {command.SeriesId}"}",
_ => throw new ArgumentOutOfRangeException(nameof(searchType), searchType, null)
};
}
private async Task<string?> ComputeCommandLogContextAsync(ArrInstance arrInstance, WhisparrCommand command, SeriesSearchType searchType)
{
try
{
StringBuilder log = new();
if (searchType is SeriesSearchType.Episode)
{
var episodes = await GetEpisodesAsync(arrInstance, command.EpisodeIds);
if (episodes?.Count is null or 0)
{
return null;
}
var seriesIds = episodes
.Select(x => x.SeriesId)
.Distinct()
.ToList();
List<Series> series = [];
foreach (long id in seriesIds)
{
Series? show = await GetSeriesAsync(arrInstance, id);
if (show is null)
{
return null;
}
series.Add(show);
}
foreach (var group in command.EpisodeIds.GroupBy(id => episodes.First(x => x.Id == id).SeriesId))
{
var show = series.First(x => x.Id == group.Key);
var episode = episodes
.Where(ep => group.Any(x => x == ep.Id))
.OrderBy(x => x.SeasonNumber)
.ThenBy(x => x.EpisodeNumber)
.Select(x => $"S{x.SeasonNumber.ToString().PadLeft(2, '0')}E{x.EpisodeNumber.ToString().PadLeft(2, '0')}")
.ToList();
log.Append($"[{show.Title} {string.Join(',', episode)}]");
}
}
if (searchType is SeriesSearchType.Season)
{
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
if (show is null)
{
return null;
}
log.Append($"[{show.Title} season {command.SeasonNumber}]");
}
if (searchType is SeriesSearchType.Series)
{
Series? show = await GetSeriesAsync(arrInstance, command.SeriesId.Value);
if (show is null)
{
return null;
}
log.Append($"[{show.Title}]");
}
return log.ToString();
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to compute log context");
}
return null;
}
private async Task<List<Episode>?> GetEpisodesAsync(ArrInstance arrInstance, List<long> episodeIds)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episode";
uriBuilder.Query = $"episodeIds={string.Join(',', episodeIds)}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
HttpResponseMessage response = await SendRequestAsync(request);
string responseContent = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<List<Episode>>(responseContent);
}
private async Task<Series?> GetSeriesAsync(ArrInstance arrInstance, long seriesId)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/series/{seriesId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
HttpResponseMessage response = await SendRequestAsync(request);
string responseContent = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<Series>(responseContent);
}
private List<WhisparrCommand> GetSearchCommands(HashSet<SeriesSearchItem> items)
{
const string episodeSearch = "EpisodeSearch";
const string seasonSearch = "SeasonSearch";
const string seriesSearch = "SeriesSearch";
List<WhisparrCommand> commands = new();
foreach (SeriesSearchItem item in items)
{
WhisparrCommand command = item.SearchType is SeriesSearchType.Episode
? commands.FirstOrDefault() ?? new() { Name = episodeSearch, EpisodeIds = new() }
: new();
switch (item.SearchType)
{
case SeriesSearchType.Episode when command.EpisodeIds is null:
command.EpisodeIds = [item.Id];
break;
case SeriesSearchType.Episode when command.EpisodeIds is not null:
command.EpisodeIds.Add(item.Id);
break;
case SeriesSearchType.Season:
command.Name = seasonSearch;
command.SeasonNumber = item.Id;
command.SeriesId = ((SeriesSearchItem)item).SeriesId;
break;
case SeriesSearchType.Series:
command.Name = seriesSearch;
command.SeriesId = item.Id;
break;
default:
throw new ArgumentOutOfRangeException(nameof(item.SearchType), item.SearchType, null);
}
if (item.SearchType is SeriesSearchType.Episode && commands.Count > 0)
{
// only one command will be generated for episodes search
continue;
}
command.SearchType = item.SearchType;
commands.Add(command);
}
return commands;
}
}

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ public partial class DelugeService
if (download?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return result;
}
@@ -52,7 +52,7 @@ public partial class DelugeService
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
}
if (contents is null)

View File

@@ -25,7 +25,7 @@ public partial class DelugeService
if (download?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return result;
}
@@ -44,7 +44,7 @@ public partial class DelugeService
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
}

View File

@@ -20,7 +20,7 @@ public partial class QBitService
if (download is null)
{
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return result;
}
@@ -39,7 +39,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties {name}", download.Name);
_logger.LogDebug("failed to find torrent properties {hash} in the download client", hash);
return result;
}

View File

@@ -97,7 +97,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties | {name}", download.Name);
_logger.LogDebug("failed to find torrent properties in the download client | {name}", download.Name);
return;
}
@@ -238,8 +238,6 @@ public partial class QBitService
}
await _dryRunInterceptor.InterceptAsync(ChangeCategory, download.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
await _eventPublisher.PublishCategoryChanged(download.Category, downloadCleanerConfig.UnlinkedTargetCategory, downloadCleanerConfig.UnlinkedUseTag);
if (downloadCleanerConfig.UnlinkedUseTag)
{
@@ -250,6 +248,8 @@ public partial class QBitService
_logger.LogInformation("category changed for {name}", download.Name);
download.Category = downloadCleanerConfig.UnlinkedTargetCategory;
}
await _eventPublisher.PublishCategoryChanged(download.Category, downloadCleanerConfig.UnlinkedTargetCategory, downloadCleanerConfig.UnlinkedUseTag);
}
}

View File

@@ -19,7 +19,7 @@ public partial class QBitService
if (download is null)
{
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return result;
}
@@ -38,7 +38,7 @@ public partial class QBitService
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties {hash}", download.Name);
_logger.LogDebug("failed to find torrent properties {hash} in the download client", hash);
return result;
}

View File

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

View File

@@ -19,7 +19,7 @@ public partial class TransmissionService
if (download?.FileStats is null || download.FileStats.Length == 0)
{
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return result;
}

View File

@@ -22,7 +22,7 @@ public partial class TransmissionService
if (download is null)
{
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return result;
}

View File

@@ -1,7 +1,7 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
namespace Cleanuparr.Infrastructure.Features.DownloadRemover.Models;

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Context;
@@ -9,6 +8,7 @@ using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;

View File

@@ -1,5 +1,3 @@
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
@@ -14,6 +12,7 @@ using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Data.Models.Arr;
using Data.Models.Arr.Queue;
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
@@ -53,6 +52,68 @@ public abstract class GenericHandler : IHandler
_dataContext = dataContext;
}
// /// <summary>
// /// Initialize download services based on configuration
// /// </summary>
// protected async Task<List<IDownloadService>> GetDownloadServices()
// {
// var clients = await _dataContext.DownloadClients
// .AsNoTracking()
// .ToListAsync();
//
// if (clients.Count is 0)
// {
// _logger.LogWarning("No download clients configured");
// return [];
// }
//
// var enabledClients = await _dataContext.DownloadClients
// .Where(c => c.Enabled)
// .ToListAsync();
//
// if (enabledClients.Count == 0)
// {
// _logger.LogWarning("No enabled download clients available");
// return [];
// }
//
// List<IDownloadService> downloadServices = [];
//
// // Add all enabled clients
// foreach (var client in enabledClients)
// {
// try
// {
// var service = _downloadServiceFactory.GetDownloadService(client);
// if (service != null)
// {
// await service.LoginAsync();
// downloadServices.Add(service);
// _logger.LogDebug("Initialized download client: {name}", client.Name);
// }
// else
// {
// _logger.LogWarning("Download client service not available for: {name}", client.Name);
// }
// }
// catch (Exception ex)
// {
// _logger.LogError(ex, "Failed to initialize download client: {name}", client.Name);
// }
// }
//
// if (downloadServices.Count == 0)
// {
// _logger.LogWarning("No valid download clients found");
// }
// else
// {
// _logger.LogDebug("Initialized {count} download clients", downloadServices.Count);
// }
//
// return downloadServices;
// }
public async Task ExecuteAsync()
{
await DataContext.Lock.WaitAsync();
@@ -69,15 +130,9 @@ public abstract class GenericHandler : IHandler
ContextProvider.Set(nameof(InstanceType.Lidarr), await _dataContext.ArrConfigs.AsNoTracking()
.Include(x => x.Instances)
.FirstAsync(x => x.Type == InstanceType.Lidarr));
ContextProvider.Set(nameof(InstanceType.Readarr), await _dataContext.ArrConfigs.AsNoTracking()
.Include(x => x.Instances)
.FirstAsync(x => x.Type == InstanceType.Readarr));
ContextProvider.Set(nameof(InstanceType.Whisparr), await _dataContext.ArrConfigs.AsNoTracking()
.Include(x => x.Instances)
.FirstAsync(x => x.Type == InstanceType.Whisparr));
ContextProvider.Set(nameof(QueueCleanerConfig), await _dataContext.QueueCleanerConfigs.AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(ContentBlockerConfig), await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(DownloadCleanerConfig), await _dataContext.DownloadCleanerConfigs.Include(x => x.Categories).AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(DownloadCleanerConfig), await _dataContext.DownloadCleanerConfigs.AsNoTracking().FirstAsync());
ContextProvider.Set(nameof(DownloadClientConfig), await _dataContext.DownloadClients.AsNoTracking()
.Where(x => x.Enabled)
.ToListAsync());
@@ -140,14 +195,14 @@ public abstract class GenericHandler : IHandler
return;
}
if (instanceType is InstanceType.Sonarr or InstanceType.Whisparr)
if (instanceType is InstanceType.Sonarr)
{
QueueItemRemoveRequest<SeriesSearchItem> removeRequest = new()
QueueItemRemoveRequest<SonarrSearchItem> removeRequest = new()
{
InstanceType = instanceType,
Instance = instance,
Record = record,
SearchItem = (SeriesSearchItem)GetRecordSearchItem(instanceType, record, isPack),
SearchItem = (SonarrSearchItem)GetRecordSearchItem(instanceType, record, isPack),
RemoveFromClient = removeFromClient,
DeleteReason = deleteReason
};
@@ -177,17 +232,17 @@ public abstract class GenericHandler : IHandler
{
return type switch
{
InstanceType.Sonarr when !isPack => new SeriesSearchItem
InstanceType.Sonarr when !isPack => new SonarrSearchItem
{
Id = record.EpisodeId,
SeriesId = record.SeriesId,
SearchType = SeriesSearchType.Episode
SearchType = SonarrSearchType.Episode
},
InstanceType.Sonarr when isPack => new SeriesSearchItem
InstanceType.Sonarr when isPack => new SonarrSearchItem
{
Id = record.SeasonNumber,
SeriesId = record.SeriesId,
SearchType = SeriesSearchType.Season
SearchType = SonarrSearchType.Season
},
InstanceType.Radarr => new SearchItem
{
@@ -197,22 +252,6 @@ public abstract class GenericHandler : IHandler
{
Id = record.AlbumId
},
InstanceType.Readarr => new SearchItem
{
Id = record.BookId
},
InstanceType.Whisparr when !isPack => new SeriesSearchItem
{
Id = record.EpisodeId,
SeriesId = record.SeriesId,
SearchType = SeriesSearchType.Episode
},
InstanceType.Whisparr when isPack => new SeriesSearchItem
{
Id = record.SeasonNumber,
SeriesId = record.SeriesId,
SearchType = SeriesSearchType.Season
},
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
};
}

View File

@@ -1,12 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
public class AppriseException : Exception
{
public AppriseException(string message) : base(message)
{
}
public AppriseException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -13,8 +13,6 @@ public sealed record ApprisePayload
public string Type { get; init; } = NotificationType.Info.ToString().ToLowerInvariant();
public string Format { get; init; } = FormatType.Text.ToString().ToLowerInvariant();
public string? Tags { get; init; }
}
public enum NotificationType

View File

@@ -3,11 +3,13 @@ using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Infrastructure.Verticals.Notifications;
using Infrastructure.Verticals.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications.Apprise;
public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
{
private readonly DataContext _dataContext;
private readonly IAppriseProxy _proxy;
public override string Name => "Apprise";
@@ -15,6 +17,7 @@ public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
public AppriseProvider(DataContext dataContext, IAppriseProxy proxy)
: base(dataContext.AppriseConfigs)
{
_dataContext = dataContext;
_proxy = proxy;
}
@@ -48,7 +51,7 @@ public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), Config);
}
private ApprisePayload BuildPayload(ArrNotification notification, NotificationType notificationType)
private static ApprisePayload BuildPayload(ArrNotification notification, NotificationType notificationType)
{
StringBuilder body = new();
body.AppendLine(notification.Description);
@@ -67,13 +70,12 @@ public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
Title = notification.Title,
Body = body.ToString(),
Type = notificationType.ToString().ToLowerInvariant(),
Tags = Config.Tags,
};
return payload;
}
private ApprisePayload BuildPayload(Notification notification, NotificationType notificationType)
private static ApprisePayload BuildPayload(Notification notification, NotificationType notificationType)
{
StringBuilder body = new();
body.AppendLine(notification.Description);
@@ -89,7 +91,6 @@ public sealed class AppriseProvider : NotificationProvider<AppriseConfig>
Title = notification.Title,
Body = body.ToString(),
Type = notificationType.ToString().ToLowerInvariant(),
Tags = Config.Tags,
};
return payload;

View File

@@ -1,5 +1,4 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Newtonsoft.Json;
@@ -18,50 +17,19 @@ public sealed class AppriseProxy : IAppriseProxy
public async Task SendNotification(ApprisePayload payload, AppriseConfig config)
{
try
string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings
{
string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore
});
UriBuilder uriBuilder = new(config.Url.ToString());
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/notify/{config.Key}";
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Method = HttpMethod.Post;
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
if (!string.IsNullOrEmpty(config.Url.UserInfo))
{
var byteArray = Encoding.ASCII.GetBytes(config.Url.UserInfo);
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray));
}
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
}
catch (HttpRequestException exception)
{
if (exception.StatusCode is null)
{
throw new AppriseException("Unable to send notification", exception);
}
switch ((int)exception.StatusCode)
{
case 401:
throw new AppriseException("Unable to send notification | API key is invalid");
case 424:
throw new AppriseException("Your tags are not configured correctly", exception);
case 502:
case 503:
case 504:
throw new AppriseException("Unable to send notification | service unavailable", exception);
default:
throw new AppriseException("Unable to send notification", exception);
}
}
ContractResolver = new CamelCasePropertyNamesContractResolver()
});
UriBuilder uriBuilder = new(config.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/notify/{config.Key}";
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Method = HttpMethod.Post;
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
}
}

View File

@@ -1,5 +1,5 @@
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Infrastructure.Verticals.Notifications;
using Infrastructure.Verticals.Notifications;
using Infrastructure.Verticals.Notifications.Models;
using MassTransit;
using Microsoft.Extensions.Logging;

View File

@@ -1,5 +1,5 @@
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Infrastructure.Verticals.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications;

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Enums;
using Infrastructure.Verticals.Notifications.Models;
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;

View File

@@ -1,4 +1,4 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Infrastructure.Verticals.Notifications.Models;
public sealed record CategoryChangedNotification : Notification
{

View File

@@ -1,4 +1,4 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Infrastructure.Verticals.Notifications.Models;
public sealed record DownloadCleanedNotification : Notification
{

View File

@@ -1,4 +1,6 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Infrastructure.Verticals.Notifications.Models;
public sealed record FailedImportStrikeNotification : ArrNotification
{

View File

@@ -1,4 +1,4 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Infrastructure.Verticals.Notifications.Models;
public abstract record Notification
{

View File

@@ -1,4 +1,4 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Infrastructure.Verticals.Notifications.Models;
public sealed record NotificationField
{

View File

@@ -1,4 +1,4 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Infrastructure.Verticals.Notifications.Models;
public enum NotificationLevel
{

View File

@@ -1,4 +1,6 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Infrastructure.Verticals.Notifications.Models;
public sealed record QueueItemDeletedNotification : ArrNotification
{

View File

@@ -1,4 +1,6 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Infrastructure.Verticals.Notifications.Models;
public sealed record SlowStrikeNotification : ArrNotification
{

View File

@@ -1,4 +1,6 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
namespace Infrastructure.Verticals.Notifications.Models;
public sealed record StalledStrikeNotification : ArrNotification
{

View File

@@ -2,6 +2,7 @@ using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Infrastructure.Verticals.Notifications;
using Infrastructure.Verticals.Notifications.Models;
using Mapster;
namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;

View File

@@ -1,5 +1,5 @@
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Infrastructure.Verticals.Notifications.Models;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Infrastructure.Features.Notifications;
@@ -10,7 +10,7 @@ public abstract class NotificationProvider<T> : INotificationProvider<T>
protected readonly DbSet<T> _notificationConfig;
protected T? _config;
public T Config => _config ??= _notificationConfig.AsNoTracking().First();
public T Config => _config ??= _notificationConfig.First();
NotificationConfig INotificationProvider.Config => Config;

View File

@@ -1,10 +1,11 @@
using System.Globalization;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr.Queue;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.Notifications.Models;
using Mapster;
using MassTransit;
using Microsoft.Extensions.Logging;
@@ -165,8 +166,6 @@ public class NotificationPublisher : INotificationPublisher
InstanceType.Sonarr => record.Series?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
InstanceType.Radarr => record.Movie?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
InstanceType.Lidarr => record.Album?.Images?.FirstOrDefault(x => x.CoverType == "cover")?.Url,
InstanceType.Readarr => record.Book?.Images?.FirstOrDefault(x => x.CoverType == "cover")?.Url,
InstanceType.Whisparr => record.Series?.Images?.FirstOrDefault(x => x.CoverType == "poster")?.RemoteUrl,
_ => throw new ArgumentOutOfRangeException(nameof(instanceType))
};

View File

@@ -1,5 +1,5 @@
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Infrastructure.Verticals.Notifications.Models;
using Microsoft.Extensions.Logging;
namespace Infrastructure.Verticals.Notifications;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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