Compare commits

..

311 Commits
v2.0.13 ... 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
184 changed files with 963 additions and 8736 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,13 +1,11 @@
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Data.Models.Arr;
using Infrastructure.Verticals.Notifications.Models;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
@@ -28,9 +26,7 @@ public static class MainDI
.AddMassTransit(config =>
{
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();
config.AddConsumer<DownloadHunterConsumer<SeriesSearchItem>>();
config.AddConsumer<DownloadRemoverConsumer<SonarrSearchItem>>();
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
@@ -52,15 +48,7 @@ public static class MainDI
cfg.ReceiveEndpoint("download-remover-queue", e =>
{
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
e.ConfigureConsumer<DownloadRemoverConsumer<SeriesSearchItem>>(context);
e.ConcurrentMessageLimit = 2;
e.PrefetchCount = 2;
});
cfg.ReceiveEndpoint("download-hunter-queue", e =>
{
e.ConfigureConsumer<DownloadHunterConsumer<SearchItem>>(context);
e.ConfigureConsumer<DownloadHunterConsumer<SeriesSearchItem>>(context);
e.ConfigureConsumer<DownloadRemoverConsumer<SonarrSearchItem>>(context);
e.ConcurrentMessageLimit = 1;
e.PrefetchCount = 1;
});
@@ -95,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

@@ -5,8 +5,6 @@ using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.ContentBlocker;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadHunter;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadRemover;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
@@ -14,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;
@@ -39,14 +37,11 @@ public static class ServicesDI
.AddTransient<SonarrClient>()
.AddTransient<RadarrClient>()
.AddTransient<LidarrClient>()
.AddTransient<ReadarrClient>()
.AddTransient<WhisparrClient>()
.AddTransient<ArrClientFactory>()
.AddTransient<QueueCleaner>()
.AddTransient<ContentBlocker>()
.AddTransient<DownloadCleaner>()
.AddTransient<IQueueItemRemover, QueueItemRemover>()
.AddTransient<IDownloadHunter, DownloadHunter>()
.AddTransient<IFilenameEvaluator, FilenameEvaluator>()
.AddTransient<IHardLinkFileService, HardLinkFileService>()
.AddTransient<UnixHardLinkFileService>()

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;
@@ -52,7 +52,7 @@ public sealed class ContentBlocker : GenericHandler
var config = ContextProvider.Get<ContentBlockerConfig>();
if (!config.Sonarr.Enabled && !config.Radarr.Enabled && !config.Lidarr.Enabled && !config.Readarr.Enabled && !config.Whisparr.Enabled)
if (!config.Sonarr.Enabled && !config.Radarr.Enabled && !config.Lidarr.Enabled)
{
_logger.LogWarning("No blocklists are enabled");
return;
@@ -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)
@@ -183,10 +171,6 @@ public sealed class ContentBlocker : GenericHandler
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
}
}
else
{
_logger.LogDebug("No torrent clients enabled");
}
}
if (!result.ShouldRemove)
@@ -210,7 +194,7 @@ public sealed class ContentBlocker : GenericHandler
record,
group.Count() > 1,
removeFromClient,
result.DeleteReason
DeleteReason.AllFilesBlocked
);
}
});

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)
@@ -141,16 +137,12 @@ public sealed class QueueCleaner : GenericHandler
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
}
}
else
{
_logger.LogDebug("No torrent clients enabled");
}
}
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

@@ -11,5 +11,4 @@ public enum DeleteReason
AllFilesSkipped,
AllFilesSkippedByQBit,
AllFilesBlocked,
MalwareFileFound,
}

View File

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

View File

@@ -6,13 +6,16 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Folder Include="Features\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FLM.QBittorrent" Version="1.0.1" />
<PackageReference Include="FLM.Transmission" Version="1.0.3" />
<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,28 +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++;
}
// Check and update Whisparr blocklist if needed
string whisparrHash = GenerateSettingsHash(contentBlockerConfig.Whisparr);
if (shouldReload || !_configHashes.TryGetValue(InstanceType.Whisparr, out string? oldWhisparrHash) || whisparrHash != oldWhisparrHash)
{
_logger.LogDebug("Loading Whisparr blocklist");
await LoadPatternsAndRegexesAsync(contentBlockerConfig.Whisparr, InstanceType.Whisparr);
_configHashes[InstanceType.Whisparr] = whisparrHash;
changedCount++;
}
if (changedCount > 0)
{
_logger.LogInformation("Successfully loaded {count} blocklists", changedCount);

View File

@@ -1,6 +1,4 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
public sealed record BlockFilesResult
{
@@ -15,6 +13,4 @@ public sealed record BlockFilesResult
public bool IsPrivate { get; set; }
public bool Found { get; set; }
public DeleteReason DeleteReason { get; set; } = DeleteReason.None;
}

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)
@@ -75,21 +75,8 @@ public partial class DelugeService
totalFiles++;
int priority = file.Priority;
if (result.ShouldRemove)
{
return;
}
if (IsDefinitelyMalware(name))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
}
if (file.Priority is 0)
{
_logger.LogTrace("File is already skipped | {file}", file.Path);
totalUnwantedFiles++;
}
@@ -101,15 +88,9 @@ public partial class DelugeService
_logger.LogInformation("unwanted file found | {file}", file.Path);
}
_logger.LogTrace("File is valid | {file}", file.Path);
priorities.Add(file.Index, priority);
});
if (result.ShouldRemove)
{
return result;
}
if (!hasPriorityUpdates)
{
return result;
@@ -124,12 +105,8 @@ public partial class DelugeService
if (totalUnwantedFiles == totalFiles)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, hash, sortedPriorities);

View File

@@ -1,4 +1,6 @@
using Cleanuparr.Domain.Entities;
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
@@ -23,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;
}
@@ -42,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);
}
@@ -59,7 +61,6 @@ public partial class DelugeService
if (shouldRemove)
{
// remove if all files are unwanted
_logger.LogTrace("all files are unwanted | removing download | {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesSkipped;
return result;
@@ -94,13 +95,11 @@ public partial class DelugeService
if (download.State is null || !download.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
{
_logger.LogTrace("skip slow check | item is in {state} state | {name}", download.State, download.Name);
return (false, DeleteReason.None);
}
if (download.DownloadSpeed <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
@@ -138,7 +137,6 @@ public partial class DelugeService
if (queueCleanerConfig.Stalled.MaxStrikes is 0)
{
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", status.Name);
return (false, DeleteReason.None);
}
@@ -151,13 +149,11 @@ public partial class DelugeService
if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
{
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", status.State, status.Name);
return (false, DeleteReason.None);
}
if (status.Eta > 0)
{
_logger.LogTrace("skip stalled check | download is not stalled | {name}", status.Name);
return (false, DeleteReason.None);
}

View File

@@ -100,16 +100,6 @@ public abstract class DownloadService : IDownloadService
/// <inheritdoc/>
public abstract Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads);
protected bool IsDefinitelyMalware(string filename)
{
if (filename.Contains("thepirateheaven.org", StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
return false;
}
protected void ResetStalledStrikesOnProgress(string hash, long downloaded)
{

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;
}
@@ -62,7 +62,6 @@ public partial class QBitService
if (files is null)
{
_logger.LogDebug("torrent {hash} has no files", hash);
return result;
}
@@ -79,30 +78,19 @@ public partial class QBitService
{
if (!file.Index.HasValue)
{
_logger.LogTrace("Skipping file with no index | {file}", file.Name);
continue;
}
totalFiles++;
if (IsDefinitelyMalware(file.Name))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Name, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
return result;
}
if (file.Priority is TorrentContentPriority.Skip)
{
_logger.LogTrace("File is already skipped | {file}", file.Name);
totalUnwantedFiles++;
continue;
}
if (_filenameEvaluator.IsValid(file.Name, blocklistType, patterns, regexes))
{
_logger.LogTrace("File is valid | {file}", file.Name);
continue;
}
@@ -113,18 +101,13 @@ public partial class QBitService
if (unwantedFiles.Count is 0)
{
_logger.LogDebug("No unwanted files found for {name}", download.Name);
return result;
}
if (totalUnwantedFiles == totalFiles)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
foreach (int fileIndex in unwantedFiles)
{

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;
}
@@ -55,13 +55,11 @@ public partial class QBitService
// if all files were blocked by qBittorrent
if (download is { CompletionOn: not null, Downloaded: null or 0 })
{
_logger.LogDebug("all files are unwanted by qBit | removing download | {name}", download.Name);
result.DeleteReason = DeleteReason.AllFilesSkippedByQBit;
return result;
}
// remove if all files are unwanted
_logger.LogDebug("all files are unwanted | removing download | {name}", download.Name);
result.DeleteReason = DeleteReason.AllFilesSkipped;
return result;
}
@@ -89,19 +87,16 @@ public partial class QBitService
if (queueCleanerConfig.Slow.MaxStrikes is 0)
{
_logger.LogDebug("skip slow check | max strikes is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.State is not (TorrentState.Downloading or TorrentState.ForcedDownload))
{
_logger.LogDebug("skip slow check | download is in {state} state | {name}", download.State, download.Name);
return (false, DeleteReason.None);
}
if (download.DownloadSpeed <= 0)
{
_logger.LogDebug("skip slow check | download speed is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
@@ -139,7 +134,6 @@ public partial class QBitService
if (queueCleanerConfig.Stalled.MaxStrikes is 0 && queueCleanerConfig.Stalled.DownloadingMetadataMaxStrikes is 0)
{
_logger.LogDebug("skip stalled check | max strikes is 0 | {name}", torrent.Name);
return (false, DeleteReason.None);
}
@@ -147,7 +141,6 @@ public partial class QBitService
and not TorrentState.ForcedFetchingMetadata)
{
// ignore other states
_logger.LogDebug("skip stalled check | download is in {state} state | {name}", torrent.State, torrent.Name);
return (false, DeleteReason.None);
}
@@ -175,7 +168,6 @@ public partial class QBitService
StrikeType.DownloadingMetadata), DeleteReason.DownloadingMetadata);
}
_logger.LogDebug("skip stalled check | download is not stalled | {name}", torrent.Name);
return (false, DeleteReason.None);
}
}

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;
}
@@ -62,30 +62,19 @@ public partial class TransmissionService
{
if (download.FileStats?[i].Wanted == null)
{
_logger.LogTrace("Skipping file with no stats | {file}", download.Files[i].Name);
continue;
}
totalFiles++;
if (IsDefinitelyMalware(download.Files[i].Name))
{
_logger.LogInformation("malware file found | {file} | {title}", download.Files[i].Name, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
return result;
}
if (!download.FileStats[i].Wanted.Value)
{
_logger.LogTrace("File is already skipped | {file}", download.Files[i].Name);
totalUnwantedFiles++;
continue;
}
if (_filenameEvaluator.IsValid(download.Files[i].Name, blocklistType, patterns, regexes))
{
_logger.LogTrace("File is valid | {file}", download.Files[i].Name);
continue;
}
@@ -96,18 +85,15 @@ public partial class TransmissionService
if (unwantedFiles.Count is 0)
{
_logger.LogDebug("No unwanted files found for {name}", download.Name);
return result;
}
if (totalUnwantedFiles == totalFiles)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
_logger.LogDebug("marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
await _dryRunInterceptor.InterceptAsync(SetUnwantedFiles, download.Id, unwantedFiles.ToArray());

View File

@@ -1,4 +1,6 @@
using Cleanuparr.Domain.Entities;
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
@@ -20,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;
}
@@ -54,7 +56,6 @@ public partial class TransmissionService
if (shouldRemove)
{
// remove if all files are unwanted
_logger.LogDebug("all files are unwanted | removing download | {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesSkipped;
return result;
@@ -99,13 +100,11 @@ public partial class TransmissionService
if (download.Status is not 4)
{
// not in downloading state
_logger.LogTrace("skip slow check | download is in {state} state | {name}", download.Status, download.Name);
return (false, DeleteReason.None);
}
if (download.RateDownload <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
@@ -143,20 +142,17 @@ public partial class TransmissionService
if (queueCleanerConfig.Stalled.MaxStrikes is 0)
{
_logger.LogTrace("skip stalled check | max strikes is 0 | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.Status is not 4)
{
// not in downloading state
_logger.LogTrace("skip stalled check | download is in {state} state | {name}", download.Status, download.Name);
return (false, DeleteReason.None);
}
if (download.RateDownload > 0 || download.Eta > 0)
{
_logger.LogTrace("skip stalled check | download is not stalled | {name}", download.Name);
return (false, DeleteReason.None);
}

View File

@@ -1,36 +0,0 @@
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Data.Models.Arr;
using MassTransit;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
public class DownloadHunterConsumer<T> : IConsumer<DownloadHuntRequest<T>>
where T : SearchItem
{
private readonly ILogger<DownloadHunterConsumer<T>> _logger;
private readonly IDownloadHunter _downloadHunter;
public DownloadHunterConsumer(ILogger<DownloadHunterConsumer<T>> logger, IDownloadHunter downloadHunter)
{
_logger = logger;
_downloadHunter = downloadHunter;
}
public async Task Consume(ConsumeContext<DownloadHuntRequest<T>> context)
{
try
{
await _downloadHunter.HuntDownloadsAsync(context.Message);
}
catch (Exception exception)
{
_logger.LogError(exception,
"failed to search for replacement | {title} | {url}",
context.Message.Record.Title,
context.Message.Instance.Url
);
}
}
}

View File

@@ -1,42 +0,0 @@
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Persistence;
using Data.Models.Arr;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Infrastructure.Features.DownloadHunter;
public sealed class DownloadHunter : IDownloadHunter
{
private readonly DataContext _dataContext;
private readonly ArrClientFactory _arrClientFactory;
public DownloadHunter(
DataContext dataContext,
ArrClientFactory arrClientFactory
)
{
_dataContext = dataContext;
_arrClientFactory = arrClientFactory;
}
public async Task HuntDownloadsAsync<T>(DownloadHuntRequest<T> request)
where T : SearchItem
{
var generalConfig = await _dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
if (!generalConfig.SearchEnabled)
{
return;
}
var arrClient = _arrClientFactory.GetClient(request.InstanceType);
await arrClient.SearchItemsAsync(request.Instance, [request.SearchItem]);
// prevent tracker spamming
await Task.Delay(TimeSpan.FromSeconds(generalConfig.SearchDelay));
}
}

View File

@@ -1,9 +0,0 @@
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Data.Models.Arr;
namespace Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
public interface IDownloadHunter
{
Task HuntDownloadsAsync<T>(DownloadHuntRequest<T> request) where T : SearchItem;
}

View File

@@ -1,18 +0,0 @@
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
namespace Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
public sealed record DownloadHuntRequest<T>
where T : SearchItem
{
public required InstanceType InstanceType { get; init; }
public required ArrInstance Instance { get; init; }
public required T SearchItem { get; init; }
public required QueueRecord Record { get; init; }
}

View File

@@ -30,7 +30,7 @@ public class DownloadRemoverConsumer<T> : IConsumer<QueueItemRemoveRequest<T>>
catch (Exception exception)
{
_logger.LogError(exception,
"failed to remove queue item | {title} | {url}",
"failed to remove queue item| {title} | {url}",
context.Message.Record.Title,
context.Message.Instance.Url
);

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,35 +1,34 @@
using System.Net;
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;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using MassTransit;
using Data.Models.Arr.Queue;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace Cleanuparr.Infrastructure.Features.DownloadRemover;
public sealed class QueueItemRemover : IQueueItemRemover
{
private readonly IBus _messageBus;
private readonly DataContext _dataContext;
private readonly IMemoryCache _cache;
private readonly ArrClientFactory _arrClientFactory;
private readonly EventPublisher _eventPublisher;
public QueueItemRemover(
IBus messageBus,
DataContext dataContext,
IMemoryCache cache,
ArrClientFactory arrClientFactory,
EventPublisher eventPublisher
)
{
_messageBus = messageBus;
_dataContext = dataContext;
_cache = cache;
_arrClientFactory = arrClientFactory;
_eventPublisher = eventPublisher;
@@ -40,35 +39,31 @@ public sealed class QueueItemRemover : IQueueItemRemover
{
try
{
var generalConfig = await _dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
var arrClient = _arrClientFactory.GetClient(request.InstanceType);
await arrClient.DeleteQueueItemAsync(request.Instance, request.Record, request.RemoveFromClient, request.DeleteReason);
// Set context for EventPublisher
ContextProvider.Set("downloadName", request.Record.Title);
ContextProvider.Set("hash", request.Record.DownloadId);
ContextProvider.Set(nameof(QueueRecord), request.Record);
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), request.Instance.Url);
ContextProvider.Set(nameof(InstanceType), request.InstanceType);
// Use the new centralized EventPublisher method
await _eventPublisher.PublishQueueItemDeleted(request.RemoveFromClient, request.DeleteReason);
await _messageBus.Publish(new DownloadHuntRequest<T>
if (!generalConfig.SearchEnabled)
{
InstanceType = request.InstanceType,
Instance = request.Instance,
SearchItem = request.SearchItem,
Record = request.Record
});
}
catch (HttpRequestException exception)
{
if (exception.StatusCode is not HttpStatusCode.NotFound)
{
throw;
return;
}
throw new Exception($"Item might have already been deleted by your {request.InstanceType} instance", exception);
await arrClient.SearchItemsAsync(request.Instance, [request.SearchItem]);
// prevent tracker spamming
await Task.Delay(TimeSpan.FromSeconds(generalConfig.SearchDelay));
}
finally
{

View File

@@ -27,7 +27,6 @@ public sealed class Striker : IStriker
{
if (maxStrikes is 0)
{
_logger.LogTrace("skip striking for {reason} | max strikes is 0 | {name}", strikeType, itemName);
return false;
}

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;

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