Compare commits

...
Author SHA1 Message Date
Flaminel ad8c5f23cf fixed some early returns causing a retry 2026-06-17 15:47:06 +03:00
Flaminel 9eebeed990 fixed log again 2026-06-17 15:46:32 +03:00
Flaminel a79a60a339 fixed ignored downloads bloating with each run 2026-06-17 15:27:32 +03:00
Flaminel d1bd9fddcc retried webhook malware scans while the torrent metadata is not yet available 2026-06-17 15:06:25 +03:00
Flaminel 96823adcc3 reworked webhooks controller to ProblemDetails errors and [controller] route 2026-06-17 12:14:31 +03:00
Flaminel e0e88147aa added more retries 2026-06-17 12:05:45 +03:00
Flaminel f278a0dad0 fixed log 2026-06-17 12:05:35 +03:00
Flaminel f61300b869 fixed docs link 2026-06-16 23:51:01 +03:00
Flaminel 561c05778c showed only the instance id on the arr card instead of the full webhook url 2026-06-16 23:47:11 +03:00
Flaminel 60d273991d used an enum for arr webhook event type instead of hardcoded strings 2026-06-16 23:43:18 +03:00
Flaminel ddb1042ca5 mapped malware-blocker triggerMode help key to its docs anchor 2026-06-16 23:29:07 +03:00
Flaminel 9a31e86ad8 removed unused method 2026-06-16 23:21:56 +03:00
Flaminel 614e97313e passed WebhookScanTarget to ScheduleMalwareBlockerWebhookRetry instead of individual fields 2026-06-16 23:21:56 +03:00
Flaminel a34a3d3c7e made MalwareBlocker webhook retries conditional on the download not being found 2026-06-16 23:21:56 +03:00
Flaminel f9588d89c0 changed some stuff 2026-06-16 23:21:56 +03:00
Flaminel eacd9346a5 generalized MalwareBlockerTriggerMode to JobTriggerMode 2026-06-16 23:21:56 +03:00
Flaminel 0561c64ddf added tests for MalwareBlocker webhook triggering 2026-06-16 23:21:56 +03:00
Flaminel 304a8e78ee documented MalwareBlocker webhook triggering 2026-06-16 23:21:56 +03:00
Flaminel e008b64a1d added MalwareBlocker webhook frontend (trigger mode and per-instance URL) 2026-06-16 23:21:56 +03:00
Flaminel 4f7e2d33b4 added webhook-triggered targeted MalwareBlocker scan 2026-06-16 23:21:56 +03:00
Flaminel b1b19e5f29 added content-id filter to arr queue fetch 2026-06-16 23:21:56 +03:00
Flaminel 40ab0e9fad added MalwareBlocker trigger mode config and migration 2026-06-16 23:21:56 +03:00
Flaminel fa1801875e Update frontend packages (#633) 2026-06-16 17:17:52 +03:00
Flaminel c6ef6ad979 Use ProblemDetails handling for the API (#632) 2026-06-16 15:26:56 +03:00
Flaminel 74f11f5beb Migrate from DateTime to DateTimeOffset (#629) 2026-06-15 17:15:06 +03:00
Flaminel c0950537ab Fix width of the recent events card (#630) 2026-06-14 17:49:32 +03:00
Flaminel 7cc079c61b Add new feature badge (#628) 2026-06-14 17:04:59 +03:00
Flaminel 7aa3224f4d Add dead torrent handling (#627) 2026-06-14 02:14:10 +03:00
Flaminel 1cc068c2ab Add Transmission label option for unlinked downloads (#626) 2026-06-13 23:09:26 +03:00
Flaminel 28f22f1085 Fix running orphaned files cleanup for unavailable clients (#624) 2026-06-01 00:36:02 +03:00
Flaminel 084f83efca Add option to remove malware if any file is blocked (#621) 2026-05-30 01:33:51 +03:00
Flaminel 26b76908eb Add min seeders to seeding rules (#620) 2026-05-28 17:23:41 +03:00
Flaminel ffc8a0a39a Fix some backgrounds not respecting the custom color (#619) 2026-05-27 20:08:43 +03:00
Flaminel 8ccd93dc97 Add orphaned files cleanup (#618) 2026-05-27 19:26:39 +03:00
Flaminel 1ca935b62b Fix Deluge crashing when removing malware (#308) 2026-05-15 03:53:05 +03:00
Flaminel 90a4909e57 Fix qBittorrent v5.2.0 login (#607) 2026-05-09 03:55:13 +03:00
Flaminel ef8fb2dd0b Fix flaky unit tests (#604) 2026-05-06 10:49:16 +03:00
Flaminel 20ad056400 Add seeding completed Transmission status for Download Cleaner (#603) 2026-05-05 16:53:12 +03:00
Flaminel ab792f5fad Add option to change the category instead of deleting queue items (#602) 2026-05-05 15:59:42 +03:00
Flaminel 48c36fab8f Add paused and queued Deluge states for Download Cleaner (#596) 2026-05-04 14:50:07 +03:00
Flaminel 3553fce597 Fix Seeker failing in dry run mode (#601) 2026-05-04 12:17:19 +03:00
Flaminel c1d2790c8d Fix value step for number inputs when using decimals (#599) 2026-05-03 23:11:31 +03:00
Flaminel 18a9c66ce7 Add name clarification (#598) 2026-05-03 01:40:13 +03:00
Flaminel 7ec60c7ea0 Update application description (#597) 2026-05-03 00:53:41 +03:00
Flaminel a96bc36a1e Add AI usage policy in contributing guide (#593) 2026-04-28 17:39:34 +03:00
Flaminel b575644d5f Improve docs layout (#570) 2026-04-28 16:22:07 +03:00
David Young 13bc71c3cd Add ElfHosted as managed deployment option in the docs (#590) 2026-04-28 13:54:03 +03:00
Flaminel c3f3ee880d Add UI warning when not linking an OIDC account (#587) 2026-04-27 13:19:46 +03:00
Flaminel 8ab4a55595 Remove CORS policy for production environments (#588) 2026-04-27 13:13:06 +03:00
Flaminel 85de80a463 Fix forwarded header parsing (#586) 2026-04-27 12:49:33 +03:00
Flaminel 9f48d3565a Add customizable theme color (#582) 2026-04-26 01:28:07 +03:00
Mike Lonergan db2e3e71db Fix number input eager clamping on keystrokes (#583) 2026-04-26 01:06:26 +03:00
Flaminel 5d400ad854 Fix Custom Format score syncer not being scheduled accordingly (#584) 2026-04-25 23:57:12 +03:00
Flaminel 02a07d4fa3 Add sorting and filters for Seeker stats (#576) 2026-04-25 11:57:38 +03:00
Flaminel 41ca55d615 Fix log config inputs not allowing for zero (#579) 2026-04-22 16:15:56 +03:00
Flaminel 8770a8b18e Add hour intervals for Seeker (#578) 2026-04-21 16:56:58 +03:00
Flaminel 24ecd88cd0 Fix backend page size to be in sync with the UI (#577) 2026-04-21 16:29:59 +03:00
Flaminel 7ed0f307be Add customizable pagination size (#575) 2026-04-20 19:51:02 +03:00
Flaminel 5b500c533e Improve the scrollbar position and visibility (#574) 2026-04-20 15:40:19 +03:00
Flaminel b1ef63ef43 Add sticky headers for pages with tables (#573) 2026-04-20 15:37:45 +03:00
Flaminel f14e345b52 Add full width UI toggle (#572) 2026-04-20 15:33:04 +03:00
Flaminel 89a0d1281f Fix Seeker's filters allowing searching for all types of movies regardless of settings (#567) 2026-04-16 18:21:36 +03:00
Flaminel ee5e7c0819 Replace Moq with NSubstitute for unit tests (#566) 2026-04-16 12:13:36 +03:00
Flaminel d875d88191 Add per-instance Seeker settings (#565) 2026-04-15 23:16:16 +03:00
Flaminel 447db6990a Add item grabbed notification (#564) 2026-04-15 21:51:09 +03:00
Flaminel 4e9d20db0a Improve seeding rule customization (#553) 2026-04-11 17:13:41 +03:00
Flaminel 53fc5eff3b Fix qBittorrent tracker fetching (#555) 2026-04-10 21:00:28 +03:00
Flaminel 69fa09e23a Fix disabled arr instances being processed in some cases (#554) 2026-04-09 14:41:32 +03:00
Flaminel 3360b7a849 Fix db migration not being applied (#552) 2026-04-07 11:51:07 +03:00
Flaminel 80b46df8e5 Add search event reason (#546) 2026-04-06 09:59:31 +03:00
Flaminel 88aa71c343 Fix duplicated grabbed items reported for Sonarr on season packs (#549) 2026-04-05 22:11:48 +03:00
Flaminel 7b80e038cc Fix rTorrent path evaluation for items without a subdirectory (#548) 2026-04-05 22:11:22 +03:00
Flaminel ef280ec398 Fix queue rules upper bound to never allow 0 (#543) 2026-04-03 16:11:12 +03:00
Flaminel 81f6de03e7 Improve Seeker's total number description (#542) 2026-04-03 15:59:23 +03:00
Flaminel 17c3a6b02a Fix duplicated event search when changing instance for Seeker stats (#537) 2026-04-01 13:29:14 +03:00
Flaminel 4903b3137b Add hide unmonitored toggle for quality scores (#535) 2026-04-01 13:28:43 +03:00
Flaminel 868406c95c Fix tags not being excluded by Seeker (#533) 2026-03-31 12:22:45 +03:00
Flaminel 7122b16a7a Add support for separate Download Cleaner settings per download client (#531) 2026-03-30 18:31:48 +03:00
Flaminel b9fbac4ddc Add performance mode description (#532) 2026-03-30 18:30:37 +03:00
Flaminel 33e948d1e7 Update frontend packages (#528) 2026-03-29 17:36:02 +03:00
Flaminel 9f551f151e Fix duplicated events on the dashboard (#527) 2026-03-29 17:22:58 +03:00
Flaminel 9447cb37c0 Fix round robin toggle not changing state correctly (#526) 2026-03-29 17:22:27 +03:00
Flaminel 8183b324a0 Fix Seeker being scheduled when disabled (#523) 2026-03-28 00:25:49 +02:00
Flaminel a6a25de19c Fix Seeker not grouping season packs when checking active downloads (#522) 2026-03-28 00:18:11 +02:00
Flaminel 51a2a1b391 Add missing and upgrade search (#507) 2026-03-27 19:40:09 +02:00
Flaminel d7ab81ddcf Fix Firefox missing the bookmark icon (#520) 2026-03-26 02:14:39 +02:00
Flaminel 8da07d4e93 Fix dashboard logs initial order (#519) 2026-03-26 01:16:04 +02:00
Flaminel 20b93f6853 Fix Transmission ratio not being fetched (#518) 2026-03-24 15:13:39 +02:00
Flaminel 2ce204c1bc Fix animated counter having negative values (#515) 2026-03-23 10:18:11 +02:00
Flaminel d653b7fa3f Remove the suggested apps section (#514) 2026-03-22 23:25:59 +02:00
Flaminel 0dcb42efa2 Add failed import edge case handling for Sonarr and Radarr (#510) 2026-03-22 12:01:08 +02:00
Flaminel 820d254553 Fix PR build bot reacting to all comments (#511) 2026-03-22 12:00:03 +02:00
Flaminel 715ef5711b Add better tracking for dry run events (#512) 2026-03-19 23:53:42 +02:00
Flaminel ea3244367e Fix Windows PR build (#509) 2026-03-17 11:37:11 +02:00
Flaminel f26768bcf7 Add Windows build trigger on PR comment (#508) 2026-03-17 11:29:50 +02:00
Flaminel 87bb92fac0 Fix process time difference between valid and invalid user login (#503) 2026-03-13 14:33:36 +02:00
Flaminel 01dc90bfa7 Fix test workflows cancelling each other (#502) 2026-03-12 22:30:59 +02:00
Flaminel c37e6384a5 Add failed screen before login when the API can not be reached (#501) 2026-03-12 22:16:15 +02:00
Flaminel 70fc955d37 Add OIDC support (#500) 2026-03-12 22:12:20 +02:00
Flaminel a44f226e8a Pin dotnet build version (#499) 2026-03-10 20:42:58 +02:00
Flaminel edafde5810 Fix SQLite schema cause by process_no_content_id column (#498) 2026-03-10 20:28:23 +02:00
Flaminel 54cd037cd2 Update frontend packages (#496) 2026-03-10 17:54:02 +02:00
Flaminel 2ebf67d44d Add option to process items with no content id (#488) 2026-03-10 11:01:45 +02:00
Flaminel d2d294b93c Add explicit no-cache headers (#495) 2026-03-10 09:53:46 +02:00
Flaminel 39eb91ac48 Fix Cloudflare blocklist deployment (#491) 2026-03-06 18:33:32 +02:00
Flaminel 53376d94d9 Fix chip input not displaying the proper error message (#490) 2026-03-06 18:31:34 +02:00
Flaminel f51973bb7b Remove the known malware feature (#489) 2026-03-06 17:53:04 +02:00
Flaminel 33d1756fdd Revoke all refresh tokens on password change (#486) 2026-03-02 14:58:19 +02:00
Flaminel 63931763c4 Fix session invalidation for local auth bypass (#487) 2026-03-02 13:34:20 +02:00
Flaminel bdb956ec84 Add guards against exposing passwords to the UI (#477) 2026-03-02 13:11:34 +02:00
Flaminel 41b48d1104 Enforce authorize attribute explicitely (#485) 2026-03-02 13:00:23 +02:00
Flaminel 57fef26726 Add missing rTorrent and Whisparr v3 docs (#484) 2026-03-02 12:45:52 +02:00
Flaminel ea94dc4548 Fix reveal button being pressed on Enter instead of login (#483) 2026-03-02 12:41:35 +02:00
Flaminel 13a7232bc5 Update frontend packages (#478) 2026-02-28 04:16:11 +02:00
Flaminel 5fea8a0041 Add Docker multistep build (#475) 2026-02-27 20:42:27 +02:00
Flaminel 2333c86a08 Increase access token lifetime to 1 hour (#474) 2026-02-27 15:13:08 +02:00
Flaminel cbfc1b2875 Fix the Malware Blocker page not displaying validation errors (#473) 2026-02-25 22:28:49 +02:00
Flaminel 62e10afe7b Fix the Download Cleaner page not displaying validation errors (#472) 2026-02-25 22:20:49 +02:00
Flaminel d542c716f9 Add support for rootless Docker container (#471) 2026-02-24 19:03:01 +02:00
Flaminel 7eeaefaa65 Fix some auth endpoints not checking if user setup is complete (#468) 2026-02-23 20:33:22 +02:00
Flaminel bd55356881 Remove Huntarr because it's too hot right now (#467) 2026-02-23 19:11:08 +02:00
Flaminel df986a2e36 Redirect to base path from root path (#466) 2026-02-23 17:24:48 +02:00
Flaminel 8a2aca79f7 Add local auth bypass and make 2FA optional (#461) 2026-02-23 11:49:55 +02:00
Flaminel e1cc0dba28 Add rTorrent support (#446) 2026-02-22 19:49:14 +02:00
Flaminel 660dce0fa7 Fix strikes not being reset when disabling dry run (#456) 2026-02-22 19:37:03 +02:00
Flaminel ee343ba469 Fixed failed imports input being invalid when strikes are set to 0 (#454) 2026-02-22 19:00:52 +02:00
Flaminel d038738008 Add seeding rule privacy type (#449) 2026-02-22 00:55:22 +02:00
Flaminel 70f9995041 Add stats endpoint used for dashboards (#453) 2026-02-21 02:27:15 +02:00
Flaminel 6a1aaec7c2 Add seconds to the jobs next run in the dashboard (#451) 2026-02-20 19:18:22 +02:00
Flaminel 50e486bf5f Add authentication (#444) 2026-02-20 18:09:29 +02:00
Flaminel bc15a9a934 Fix PWA installation (#450) 2026-02-20 16:27:22 +02:00
Flaminel 573dbcf882 Fix modal transparency (#448) 2026-02-17 00:23:31 +02:00
Flaminel 94acd9afa4 Fix download client inputs (#442) 2026-02-15 04:06:28 +02:00
Flaminel 65d25a72a9 Fix failed import reason display for events (#443) 2026-02-15 03:59:40 +02:00
Flaminel 97eb2fce44 Add strikes page (#438) 2026-02-15 03:57:14 +02:00
Flaminel 701829001c Fix speed and size inputs for queue rules (#440) 2026-02-15 01:19:32 +02:00
Flaminel 8aeeca111c Add strike persistency (#437) 2026-02-14 04:00:05 +02:00
Flaminel c43936ce81 Remove testing download path (#436) 2026-02-13 11:52:53 +02:00
Flaminel f35eb0c922 Add full message for logs on the UI (#435) 2026-02-13 02:05:39 +02:00
Flaminel b2b0626b44 Add external url for notifications (#434) 2026-02-13 02:04:27 +02:00
Flaminel 40f108d7ca Add app status setting to general settings (#433) 2026-02-12 23:12:26 +02:00
Flaminel 6570f74b7e Fix Deluge failing on empty password (#432) 2026-02-12 22:29:55 +02:00
Flaminel 16f216cf84 Add Gotify notification provider (#420) 2026-02-12 18:17:09 +02:00
Flaminel 69551edeff Revamp UI (#429) 2026-02-12 17:51:30 +02:00
Flaminel 7192796e89 Update frontend packages (#422) 2026-01-14 14:17:04 +02:00
Flaminel 1d1ee7972f Use CDN to deliver logos (#421) 2026-01-14 13:51:25 +02:00
Flaminel 8bd6b86018 Add Discord notification provider (#417) 2026-01-13 18:53:40 +02:00
Flaminel 6abb542271 Fix Servarr version dropdown (#414) 2026-01-11 02:33:16 +02:00
Flaminel 2aceae3078 Fix package lock file being out of sync (#411) 2026-01-09 00:05:34 +02:00
Flaminel 65b200a68e Fix MacOS build (#409) 2026-01-07 02:57:52 +02:00
Flaminel de0c881944 Fix inode count duplication when looking for hardlinks (#407) 2026-01-07 02:27:27 +02:00
Flaminel d0ef01d79b Add images for Apprise CLI notifications (#408) 2026-01-07 02:11:59 +02:00
Flaminel 9457236e99 Update packages and dotnet version (#398) 2026-01-07 00:27:20 +02:00
Flaminel d43b4fc1c4 Add Whisparr v3 support (#405) 2026-01-03 23:34:21 +02:00
Flaminel e9750429eb Add Telegram notification provider (#400) 2026-01-03 23:34:05 +02:00
Flaminel b71b268b08 Add configurable bind address (#404) 2025-12-31 04:40:43 +02:00
Flaminel a708d22b27 Add GitAds to README (#403) 2025-12-31 03:35:35 +02:00
Flaminel a9a3b08ad6 Add item name for dashboard events (#402) 2025-12-31 02:35:55 +02:00
Flaminel 1d1e8679e4 Add supported apps version disclaimer (#399) 2025-12-30 00:26:16 +02:00
Flaminel 142d445ed0 Add Apprise CLI notification provider (#387) 2025-12-25 22:05:23 +02:00
Flaminel 375094862c Add test button for arrs and download clients (#391) 2025-12-20 17:06:03 +02:00
Flaminel 58a72cef0f Add option for multiple ignored root directories (#390) 2025-12-20 17:04:36 +02:00
Flaminel 4ceff127a7 Add option to keep source files when cleaning downloads (#388) 2025-12-19 23:52:59 +02:00
Flaminel c07b811cf8 Fix Transmission torrent fetch (#389) 2025-12-19 23:35:23 +02:00
Flaminel b16fa70774 Add Pushover notification provider (#385) 2025-12-13 21:24:34 +02:00
Flaminel b343165644 Fix Download Cleaner making too many requests (#368) 2025-12-10 09:22:51 +02:00
Flaminel 02dff0bb9b Fix manual release workflows (#380) 2025-12-01 23:42:22 +02:00
Flaminel ac3be75082 Fix workflow dispatch defaulting to dev version (#379) 2025-11-30 22:53:23 +02:00
Flaminel a1663b865a Improve workflow dispatch (#378) 2025-11-30 22:27:40 +02:00
Flaminel c97a416d1e Fix windows workflow (#377) 2025-11-30 16:22:21 +02:00
Flaminel d28ab42303 Fix frontend workflow using assets instead of cache (#376) 2025-11-30 15:48:53 +02:00
Flaminel fbb2bba3b6 Update packages (#375) 2025-11-30 13:14:29 +02:00
Flaminel 08eda22587 Add test workflow and improve workflow parallelization (#369) 2025-11-25 23:05:28 +02:00
Flaminel a4045eebd3 Add downloads volume to detailed installation docs (#365) 2025-11-22 22:15:37 +02:00
Flaminel a57cbccbb4 Improve UI validations (#366) 2025-11-22 22:14:50 +02:00
Flaminel 2221f118bb Fix qBittorrent tracker check (#363) 2025-11-09 19:03:48 +02:00
Flaminel 2cc3eb4ebb Fix ignored downloads not checking for certain fields (#362) 2025-11-09 18:24:26 +02:00
Flaminel 3a064a22bd Remove hardcoded app status timeout (#356) 2025-11-03 18:38:09 +02:00
Flaminel ee764ff215 Fix Transmission stalled check (#354) 2025-11-02 17:48:30 +02:00
Flaminel 402677b69b Fix ignored downloads not being saved for Queue Cleaner (#353)
fixed ignored downloads not being saved for Queue Cleaner
2025-10-31 17:39:52 +02:00
Flaminel 97f63434fd Recreate faulty migration (#349) 2025-10-23 23:59:11 +03:00
Flaminel 07d0cf07e3 Add more screenshots to the docs (#350) 2025-10-23 23:53:01 +03:00
Flaminel 4be107439a Fix ntfy notification priority being overwritten (#348) 2025-10-23 23:36:22 +03:00
Flaminel 89ef03a859 Add failed import safeguard for private torrents when download client is unavailable (#347) 2025-10-23 18:27:28 +03:00
Flaminel 905384034d Improve docs (#342) 2025-10-23 18:17:43 +03:00
Flaminel bf826da1ae Add handling for items that are not being blocked (#346) 2025-10-23 18:12:42 +03:00
Flaminel 6aac35181b Change minimum and default value for search delay (#345) 2025-10-23 11:50:13 +03:00
Flaminel efbf60dcdd Add option to remove only specified failed import message patterns (#297) 2025-10-22 23:45:05 +03:00
Flaminel ebb166a7b9 Add queue rules (#332) 2025-10-22 13:46:43 +03:00
Flaminel 7aced28262 Add contributing guide (#340) 2025-10-17 23:12:35 +03:00
Flaminel ae3e793498 Update GitHub issue templates (#339)
updated issue templates
2025-10-17 22:30:35 +03:00
Flaminel 4eb98b18a1 Update the feature request template (#329) 2025-10-06 16:11:31 +03:00
Copilot 128e7e5f11 Add on-demand job triggers (#310) 2025-10-01 10:42:02 +03:00
Flaminel d224b2dea0 Add app version to the UI (#318) 2025-10-01 10:36:13 +03:00
Flaminel 16e823b8d3 Add status json to Cloudflare pages (#323) 2025-09-29 21:43:15 +03:00
Flaminel f2f11e3472 Fix UI caching for Download Cleaner page (#316) 2025-09-29 21:37:25 +03:00
Flaminel a3549c80a9 Fix icons paths (#322) 2025-09-29 21:36:59 +03:00
Flaminel 2b9c347ed6 Fix docs build workflow (#315) 2025-09-25 12:23:20 +03:00
Flaminel 98ccee866d Fix sidebar styling (#313) 2025-09-25 12:06:46 +03:00
Flaminel 911849c6dd Fix blacklist synchronizer docs (#307) 2025-09-16 23:30:38 +03:00
Flaminel cce3bb2c4a Add ntfy support (#300) 2025-09-15 22:08:48 +03:00
Flaminel bcc117cd0d Fix slow strikes not being reset (#305) 2025-09-15 22:03:18 +03:00
Flaminel 8e20a68ae2 Improve frontend layout (#299) 2025-09-15 22:03:03 +03:00
Flaminel 736c146f25 Add ignored downloads setting per job (#301) 2025-09-15 22:02:03 +03:00
Flaminel 6398ef1cc6 Add option to inject blacklist into qBittorrent (#304) 2025-09-15 21:59:49 +03:00
Flaminel 83e6a289be Change docs build triggers (#303) 2025-09-15 20:55:35 +03:00
Julien Virey 5662118b01 Add documentation about archlinux package (#296) 2025-09-06 01:30:06 +03:00
Flaminel 22dfc7b40d Fix blocklist provider reporting wrong number of loaded blocklists (#293) 2025-09-04 22:14:46 +03:00
Flaminel a51e387453 Fix log level change not taking effect (#292) 2025-09-04 22:12:57 +03:00
Flaminel c7d2ec7311 Fix notification provider update (#291) 2025-09-03 23:48:02 +03:00
Flaminel bb9ac5b67b Fix notifications migration when no event type is enabled (#290) 2025-09-03 21:12:55 +03:00
Flaminel f93494adb2 Rework notifications system (#284) 2025-09-02 23:18:22 +03:00
Flaminel 7201520411 Add configurable log retention (#279) 2025-09-02 00:17:16 +03:00
Flaminel 2a1e65e1af Make sidebar scrollable (#285) 2025-09-02 00:16:38 +03:00
Flaminel da318c3339 Fix HTTPS schema for Cloudflare pages links (#286) 2025-09-02 00:16:27 +03:00
Flaminel 7149b6243f Add .sql to the blacklist (#287) 2025-09-02 00:16:12 +03:00
Flaminel 11f5a28c04 Improve download client health checks (#288) 2025-09-02 00:15:09 +03:00
Flaminel 9cc36c7a50 Add qBittorrent basic auth support (#246) 2025-08-11 10:52:44 +03:00
Flaminel 861c135cc6 fixed Malware Blocker docs path 2025-08-07 11:55:46 +03:00
Flaminel 3b0275c411 Finish rebranding Content Blocker to Malware Blocker (#271) 2025-08-06 22:55:39 +03:00
Flaminel cad1b51202 Improve logs and events ordering to be descending from the top (#270) 2025-08-06 22:51:20 +03:00
Flaminel f50acd29f4 Disable MassTransit telemetry (#268) 2025-08-06 22:50:48 +03:00
LucasFA af11d595d8 Fix detailed installation docs (#260)
https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed
2025-08-06 22:49:14 +03:00
Flaminel 44994d5b21 Fix Notifiarr channel id input (#267) 2025-08-04 22:07:33 +03:00
Flaminel 592fd2d846 Fix Malware Blocker renaming issue (#259) 2025-08-02 15:54:26 +03:00
Flaminel e96be1fca2 Small general fixes (#257)
* renamed ContentBlocker into MalwareBlocker in the logs

* fixed "Delete Private" input description
2025-08-02 11:36:47 +03:00
Flaminel ee44e2b5ac Rework sidebar navigation (#255) 2025-08-02 05:31:25 +03:00
Flaminel 323bfc4d2e added major and minor tags for Docker images 2025-08-01 19:51:10 +03:00
Flaminel dca45585ca General frontend improvements (#252) 2025-08-01 19:45:01 +03:00
Flaminel 8b5918d221 Improve malware detection for known malware (#251) 2025-08-01 19:33:35 +03:00
Flaminel 9c227c1f59 add Cloudflare static assets 2025-08-01 18:37:45 +03:00
Flaminel 2ad4499a6f Fix DownloadCleaner failing when using multiple download clients (#248) 2025-07-31 22:20:01 +03:00
Flaminel 33a5bf9ab3 Add uTorrent support (#240) 2025-07-28 23:09:19 +03:00
Flaminel de06d1c2d3 Fix download client type being sent as number instead of string (#245) 2025-07-27 14:23:48 +03:00
Flaminel 72855bc030 small fix on how_it_works page of the docs 2025-07-24 18:41:05 +03:00
eatsleepcoderepeat-gl b185ea6899 Added new whitelist which includes subtitles (#243) 2025-07-24 12:50:03 +03:00
Flaminel 1e0127e97e Add more states to be picked up by Download Cleaner (#242) 2025-07-23 23:54:20 +03:00
Flaminel 5bdbc98d68 fixed Docker image path in docs 2025-07-23 11:39:50 +03:00
Flaminel e1aeb3da31 Try #1 to fix memory leak (#241) 2025-07-22 12:24:38 +03:00
Flaminel 283b09e8f1 fixed release name 2025-07-22 12:03:23 +03:00
Flaminel b03c96249b Improve torrent protocol detection (#235) 2025-07-07 20:42:59 +03:00
Flaminel 2971445090 Add handling type of malware when containing thepirateheaven.org file (#232) 2025-07-07 14:29:39 +03:00
Flaminel 55c23419cd Improve download removal to be separate from download search (#233) 2025-07-07 14:28:34 +03:00
Flaminel c4b9d9503a Add more logs for debug (#201) 2025-07-07 14:28:15 +03:00
Flaminel 823b73d9f0 Fix arr max strikes not being used instead of global setting (#231) 2025-07-04 21:16:02 +03:00
Flaminel 31632d25a4 Add Whisparr support (#215) 2025-07-04 21:15:35 +03:00
Flaminel c59951a39c Add Progressive Web App (PWA) support (#228) 2025-07-04 21:15:14 +03:00
Flaminel d9140d7b5b Add support for Apprise tags (#229) 2025-07-04 21:14:40 +03:00
Flaminel 90865a73b5 Add failed import messages to logs (#230) 2025-07-04 21:14:27 +03:00
Flaminel cc45233223 Add support for basic auth for Apprise (#221) 2025-07-03 12:43:43 +03:00
Flaminel 5d12d601ae fixed repo links in the docs 2025-07-01 22:02:14 +03:00
Flaminel 88f40438af Fix validations and increased strikes limits (#212) 2025-07-01 13:18:50 +03:00
Flaminel 0a9ec06841 removed forgotten release step from MacOS workflow 2025-07-01 11:05:00 +03:00
Flaminel a0ca6ec4b8 Add curl to the Docker image (#211) 2025-07-01 10:06:22 +03:00
Flaminel eb6cf96470 Fix cron expression inputs (#203) 2025-07-01 01:00:43 +03:00
Flaminel 2ca0616771 Add date on dashboard logs and events (#205) 2025-07-01 01:00:30 +03:00
Flaminel bc85144e60 Improve deploy workflows (#206) 2025-07-01 01:00:16 +03:00
Flaminel 236e31c841 Add download client name on debug logs (#207) 2025-07-01 00:59:52 +03:00
Flaminel 7a15139aa6 Fix autocomplete input on mobile phones (#196) 2025-06-30 13:28:14 +03:00
Flaminel fb6ccfd011 Add Readarr support (#191) 2025-06-29 19:54:15 +03:00
Flaminel ef85e2b690 Fix docs broken links (#190) 2025-06-29 01:03:24 +03:00
Flaminel bb734230aa Add health checks (#181) 2025-06-29 00:00:55 +03:00
Flaminel aa31c31955 Remove right-side icons from settings cards (#183) 2025-06-29 00:00:25 +03:00
Flaminel 1a89822f36 Change icon direction for UI accordions (#182) 2025-06-29 00:00:11 +03:00
Flaminel fc9e0eca36 Fix some small UI stuff (#185) 2025-06-28 23:59:49 +03:00
Flaminel 0010dcb1c6 Fix jobs not being scheduled according to the cron expression (#187) 2025-06-28 23:55:08 +03:00
Flaminel 0ab8611f29 removed Docker Hub reference 2025-06-28 11:52:34 +03:00
Flaminel 9e02408a7e Fix download cleaner categories not being fetched (#177) 2025-06-28 00:08:58 +03:00
Flaminel 1bd0db05e6 updated readme 2025-06-27 21:32:22 +03:00
Flaminel fb438f2ca7 Fix base paths being incorrectly configured for download clients (#173) 2025-06-27 19:44:46 +03:00
Flaminel d4de7f2ec3 Fix old category being the same as the new category when handling unlinked downloads (#172) 2025-06-27 19:12:48 +03:00
Flaminel 98ee1943f9 fixed duplicated docs description 2025-06-27 18:45:47 +03:00
Flaminel 4a57c0fba3 fixed broken docs links from README 2025-06-27 16:51:18 +03:00
Flaminel db0698d515 fixed docs path 2025-06-27 16:33:02 +03:00
Flaminel 712cc9ff1e fixed docs broken link 2025-06-27 16:29:40 +03:00
Flaminel 501be0e4e7 triggered docs build 2025-06-27 16:26:02 +03:00
Flaminel 19b7613eea fixed release workflow 2025-06-27 16:25:53 +03:00
Flaminel 3d9cd8f6a9 fixed UI support button 2025-06-27 16:23:22 +03:00
Flaminel c8add22d3d fixed executable build 2025-06-27 16:21:48 +03:00
Flaminel 69d8cc8fa0 fixed docker image path 2025-06-27 16:08:48 +03:00
Flaminel 8b8a4b3837 fixed docker build 2025-06-27 15:54:07 +03:00
Flaminel c45006f219 fixed release workflow 2025-06-27 15:43:47 +03:00
Flaminel bc306a37c9 Cleanuparr v2 (#166) 2025-06-27 15:39:26 +03:00
Flaminel aab0487020 Updated blacklists 2025-06-25 16:19:35 +03:00
Flaminel ca892ce188 Updated blacklists 2025-06-23 00:41:07 +03:00
Flaminel cff5dc20e5 Update blacklists 2025-06-21 23:41:19 +03:00
Flaminel 0050c45f09 updated deployment 2025-06-12 23:45:46 +03:00
Flaminel 2a8f4634e5 Update blacklists (#155) 2025-06-07 16:57:06 +03:00
Flaminel 81da704e6f updated docs with missing variable 2025-05-15 15:25:55 +03:00
Flaminel c82b5e11b1 Add rate limiting for download removal (#141) 2025-05-11 13:27:51 +03:00
Flaminel c36d9eb9cf fixed Transmission docs 2025-05-11 10:16:13 +03:00
Flaminel 2f21603e8e fixed docs urls 2025-05-10 14:28:26 +03:00
Flaminel 586f9964b5 Update docs (#138) 2025-05-10 14:28:40 +03:00
Flaminel 124670bb98 updated features 2025-05-09 14:39:39 +03:00
Flaminel baf6a8c2f4 Add option to set failed import strikes per arr (#135) 2025-05-09 01:17:16 +03:00
Flaminel cd345afc54 Fix logs when using qBit tag instead of category (#134) 2025-05-08 22:50:14 +03:00
Flaminel 246ec4d6eb Add option to set a tag instead of changing the category for unlinked downloads (#133) 2025-05-08 21:51:08 +03:00
Flaminel 569eeae181 Fix hardlinks on ARM64 (#130) 2025-05-07 21:44:49 +03:00
1644 changed files with 229684 additions and 16264 deletions

No files matched your search

+9 -2
View File
@@ -6,7 +6,14 @@ body:
- type: markdown
attributes:
value: |
Thanks for taking the time to improve cleanuperr!
Thanks for taking the time to improve Cleanuparr!
- type: checkboxes
id: duplicate-check
attributes:
label: "Duplicate check"
options:
- label: I have searched for existing issues and confirmed this is not a duplicate.
required: true
- type: checkboxes
id: init
attributes:
@@ -14,7 +21,7 @@ body:
options:
- label: Reviewed the documentation.
required: true
- label: Ensured I am using ghcr.io/flmorg/cleanuperr 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
+20 -1
View File
@@ -6,7 +6,26 @@ body:
- type: markdown
attributes:
value: |
Thanks for taking the time to improve cleanuperr!
Thanks for taking the time to improve Cleanuparr!
- type: checkboxes
id: duplicate-check
attributes:
label: "Duplicate check"
options:
- label: I have searched for existing issues and confirmed this is not a duplicate.
required: true
- type: checkboxes
id: init
attributes:
label: Implementation & testing support
description: The requester should help answer questions, provide support for the implementation and test changes.
options:
- label: I understand I must be available to assist with implementation questions and to test the feature before being released.
required: true
- label: I understand that joining the Discord server may be necessary for better coordination and faster communication.
required: true
- label: I understand that failure to assist in the development process of my request will result in the request being closed.
required: true
- type: textarea
id: description
attributes:
+8 -1
View File
@@ -7,6 +7,13 @@ body:
attributes:
value: |
If you are experiencing unexpected behavior, please consider submitting a bug report instead.
- type: checkboxes
id: duplicate-check
attributes:
label: "Duplicate check"
options:
- label: I have searched for existing issues and confirmed this is not a duplicate.
required: true
- type: checkboxes
id: init
attributes:
@@ -14,7 +21,7 @@ body:
options:
- label: Reviewed the documentation.
required: true
- label: Ensured I am using ghcr.io/flmorg/cleanuperr 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
+7 -1
View File
@@ -1,2 +1,8 @@
blank_issues_enabled: false
contact_links: []
contact_links:
- name: Discord Community
url: https://discord.gg/SCtMCgtsc4
about: Join our Discord for real-time help and discussions
- name: Documentation
url: https://cleanuparr.github.io/Cleanuparr/
about: Check the documentation for configurations and usage guidelines
+24
View File
@@ -0,0 +1,24 @@
## Description
<!-- Brief description of what this PR does -->
## Related Issue
Closes #ISSUE_NUMBER
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Testing
<!-- Describe how you tested your changes -->
## Screenshots (if applicable)
<!-- Add screenshots here -->
## Checklist
- [ ] I have read the [Contributing Guide](../CONTRIBUTING.md)
- [ ] I have announced my intent to work on this and received approval
- [ ] My code follows the project's code standards
- [ ] I have tested my changes thoroughly
- [ ] I have updated relevant documentation
+30
View File
@@ -0,0 +1,30 @@
name: 'Get Vault Secrets'
description: 'Retrieves secrets from HashiCorp Vault using AppRole authentication'
inputs:
vault_host:
description: 'Vault server URL'
required: true
vault_role_id:
description: 'Vault AppRole Role ID'
required: true
vault_secret_id:
description: 'Vault AppRole Secret ID'
required: true
secrets:
description: 'Secrets to retrieve (multiline string, one per line in format: path | output_name)'
required: true
default: |
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
secrets/data/github packages_pat | PACKAGES_PAT
runs:
using: "composite"
steps:
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ inputs.vault_host }}
method: approle
roleId: ${{ inputs.vault_role_id }}
secretId: ${{ inputs.vault_secret_id }}
secrets: ${{ inputs.secrets }}
+270
View File
@@ -0,0 +1,270 @@
name: Build Docker Images
on:
pull_request:
paths:
- 'code/**'
workflow_call:
inputs:
push_docker:
description: 'Push Docker image to registry'
type: boolean
required: false
default: true
app_version:
description: 'Application version'
type: string
required: false
default: ''
# Cancel in-progress runs for the same PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
env:
REGISTRY_IMAGE: ghcr.io/cleanuparr/cleanuparr
jobs:
# Compute tags, version, and push decision for downstream jobs
prepare:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
outputs:
tags: ${{ steps.build-info.outputs.tags }}
version: ${{ steps.build-info.outputs.version }}
version_docker_tag: ${{ steps.build-info.outputs.version_docker_tag }}
branch: ${{ steps.build-info.outputs.branch }}
push: ${{ steps.build-info.outputs.push }}
github_sha: ${{ github.sha }}
steps:
- name: Initialize build info
id: build-info
timeout-minutes: 1
run: |
githubHeadRef="${{ github.head_ref }}"
githubRef="${{ github.ref }}"
inputVersion="${{ inputs.app_version }}"
latestDockerTag=""
versionDockerTag=""
majorVersionDockerTag=""
minorVersionDockerTag=""
version="0.0.1"
if [[ -n "$inputVersion" ]]; then
# Version provided via input (manual release)
branch="main"
latestDockerTag="latest"
versionDockerTag="$inputVersion"
version="$inputVersion"
# Extract major and minor versions for additional tags
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
majorVersionDockerTag="${BASH_REMATCH[1]}"
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
fi
elif [[ "$githubRef" =~ ^"refs/tags/" ]]; then
# Tag push
branch=${githubRef##*/}
latestDockerTag="latest"
versionDockerTag=${branch#v}
version=${branch#v}
# Extract major and minor versions for additional tags
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
majorVersionDockerTag="${BASH_REMATCH[1]}"
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
fi
else
if [[ -z "$githubHeadRef" ]]; then
# Main branch
branch=${githubRef##*/}
versionDockerTag="$branch"
else
# Pull request
branch=$githubHeadRef
versionDockerTag="$branch"
fi
fi
githubTags=""
if [ -n "$latestDockerTag" ]; then
githubTags="$githubTags,$REGISTRY_IMAGE:$latestDockerTag"
fi
if [ -n "$versionDockerTag" ]; then
githubTags="$githubTags,$REGISTRY_IMAGE:$versionDockerTag"
fi
if [ -n "$minorVersionDockerTag" ]; then
githubTags="$githubTags,$REGISTRY_IMAGE:$minorVersionDockerTag"
fi
if [ -n "$majorVersionDockerTag" ]; then
githubTags="$githubTags,$REGISTRY_IMAGE:$majorVersionDockerTag"
fi
githubTags="${githubTags#,}"
# Determine push decision
push="${{ github.event_name == 'pull_request' || inputs.push_docker == true }}"
echo "tags=$githubTags" >> $GITHUB_OUTPUT
echo "version=$version" >> $GITHUB_OUTPUT
echo "version_docker_tag=$versionDockerTag" >> $GITHUB_OUTPUT
echo "branch=$branch" >> $GITHUB_OUTPUT
echo "push=$push" >> $GITHUB_OUTPUT
# Build each platform in parallel
build:
runs-on: ubuntu-latest
needs: [prepare]
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
steps:
- name: Prepare platform pair
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
secrets/data/github packages_pat | PACKAGES_PAT
- name: Checkout target repository
uses: actions/checkout@v4
timeout-minutes: 1
with:
repository: ${{ github.repository }}
ref: ${{ needs.prepare.outputs.branch }}
token: ${{ env.REPO_READONLY_PAT }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
timeout-minutes: 5
- name: Login to GitHub Container Registry
if: needs.prepare.outputs.push == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
if: needs.prepare.outputs.push == 'true'
id: build-push
timeout-minutes: 30
uses: docker/build-push-action@v6
with:
context: ${{ github.workspace }}/code
file: ${{ github.workspace }}/code/Dockerfile
provenance: false
labels: |
commit=sha-${{ needs.prepare.outputs.github_sha }}
version=${{ needs.prepare.outputs.version_docker_tag }}
build-args: |
VERSION=${{ needs.prepare.outputs.version }}
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
platforms: ${{ matrix.platform }}
outputs: type=image,"name=${{ env.REGISTRY_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
- name: Build (no push)
if: needs.prepare.outputs.push != 'true'
timeout-minutes: 30
uses: docker/build-push-action@v6
with:
context: ${{ github.workspace }}/code
file: ${{ github.workspace }}/code/Dockerfile
provenance: false
labels: |
commit=sha-${{ needs.prepare.outputs.github_sha }}
version=${{ needs.prepare.outputs.version_docker_tag }}
build-args: |
VERSION=${{ needs.prepare.outputs.version }}
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
platforms: ${{ matrix.platform }}
push: false
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
- name: Export digest
if: needs.prepare.outputs.push == 'true'
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build-push.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
if: needs.prepare.outputs.push == 'true'
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
# Create multi-platform manifest and push with final tags
merge:
runs-on: ubuntu-latest
needs: [prepare, build]
if: needs.prepare.outputs.push == 'true'
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
timeout-minutes: 5
working-directory: ${{ runner.temp }}/digests
run: |
tags="${{ needs.prepare.outputs.tags }}"
tag_args=""
IFS=',' read -ra TAG_ARRAY <<< "$tags"
for tag in "${TAG_ARRAY[@]}"; do
tag=$(echo "$tag" | xargs)
if [ -n "$tag" ]; then
tag_args="$tag_args -t $tag"
fi
done
docker buildx imagetools create $tag_args \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image
run: |
tags="${{ needs.prepare.outputs.tags }}"
first_tag=$(echo "$tags" | tr ',' '\n' | grep -v '^$' | head -1 | xargs)
docker buildx imagetools inspect "$first_tag"
+126
View File
@@ -0,0 +1,126 @@
name: Build Executables
on:
workflow_call:
inputs:
app_version:
description: 'Application version'
type: string
required: false
default: ''
jobs:
# Build for each platform in parallel using matrix strategy
build-platform:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
include:
- runtime: win-x64
platform: win-amd64
- runtime: linux-x64
platform: linux-amd64
- runtime: linux-arm64
platform: linux-arm64
- runtime: osx-x64
platform: osx-amd64
- runtime: osx-arm64
platform: osx-arm64
steps:
- name: Set variables
run: |
ref=${{ github.ref }}
# Use input version if provided, otherwise determine from ref
if [[ -n "${{ inputs.app_version }}" ]]; then
appVersion="${{ inputs.app_version }}"
releaseVersion="v$appVersion"
elif [[ "$ref" =~ ^refs/tags/ ]]; then
releaseVersion=${ref##refs/tags/}
appVersion=${releaseVersion#v}
else
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
appVersion="0.0.1-dev"
fi
repoFullName=${{ github.repository }}
repositoryName=${repoFullName#*/}
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
echo "appVersion=$appVersion" >> $GITHUB_ENV
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
secrets/data/github packages_pat | PACKAGES_PAT
- name: Checkout target repository
uses: actions/checkout@v4
timeout-minutes: 1
with:
repository: ${{ env.githubRepository }}
ref: ${{ github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
- name: Setup dotnet
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.200
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Download frontend artifact
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: code/frontend/dist/ui/browser
- name: Install dependencies and restore
run: |
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
- name: Copy frontend to backend wwwroot
run: |
mkdir -p code/backend/${{ env.executableName }}/wwwroot
cp -r code/frontend/dist/ui/browser/* code/backend/${{ env.executableName }}/wwwroot/
- name: Build ${{ matrix.platform }}
run: |
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
-c Release \
--runtime ${{ matrix.runtime }} \
--self-contained \
-o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }} \
/p:PublishSingleFile=true \
/p:Version=${{ env.appVersion }} \
/p:DebugSymbols=false
- name: Zip artifact
run: |
cd ./artifacts
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}/
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: executable-${{ matrix.platform }}
path: ./artifacts/*.zip
retention-days: 30
+52
View File
@@ -0,0 +1,52 @@
name: Build Frontend
on:
workflow_call:
inputs:
ref:
description: 'Git ref to checkout (branch, tag, or SHA). Defaults to github.ref_name.'
type: string
required: false
default: ''
jobs:
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
- name: Checkout repository
uses: actions/checkout@v4
timeout-minutes: 1
with:
repository: ${{ github.repository }}
ref: ${{ inputs.ref || github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: code/frontend/package-lock.json
- name: Build frontend
run: |
cd code/frontend
npm ci
npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: code/frontend/dist/ui/browser
retention-days: 1
+376
View File
@@ -0,0 +1,376 @@
name: Build macOS Installers
permissions:
contents: write
on:
workflow_call:
inputs:
app_version:
description: 'Application version'
type: string
required: false
default: ''
jobs:
build-macos-installer:
name: Build macOS ${{ matrix.arch }} Installer
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: Intel
runner: macos-15-intel
runtime: osx-x64
min_os_version: "10.15"
artifact_suffix: intel
- arch: ARM
runner: macos-15
runtime: osx-arm64
min_os_version: "11.0"
artifact_suffix: arm64
steps:
- name: Set variables
run: |
repoFullName=${{ github.repository }}
ref=${{ github.ref }}
# Use input version if provided, otherwise determine from ref
if [[ -n "${{ inputs.app_version }}" ]]; then
appVersion="${{ inputs.app_version }}"
releaseVersion="v$appVersion"
elif [[ "$ref" =~ ^refs/tags/ ]]; then
releaseVersion=${ref##refs/tags/}
appVersion=${releaseVersion#v}
else
# For manual dispatch, use a default version
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
appVersion="0.0.1-dev"
fi
repositoryName=${repoFullName#*/}
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
echo "appVersion=$appVersion" >> $GITHUB_ENV
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
secrets/data/github packages_pat | PACKAGES_PAT
- name: Checkout repository
uses: actions/checkout@v4
with:
repository: ${{ env.githubRepository }}
ref: ${{ github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
fetch-depth: 0
- name: Download frontend artifact
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: code/frontend/dist/ui/browser
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.200
- name: Restore .NET dependencies
run: |
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
- name: Build macOS ${{ matrix.arch }} executable
run: |
# Clean any existing output directory
rm -rf dist
mkdir -p dist/temp
# Build to a temporary location
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
-c Release \
--runtime ${{ matrix.runtime }} \
--self-contained true \
-o dist/temp \
/p:PublishSingleFile=true \
/p:Version=${{ env.appVersion }} \
/p:DebugType=None \
/p:DebugSymbols=false \
/p:UseAppHost=true \
/p:EnableMacOSCodeSign=false \
/p:CodeSignOnCopy=false \
/p:_CodeSignDuringBuild=false \
/p:PublishTrimmed=false \
/p:TrimMode=link
# Create proper app bundle structure
mkdir -p dist/Cleanuparr.app/Contents/MacOS
# Copy the built executable (note: AssemblyName is "Cleanuparr" not "Cleanuparr.Api")
cp dist/temp/Cleanuparr dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
# Copy frontend directly to where it belongs in the app bundle
mkdir -p dist/Cleanuparr.app/Contents/MacOS/wwwroot
cp -r code/frontend/dist/ui/browser/* dist/Cleanuparr.app/Contents/MacOS/wwwroot/
# Copy any additional runtime files if they exist
if [ -d "dist/temp" ]; then
find dist/temp -name "*.dylib" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
find dist/temp -name "createdump" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
fi
- name: Post-build setup
run: |
# Make sure the executable is actually executable
chmod +x dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
# Remove any .pdb files that might have been created
find dist/Cleanuparr.app/Contents/MacOS -name "*.pdb" -delete 2>/dev/null || true
echo "Checking architecture of built binary:"
file dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
if command -v lipo >/dev/null 2>&1; then
lipo -info dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
fi
echo "Files in MacOS directory:"
ls -la dist/Cleanuparr.app/Contents/MacOS/
- name: Create macOS app bundle structure
run: |
# Create proper app bundle structure
mkdir -p dist/Cleanuparr.app/Contents/{MacOS,Resources,Frameworks}
# Convert ICO to ICNS for macOS app bundle
if command -v iconutil >/dev/null 2>&1; then
# Create iconset directory structure
mkdir -p Cleanuparr.iconset
# Use existing PNG files from Logo directory for different sizes
cp Logo/16.png Cleanuparr.iconset/icon_16x16.png
cp Logo/32.png Cleanuparr.iconset/icon_16x16@2x.png
cp Logo/32.png Cleanuparr.iconset/icon_32x32.png
cp Logo/64.png Cleanuparr.iconset/icon_32x32@2x.png
cp Logo/128.png Cleanuparr.iconset/icon_128x128.png
cp Logo/256.png Cleanuparr.iconset/icon_128x128@2x.png
cp Logo/256.png Cleanuparr.iconset/icon_256x256.png
cp Logo/512.png Cleanuparr.iconset/icon_256x256@2x.png
cp Logo/512.png Cleanuparr.iconset/icon_512x512.png
cp Logo/1024.png Cleanuparr.iconset/icon_512x512@2x.png
# Create ICNS file
iconutil -c icns Cleanuparr.iconset -o dist/Cleanuparr.app/Contents/Resources/Cleanuparr.icns
# Clean up iconset directory
rm -rf Cleanuparr.iconset
fi
# Create Launch Daemon plist
cat > dist/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.cleanuparr.daemon</string>
<key>ProgramArguments</key>
<array>
<string>/Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/cleanuparr.log</string>
<key>StandardErrorPath</key>
<string>/var/log/cleanuparr.error.log</string>
<key>WorkingDirectory</key>
<string>/Applications/Cleanuparr.app/Contents/MacOS</string>
<key>EnvironmentVariables</key>
<dict>
<key>HTTP_PORTS</key>
<string>11011</string>
</dict>
</dict>
</plist>
EOF
# Create Info.plist with proper configuration
cat > dist/Cleanuparr.app/Contents/Info.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>Cleanuparr</string>
<key>CFBundleIdentifier</key>
<string>com.Cleanuparr</string>
<key>CFBundleName</key>
<string>Cleanuparr</string>
<key>CFBundleDisplayName</key>
<string>Cleanuparr</string>
<key>CFBundleVersion</key>
<string>${{ env.appVersion }}</string>
<key>CFBundleShortVersionString</key>
<string>${{ env.appVersion }}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleSignature</key>
<string>CLNR</string>
<key>CFBundleIconFile</key>
<string>Cleanuparr</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSRequiresAquaSystemAppearance</key>
<false/>
<key>LSMinimumSystemVersion</key>
<string>${{ matrix.min_os_version }}</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSSupportsAutomaticTermination</key>
<false/>
<key>NSSupportsSuddenTermination</key>
<false/>
<key>LSBackgroundOnly</key>
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
EOF
# Clean up temp directory
rm -rf dist/temp
- name: Create PKG installer
run: |
# Create preinstall script to handle existing installations
mkdir -p scripts
cat > scripts/preinstall << 'EOF'
#!/bin/bash
# Stop and unload existing launch daemon if it exists
if launchctl list | grep -q "com.cleanuparr.daemon"; then
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
fi
# Stop any running instances of Cleanuparr
pkill -f "Cleanuparr" || true
sleep 2
# Remove old installation if it exists
if [[ -d "/Applications/Cleanuparr.app" ]]; then
rm -rf "/Applications/Cleanuparr.app"
fi
# Remove old launch daemon plist if it exists
if [[ -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist" ]]; then
rm -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist"
fi
exit 0
EOF
chmod +x scripts/preinstall
# Create postinstall script
cat > scripts/postinstall << 'EOF'
#!/bin/bash
# Set proper permissions for the app bundle
chmod -R 755 /Applications/Cleanuparr.app
chmod +x /Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr
# Install the launch daemon
cp /Applications/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist /Library/LaunchDaemons/
chown root:wheel /Library/LaunchDaemons/com.cleanuparr.daemon.plist
chmod 644 /Library/LaunchDaemons/com.cleanuparr.daemon.plist
# Load and start the service
launchctl load /Library/LaunchDaemons/com.cleanuparr.daemon.plist
launchctl start com.cleanuparr.daemon
# Wait a moment for service to start
sleep 3
# Display as system notification
osascript -e 'display notification "Cleanuparr service started! Visit http://localhost:11011 in your browser." with title "Installation Complete"' 2>/dev/null || true
exit 0
EOF
chmod +x scripts/postinstall
# Create uninstall script (optional, for user reference)
cat > scripts/uninstall_cleanuparr.sh << 'EOF'
#!/bin/bash
# Cleanuparr Uninstall Script
# Run this script with sudo to completely remove Cleanuparr
echo "Stopping Cleanuparr service..."
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
echo "Removing service files..."
rm -f /Library/LaunchDaemons/com.cleanuparr.daemon.plist
echo "Removing application..."
rm -rf /Applications/Cleanuparr.app
echo "Removing logs..."
rm -f /var/log/cleanuparr.log
rm -f /var/log/cleanuparr.error.log
echo "Cleanuparr has been completely removed."
echo "Note: Configuration files in /Applications/Cleanuparr.app/Contents/MacOS/config/ have been removed with the app."
EOF
chmod +x scripts/uninstall_cleanuparr.sh
# Copy uninstall script to app bundle for user access
cp scripts/uninstall_cleanuparr.sh dist/Cleanuparr.app/Contents/Resources/
# Determine package name - if app_version input was provided, it's a release build
if [[ -n "${{ inputs.app_version }}" ]] || [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}.pkg"
else
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}-dev.pkg"
fi
# Create PKG installer with better metadata
pkgbuild --root dist/ \
--scripts scripts/ \
--identifier com.Cleanuparr \
--version ${{ env.appVersion }} \
--install-location /Applications \
--ownership preserve \
${pkg_name}
echo "pkgName=${pkg_name}" >> $GITHUB_ENV
- name: Upload installer as artifact
uses: actions/upload-artifact@v4
with:
name: Cleanuparr-macos-${{ matrix.artifact_suffix }}-installer
path: '${{ env.pkgName }}'
retention-days: 30
@@ -0,0 +1,152 @@
name: Build Windows Installer
on:
workflow_call:
inputs:
app_version:
description: 'Application version'
type: string
required: false
default: ''
ref:
description: 'Git ref to checkout (branch, tag, or SHA). Defaults to github.ref_name.'
type: string
required: false
default: ''
jobs:
build-windows-installer:
runs-on: windows-latest
steps:
- name: Set variables
shell: pwsh
run: |
$repoFullName = "${{ github.repository }}"
$ref = "${{ github.ref }}"
$inputVersion = "${{ inputs.app_version }}"
# Use input version if provided, otherwise determine from ref
if ($inputVersion -ne "") {
$appVersion = $inputVersion
$releaseVersion = "v$appVersion"
} elseif ($ref -match "^refs/tags/") {
$releaseVersion = $ref -replace "refs/tags/", ""
$appVersion = $releaseVersion -replace "^v", ""
} else {
# For manual dispatch, use a default version
$releaseVersion = "dev-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
$appVersion = "0.0.1-dev"
}
$repositoryName = $repoFullName.Split("/")[1]
echo "githubRepository=${{ github.repository }}" >> $env:GITHUB_ENV
echo "githubRepositoryName=$repositoryName" >> $env:GITHUB_ENV
echo "releaseVersion=$releaseVersion" >> $env:GITHUB_ENV
echo "appVersion=$appVersion" >> $env:GITHUB_ENV
echo "APP_VERSION=$appVersion" >> $env:GITHUB_ENV
echo "executableName=Cleanuparr.Api" >> $env:GITHUB_ENV
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
secrets/data/github packages_pat | PACKAGES_PAT
- name: Checkout repository
uses: actions/checkout@v4
with:
repository: ${{ env.githubRepository }}
ref: ${{ inputs.ref || github.ref_name }}
token: ${{ env.REPO_READONLY_PAT }}
- name: Download frontend artifact
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: code/frontend/dist/ui/browser
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.200
- name: Restore .NET dependencies
run: |
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
- name: Copy frontend to backend wwwroot
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path "code/backend/${{ env.executableName }}/wwwroot"
Copy-Item -Path "code/frontend/dist/ui/browser/*" -Destination "code/backend/${{ env.executableName }}/wwwroot/" -Recurse -Force
- name: Build Windows executable
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: Setup Inno Setup
shell: pwsh
run: |
# Download and install Inno Setup
$url = "https://jrsoftware.org/download.php/is.exe"
$output = "innosetup-installer.exe"
Invoke-WebRequest -Uri $url -OutFile $output
Start-Process -FilePath $output -ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART" -Wait
# Add Inno Setup to PATH
$innoPath = "C:\Program Files (x86)\Inno Setup 6"
echo "$innoPath" >> $env:GITHUB_PATH
- name: Verify LICENSE file exists
shell: pwsh
run: |
if (-not (Test-Path "LICENSE")) {
Write-Error "LICENSE file not found in repository root"
exit 1
}
Write-Host "LICENSE file found successfully"
- name: Build Windows installer
shell: pwsh
run: |
# Copy installer script to root
Copy-Item "installers/windows/cleanuparr-installer.iss" -Destination "cleanuparr-installer.iss"
# The installer script has been pre-updated with proper icon and config paths
# No dynamic modifications needed as the base script now includes correct references
# Run Inno Setup compiler
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "cleanuparr-installer.iss"
# Check if installer was created
if (Test-Path "installer/Cleanuparr_Setup.exe") {
Write-Host "Installer created successfully"
} else {
Write-Error "Installer creation failed"
exit 1
}
- name: Rename installer with version
shell: pwsh
run: |
$installerName = "Cleanuparr-${{ env.appVersion }}-Setup.exe"
Move-Item "installer/Cleanuparr_Setup.exe" "installer/$installerName"
echo "installerName=$installerName" >> $env:GITHUB_ENV
- name: Upload installer artifact
uses: actions/upload-artifact@v4
with:
name: Cleanuparr-windows-installer
path: installer/${{ env.installerName }}
retention-days: 30
# Removed individual release step - handled by main release workflow
-12
View File
@@ -1,12 +0,0 @@
on:
workflow_dispatch:
workflow_call:
jobs:
build:
uses: flmorg/universal-workflows/.github/workflows/dotnet.build.app.yml@main
with:
dockerRepository: flaminel/cleanuperr
githubContext: ${{ toJSON(github) }}
outputName: cleanuperr
secrets: inherit
@@ -0,0 +1,40 @@
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
paths:
- 'Cloudflare/**'
- 'blacklist'
- 'blacklist_permissive'
- 'whitelist'
- 'whitelist_with_subtitles'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy to Cloudflare Pages
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Create directory for static files
run: |
mkdir -p Cloudflare/static
- name: Copy root static files to Cloudflare static directory
run: |
cp blacklist Cloudflare/static/
cp blacklist_permissive Cloudflare/static/
cp whitelist Cloudflare/static/
cp whitelist_with_subtitles Cloudflare/static/
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
workingDirectory: "Cloudflare"
command: pages deploy . --project-name=cleanuparr
@@ -0,0 +1,30 @@
name: Deploy to Cloudflare Pages
on:
push:
tags:
- "v*.*.*"
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy to Cloudflare Pages
steps:
- name: Create status files
run: |
mkdir -p status
echo "{ \"version\": \"${GITHUB_REF_NAME}\" }" > status/status.json
# Cache static files for 10 minutes
cat > status/_headers << 'EOF'
/*
Cache-Control: public, max-age=600, s-maxage=600
EOF
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
workingDirectory: "status"
command: pages deploy . --project-name=cleanuparr-status
+45
View File
@@ -0,0 +1,45 @@
name: Dependency Review
on:
pull_request:
branches:
- main
# Cancel in-progress runs for the same PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Dependency Review
uses: actions/dependency-review-action@v4
with:
# Fail on critical and high severity vulnerabilities
fail-on-severity: high
# Warn on moderate vulnerabilities
warn-on-severity: moderate
# Allow licenses
# allow-licenses: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD
# Comment summarizes the vulnerabilities found
comment-summary-in-pr: on-failure
# Show dependency changes in PR
show-openssf-scorecard: true
vulnerability-check: true
- name: Upload dependency review results
uses: actions/upload-artifact@v4
with:
name: dependency-review-results
path: dependency-review-*.json
if-no-files-found: ignore
retention-days: 30
-19
View File
@@ -1,19 +0,0 @@
on:
workflow_call:
workflow_dispatch:
push:
paths:
- 'chart/**'
branches: [ main ]
jobs:
deploy:
uses: flmorg/universal-workflows/.github/workflows/chart.install.yml@main
with:
githubContext: ${{ toJSON(github) }}
chartRepo: oci://ghcr.io/flmorg
chartName: universal-chart
version: ^1.0.0
valuesPath: chart/values.yaml
releaseName: main
secrets: inherit
+5 -4
View File
@@ -2,9 +2,9 @@ name: Deploy Docusaurus to GitHub Pages
on:
push:
branches: [main]
paths:
- 'docs/**'
tags:
- "v*.*.*"
workflow_dispatch: {}
permissions:
contents: read
@@ -22,11 +22,12 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: main
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
node-version: 24.x
cache: yarn
cache-dependency-path: docs/yarn.lock
+105
View File
@@ -0,0 +1,105 @@
name: E2E Tests
on:
push:
branches:
- main
paths:
- 'code/**'
- 'e2e/**'
- '.github/workflows/e2e.yml'
pull_request:
paths:
- 'code/**'
- 'e2e/**'
- '.github/workflows/e2e.yml'
workflow_call:
concurrency:
group: E2E Tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
suite:
- name: api
make-target: up-api
- name: download-clients
make-target: up-dc
name: e2e (${{ matrix.suite.name }})
steps:
- name: Checkout repository
uses: actions/checkout@v4
timeout-minutes: 1
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github packages_pat | PACKAGES_PAT
- name: Start services
working-directory: e2e
run: make ${{ matrix.suite.make-target }}
env:
PACKAGES_USERNAME: ${{ github.repository_owner }}
PACKAGES_PAT: ${{ env.PACKAGES_PAT }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install E2E dependencies
working-directory: e2e
run: npm ci
- name: Install Playwright browsers
working-directory: e2e
run: npx playwright install --with-deps chromium
- name: Wait for Keycloak
run: |
echo "Waiting for Keycloak realm to be ready..."
timeout 120 bash -c 'until curl -sf http://localhost:8080/realms/cleanuparr-test/.well-known/openid-configuration; do sleep 3; done'
echo "Keycloak ready!"
- name: Wait for app
run: |
echo "Waiting for Cleanuparr to be ready..."
timeout 120 bash -c 'until curl -sf http://localhost:5000/health; do sleep 3; done'
echo "App ready!"
- name: Run E2E tests
working-directory: e2e
run: npx playwright test --project=${{ matrix.suite.name }}
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-test-results-${{ matrix.suite.name }}
path: |
e2e/playwright-report/
e2e/test-results/
retention-days: 7
- name: Stop services
if: always()
working-directory: e2e
run: docker compose -f docker-compose.e2e.yml down
-20
View File
@@ -1,20 +0,0 @@
on:
push:
tags:
- "v*.*.*"
# paths:
# - 'code/**'
# branches: [ main ]
pull_request:
paths:
- 'code/**'
jobs:
build:
uses: flmorg/cleanuperr/.github/workflows/build.yml@main
secrets: inherit
# deploy:
# needs: [ build ]
# uses: flmorg/cleanuperr/.github/workflows/deploy.yml@main
# secrets: inherit
+181
View File
@@ -0,0 +1,181 @@
name: PR Build (Comment Triggered)
on:
issue_comment:
types: [created]
concurrency:
group: pr-build-${{ github.event.issue.number }}
cancel-in-progress: true
permissions:
issues: write
pull-requests: write
actions: read
jobs:
validate:
runs-on: ubuntu-latest
if: github.event.issue.pull_request != null
outputs:
build_windows: ${{ steps.parse.outputs.build_windows }}
pr_ref: ${{ steps.parse.outputs.pr_ref }}
pr_sha: ${{ steps.parse.outputs.pr_sha }}
pr_number: ${{ steps.parse.outputs.pr_number }}
steps:
- name: Parse command and check permissions
id: parse
uses: actions/github-script@v7
with:
script: |
const comment = context.payload.comment.body.trim();
// Parse supported commands
const commands = {
'/build-windows': 'build_windows'
};
const command = commands[comment];
if (!command) {
console.log(`Comment "${comment}" is not a recognized build command, skipping.`);
core.setOutput('build_windows', 'false');
return;
}
try {
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'eyes'
});
} catch (e) {
console.log(`Could not add reaction: ${e}`);
}
// Fetch PR details
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
// Verify PR is open
if (pr.data.state !== 'open') {
console.log('PR is not open, skipping.');
core.setOutput('build_windows', 'false');
return;
}
// Block fork PRs — fork code should not run with access to secrets
const isFork = pr.data.head.repo.full_name !== context.repo.owner + '/' + context.repo.repo;
if (isFork) {
console.log(`PR is from fork ${pr.data.head.repo.full_name}, blocking build.`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'On-demand builds are not available for PRs from forks.'
});
core.setOutput('build_windows', 'false');
return;
}
// Verify commenter has write access
let permission = 'none';
try {
const resp = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.comment.user.login
});
permission = resp.data.permission;
} catch (_) {}
if (!['admin', 'write'].includes(permission)) {
console.log(`User ${context.payload.comment.user.login} has '${permission}' permission — insufficient.`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `@${context.payload.comment.user.login} Only collaborators with write access can trigger builds.`
});
core.setOutput('build_windows', 'false');
return;
}
console.log(`User ${context.payload.comment.user.login} has '${permission}' permission — proceeding with ${command}.`);
core.setOutput(command, 'true');
// Export PR details for downstream jobs
core.setOutput('pr_ref', pr.data.head.ref);
core.setOutput('pr_sha', pr.data.head.sha);
core.setOutput('pr_number', String(pr.data.number));
build-frontend:
needs: validate
if: needs.validate.outputs.build_windows == 'true'
uses: ./.github/workflows/build-frontend.yml
with:
ref: ${{ needs.validate.outputs.pr_ref }}
secrets: inherit
build-windows:
needs: [validate, build-frontend]
if: needs.validate.outputs.build_windows == 'true'
uses: ./.github/workflows/build-windows-installer.yml
with:
ref: ${{ needs.validate.outputs.pr_ref }}
secrets: inherit
post-result:
needs: [validate, build-windows]
if: always() && needs.validate.outputs.build_windows == 'true'
runs-on: ubuntu-latest
steps:
- name: Post result comment
uses: actions/github-script@v7
with:
script: |
const buildResult = '${{ needs.build-windows.result }}';
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const prRef = '${{ needs.validate.outputs.pr_ref }}';
const prSha = '${{ needs.validate.outputs.pr_sha }}';
const shortSha = prSha.substring(0, 7);
// Skip comment for skipped builds
if (buildResult === 'skipped') {
console.log('Build was skipped, no comment needed.');
return;
}
let body;
if (buildResult === 'success') {
body = [
`Windows installer build **succeeded** for \`${prRef}\` (\`${shortSha}\`).`,
``,
`**Download:** open the [workflow run](${runUrl}), scroll to the **Artifacts** section at the bottom.`,
`The artifact \`Cleanuparr-windows-installer\` is retained for 30 days.`
].join('\n');
} else if (buildResult === 'cancelled') {
body = [
`Windows installer build was **cancelled** for \`${prRef}\` (\`${shortSha}\`).`,
``,
`See the [workflow run](${runUrl}) for details.`
].join('\n');
} else {
body = [
`Windows installer build **failed** for \`${prRef}\` (\`${shortSha}\`).`,
``,
`See the [workflow run](${runUrl}) for details.`
].join('\n');
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt('${{ needs.validate.outputs.pr_number }}'),
body
});
+293 -4
View File
@@ -1,11 +1,300 @@
name: Release Build
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., 1.0.0)'
required: true
runTests:
description: 'Run test suite'
type: boolean
required: false
default: true
buildDocker:
description: 'Build Docker image'
type: boolean
required: false
default: true
pushDocker:
description: 'Push Docker image to registry'
type: boolean
required: false
default: false
buildBinaries:
description: 'Build executables and installers'
type: boolean
required: false
default: true
createRelease:
description: 'Create GitHub release'
type: boolean
required: false
default: false
jobs:
release:
uses: flmorg/universal-workflows/.github/workflows/dotnet.release.yml@main
# Validate release
validate:
runs-on: ubuntu-latest
outputs:
app_version: ${{ steps.version.outputs.app_version }}
release_version: ${{ steps.version.outputs.release_version }}
is_tag: ${{ steps.version.outputs.is_tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Get version info
id: version
run: |
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
# Tag event
release_version=${GITHUB_REF##refs/tags/}
app_version=${release_version#v}
is_tag=true
else
# Manual workflow with version
app_version="${{ github.event.inputs.version }}"
# Validate version format (x.x.x)
if ! [[ "$app_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Version must be in format x.x.x (e.g., 1.0.0)"
echo "Provided version: $app_version"
exit 1
fi
release_version="v$app_version"
is_tag=false
fi
echo "app_version=$app_version" >> $GITHUB_OUTPUT
echo "release_version=$release_version" >> $GITHUB_OUTPUT
echo "is_tag=$is_tag" >> $GITHUB_OUTPUT
echo "Release Version: $release_version"
echo "App Version: $app_version"
echo "Is Tag: $is_tag"
- name: Check if release already exists
run: |
if gh release view "${{ steps.version.outputs.release_version }}" &>/dev/null; then
echo "❌ Release ${{ steps.version.outputs.release_version }} already exists. Stopping workflow."
exit 1
fi
echo "✅ Release ${{ steps.version.outputs.release_version }} does not exist. Proceeding."
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Run tests
test:
needs: validate
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
uses: ./.github/workflows/test.yml
secrets: inherit
# Run E2E tests
e2e:
needs: validate
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
uses: ./.github/workflows/e2e.yml
secrets: inherit
# Build frontend once for all build jobs and cache it
build-frontend:
needs: [validate, test, e2e]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-frontend.yml
secrets: inherit
# Build portable executables
build-executables:
needs: [validate, test, e2e, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-executable.yml
with:
githubContext: ${{ toJSON(github) }}
secrets: inherit
app_version: ${{ needs.validate.outputs.app_version }}
secrets: inherit
# Build Windows installer
build-windows-installer:
needs: [validate, test, e2e, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-windows-installer.yml
with:
app_version: ${{ needs.validate.outputs.app_version }}
secrets: inherit
# Build macOS installers (Intel and ARM)
build-macos:
needs: [validate, test, e2e, build-frontend]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
needs.build-frontend.result == 'success' &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
uses: ./.github/workflows/build-macos-installer.yml
with:
app_version: ${{ needs.validate.outputs.app_version }}
secrets: inherit
# Build and push Docker image(s)
build-docker:
needs: [validate, test, e2e]
if: |
always() &&
needs.validate.result == 'success' &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildDocker == 'true')
uses: ./.github/workflows/build-docker.yml
with:
push_docker: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.pushDocker == 'true' }}
app_version: ${{ needs.validate.outputs.app_version }}
secrets: inherit
# Create GitHub release
create-release:
needs: [validate, build-executables, build-windows-installer, build-macos]
runs-on: ubuntu-latest
if: |
always() &&
needs.validate.result == 'success' &&
needs.build-executables.result == 'success' &&
needs.build-windows-installer.result == 'success' &&
needs.build-macos.result == 'success' &&
(
needs.validate.outputs.is_tag == 'true' ||
(github.event.inputs.createRelease == 'true' && github.event.inputs.buildBinaries == 'true')
)
steps:
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
- name: Download executable artifacts
uses: actions/download-artifact@v4
with:
pattern: executable-*
path: ./artifacts
merge-multiple: true
- name: Download Windows installer
uses: actions/download-artifact@v4
with:
name: Cleanuparr-windows-installer
path: ./artifacts
- name: Download macOS installers
uses: actions/download-artifact@v4
with:
pattern: Cleanuparr-macos-*-installer
path: ./artifacts
merge-multiple: true
- name: List downloaded artifacts
run: |
echo "Downloaded artifacts:"
find ./artifacts -type f \( -name "*.zip" -o -name "*.pkg" -o -name "*.exe" \) | sort
echo ""
echo "Total files: $(find ./artifacts -type f \( -name "*.zip" -o -name "*.pkg" -o -name "*.exe" \) | wc -l)"
- name: Create release
uses: softprops/action-gh-release@v2
with:
name: ${{ needs.validate.outputs.release_version }}
tag_name: ${{ needs.validate.outputs.release_version }}
token: ${{ env.REPO_READONLY_PAT }}
make_latest: true
target_commitish: main
generate_release_notes: true
files: |
./artifacts/*.zip
./artifacts/*.pkg
./artifacts/*.exe
# Summary job
summary:
needs: [validate, test, e2e, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
runs-on: ubuntu-latest
if: always()
steps:
- name: Record workflow start time
id: workflow-start
run: |
# Get workflow start time from GitHub API
workflow_start=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }} --jq '.run_started_at')
start_epoch=$(date -d "$workflow_start" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$workflow_start" +%s)
echo "start=$start_epoch" >> $GITHUB_OUTPUT
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Summary
run: |
# Calculate total workflow duration
start_time=${{ steps.workflow-start.outputs.start }}
end_time=$(date +%s)
duration=$((end_time - start_time))
minutes=$((duration / 60))
seconds=$((duration % 60))
echo "## 🏗️ Cleanuparr Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version**: ${{ needs.validate.outputs.release_version }}" >> $GITHUB_STEP_SUMMARY
echo "**App Version**: ${{ needs.validate.outputs.app_version }}" >> $GITHUB_STEP_SUMMARY
echo "**Is Tag**: ${{ needs.validate.outputs.is_tag }}" >> $GITHUB_STEP_SUMMARY
echo "**Total Duration**: ${minutes}m ${seconds}s" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Build Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Helper function to print job result
print_result() {
local name="$1"
local result="$2"
case "$result" in
success) echo "✅ **$name**: Success" >> $GITHUB_STEP_SUMMARY ;;
skipped) echo "⏭️ **$name**: Skipped" >> $GITHUB_STEP_SUMMARY ;;
*) echo "❌ **$name**: $result" >> $GITHUB_STEP_SUMMARY ;;
esac
}
print_result "Tests" "${{ needs.test.result }}"
print_result "E2E Tests" "${{ needs.e2e.result }}"
print_result "Frontend Build" "${{ needs.build-frontend.result }}"
print_result "Portable Executables" "${{ needs.build-executables.result }}"
print_result "Windows Installer" "${{ needs.build-windows-installer.result }}"
print_result "macOS Installers (Intel & ARM)" "${{ needs.build-macos.result }}"
print_result "Docker Image Build" "${{ needs.build-docker.result }}"
echo "" >> $GITHUB_STEP_SUMMARY
echo "🎉 **Build completed!**" >> $GITHUB_STEP_SUMMARY
+99
View File
@@ -0,0 +1,99 @@
name: Tests
on:
push:
branches:
- main
paths:
- 'code/backend/**'
- '.github/workflows/test.yml'
pull_request:
paths:
- 'code/backend/**'
- '.github/workflows/test.yml'
workflow_call:
# Cancel in-progress runs for the same PR
concurrency:
group: Tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
timeout-minutes: 1
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.200
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Get vault secrets
uses: hashicorp/vault-action@v2
with:
url: ${{ secrets.VAULT_HOST }}
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets:
secrets/data/github packages_pat | PACKAGES_PAT
- name: Restore dependencies
run: |
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
dotnet restore code/backend/cleanuparr.sln
- name: Build solution
run: dotnet build code/backend/cleanuparr.sln --configuration Release --no-restore
- name: Run tests
id: run-tests
run: dotnet test code/backend/cleanuparr.sln --configuration Release --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" --settings code/backend/coverage.runsettings --results-directory ./coverage
- name: Upload test results
uses: actions/upload-artifact@v4
with:
name: test-results
path: ./coverage/*.trx
retention-days: 30
- name: Upload coverage reports
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: ./coverage/**/coverage.cobertura.xml
retention-days: 30
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: ./coverage/**/coverage.cobertura.xml
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
flags: backend
name: backend-coverage
- name: Test Summary
run: |
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.run-tests.outcome }}" == "success" ]; then
echo "✅ All tests passed!" >> $GITHUB_STEP_SUMMARY
else
echo "❌ Tests failed or were cancelled. Status: ${{ steps.run-tests.outcome }}" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "Test artifacts have been uploaded for detailed analysis." >> $GITHUB_STEP_SUMMARY
+66
View File
@@ -0,0 +1,66 @@
name: Get Version Info
on:
workflow_call:
inputs:
manual_version:
description: 'Manual version override (e.g., 1.0.0)'
required: false
type: string
default: ''
outputs:
app_version:
description: 'Application version (without v prefix)'
value: ${{ jobs.version.outputs.app_version }}
release_version:
description: 'Release version (with v prefix)'
value: ${{ jobs.version.outputs.release_version }}
is_tag:
description: 'Whether this is a tag event'
value: ${{ jobs.version.outputs.is_tag }}
repository_name:
description: 'Repository name without owner'
value: ${{ jobs.version.outputs.repository_name }}
jobs:
version:
runs-on: ubuntu-latest
outputs:
app_version: ${{ steps.version.outputs.app_version }}
release_version: ${{ steps.version.outputs.release_version }}
is_tag: ${{ steps.version.outputs.is_tag }}
repository_name: ${{ steps.version.outputs.repository_name }}
steps:
- name: Calculate version info
id: version
run: |
repoFullName="${{ github.repository }}"
repositoryName="${repoFullName#*/}"
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
# Tag event
release_version="${GITHUB_REF##refs/tags/}"
app_version="${release_version#v}"
is_tag="true"
elif [[ -n "${{ inputs.manual_version }}" ]]; then
# Manual workflow with version
app_version="${{ inputs.manual_version }}"
release_version="v${app_version}"
is_tag="false"
else
# Development build
app_version="0.0.1-dev-$(date +%Y%m%d-%H%M%S)"
release_version="v${app_version}"
is_tag="false"
fi
echo "app_version=${app_version}" >> $GITHUB_OUTPUT
echo "release_version=${release_version}" >> $GITHUB_OUTPUT
echo "is_tag=${is_tag}" >> $GITHUB_OUTPUT
echo "repository_name=${repositoryName}" >> $GITHUB_OUTPUT
echo "📦 Repository: ${repositoryName}"
echo "🏷️ Release Version: ${release_version}"
echo "📱 App Version: ${app_version}"
echo "🔖 Is Tag: ${is_tag}"
+189
View File
@@ -0,0 +1,189 @@
# Cleanuparr - Claude AI Rules
## Rules
1. **DO NOT break existing functionality** - All features are critical and must continue to work
2. **When in doubt, ASK** - Don't assume, clarify with the maintainer first
3. **Always read existing code before making changes** - Understand the current architecture and patterns
4. **Follow existing patterns** - Study the codebase style and match it exactly
5. **Ask before introducing new patterns** - Use current coding standards or get approval first
6. **Prefer editing existing files over creating new ones** - Build on existing work
7. **Flag potential gotchas or issues immediately** - Document and report anything unexpected
8. **If unsure about an approach, ask before implementing**
## Project Overview
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients (qBittorrent, Transmission, Deluge, uTorrent, rTorrent). It provides malware protection, automated cleanup, and queue management for *arr applications.
**Key Features:**
- Strike system for bad downloads
- Malware detection and blocking
- Automatic search triggering after removal (Seeker)
- Missing and upgrade search
- Orphaned download cleanup with cross-seed support
- Authentication (OIDC, 2FA)
- Notification providers (Apprise, Discord, Gotify, Notifiarr, Ntfy, Pushover, Telegram)
## Architecture & Tech Stack
### Backend
- **.NET 10.0** (C#) with ASP.NET Core
- **Architecture**: Clean Architecture with `Features/` subdirectory pattern
- `Cleanuparr.Api` - REST API and web host (`Features/` for endpoint groups)
- `Cleanuparr.Application` - Application services and use cases
- `Cleanuparr.Domain` - Domain models (Entities, Enums, Exceptions)
- `Cleanuparr.Infrastructure` - External integrations (`Features/` for Arr, DownloadClient, Notifications, etc.)
- `Cleanuparr.Persistence` - Data access with EF Core (SQLite)
- `Cleanuparr.Shared` - Shared utilities
- **Database**: SQLite with Entity Framework Core
- Three separate contexts: `DataContext`, `EventsContext`, `UsersContext`
- **Key Libraries**:
- MassTransit (messaging)
- Quartz.NET (scheduling)
- Serilog (logging)
- SignalR (real-time communication)
- **Testing**: xUnit + NSubstitute + Shouldly
- Always use **NSubstitute** for mocking in new tests (Moq is being phased out)
### Frontend
- **Angular 21** with TypeScript 5.9 (standalone components, zoneless, OnPush)
- **UI**: Custom glassmorphism design system with 33 custom components — no external UI frameworks
- **Icons**: @ng-icons/core + @ng-icons/tabler-icons
- **Design System**: 3-layer SCSS (`_variables` -> `_tokens` -> `_themes`), dark/light themes
- **State Management**: @ngrx/signals (Angular signals-based)
- **Real-time Updates**: @microsoft/signalr 10.0.0
- **PWA**: Service Worker support enabled
## Project Structure
```
Cleanuparr/
├── code/
│ ├── backend/
│ │ ├── Cleanuparr.Api/ # REST API (Features/ for endpoint groups)
│ │ ├── Cleanuparr.Api.Tests/ # API layer tests
│ │ ├── Cleanuparr.Application/ # Business logic layer
│ │ ├── Cleanuparr.Domain/ # Domain models
│ │ ├── Cleanuparr.Infrastructure/ # External integrations (Features/ subdirs)
│ │ ├── Cleanuparr.Infrastructure.Tests/
│ │ ├── Cleanuparr.Persistence/ # SQLite data access
│ │ ├── Cleanuparr.Persistence.Tests/
│ │ └── Cleanuparr.Shared/ # Shared utilities
│ ├── frontend/ # Angular 21 application
│ ├── e2e/ # Playwright E2E tests
│ ├── Dockerfile # Multi-stage Docker build
│ ├── entrypoint.sh # Docker entrypoint
│ └── Makefile # Build & migration helpers
├── docs/ # Docusaurus documentation
├── .github/workflows/ # CI/CD pipelines
├── blacklist # Default malware patterns (strict)
├── blacklist_permissive # Less strict malware patterns
├── whitelist # Safe file extensions
└── whitelist_with_subtitles # Includes subtitle formats
```
## Code Standards & Conventions
**IMPORTANT:** Always study existing code in the relevant area before making changes. Match the existing style exactly.
### Backend (C#)
- Follow Microsoft C# Coding Conventions
- Use nullable reference types (`<Nullable>enable</Nullable>`)
- Add XML documentation comments for public APIs
- Use meaningful names - avoid abbreviations unless widely understood
- Keep services focused - single responsibility principle
- New integrations go under `Features/` subdirectories (e.g., `Infrastructure/Features/Arr/`)
### Frontend (TypeScript/Angular)
- All components must be **standalone** with **ChangeDetectionStrategy.OnPush**
- Use `input()` / `output()` function APIs (not `@Input()` / `@Output()` decorators)
- Use Angular **signals** for reactive state (`signal()`, `computed()`, `effect()`)
- Follow the 3-layer SCSS design system (`_variables` -> `_tokens` -> `_themes`)
- **Do not introduce external UI frameworks** (no PrimeNG, Material, Tailwind, etc.)
- Component naming: `{feature}.component.ts`
- Service naming: `{feature}.service.ts`
- **Look at similar existing components before creating new ones**
### Testing
- **Backend**: xUnit + NSubstitute + Shouldly
- Always use **NSubstitute** for mocking (Moq is being phased out)
- Write unit tests for new features and bug fixes
- Use descriptive test names that explain what is being tested
- No frontend unit tests currently
### Git Commit Messages
- Use clear, descriptive messages in imperative mood
- Examples: "Add Discord notification support", "Fix memory leak in download client polling"
- Reference issue numbers when applicable: "Fix #123: Handle null response from Radarr API"
## Development Setup
### Running the Backend
```bash
cd code/backend
dotnet build Cleanuparr.Api/Cleanuparr.Api.csproj
dotnet run --project Cleanuparr.Api/Cleanuparr.Api.csproj
```
API runs at http://localhost:5000
### Running the Frontend
```bash
cd code/frontend
npm install
npm start
```
UI runs at http://localhost:4200
### Running Tests
```bash
cd code/backend
dotnet test
```
## Database Migrations
Three separate database contexts, all commands run from the `code` directory:
```bash
# Data migrations (DataContext)
make migrate-data name=YourMigrationName
# Events migrations (EventsContext)
make migrate-events name=YourMigrationName
# Users migrations (UsersContext)
make migrate-users name=YourMigrationName
```
## Common Development Workflows
### Adding a New *arr Application Integration
1. Add integration in `Cleanuparr.Infrastructure/Features/Arr/`
2. Update domain models in `Cleanuparr.Domain/`
3. Create/update services in `Cleanuparr.Application/`
4. Add API endpoints in `Cleanuparr.Api/Features/Arr/`
5. Update frontend in `code/frontend/src/app/`
### Adding a New Download Client
1. Add client implementation in `Cleanuparr.Infrastructure/Features/DownloadClient/`
2. Follow existing patterns (qBittorrent, Transmission, etc.)
3. Add configuration models to `Cleanuparr.Domain/`
4. Update API and frontend as above
### Adding a New Notification Provider
1. Add provider in `Cleanuparr.Infrastructure/Features/Notifications/`
2. Update configuration models
3. Add UI configuration in frontend
## Key Gotchas
- **Custom glassmorphism design system** - Do not introduce external UI frameworks (no PrimeNG, Material, Tailwind)
- **All frontend components** must be standalone with OnPush change detection
- **Database migrations** require awareness of all three contexts (Data, Events, Users)
- **Malware blocker** is a critical security feature - changes require careful testing
- **Cross-seed integration** allows keeping torrents that are actively seeding
- **Real-time updates** use SignalR - maintain websocket patterns when adding features
- Use `@ng-icons/core` + `@ng-icons/tabler-icons` for icons (NOT `angular-tabler-icons` which doesn't support Angular 21)
- **Sidebar** stays dark purple in both themes - uses sidebar-specific CSS variables
- The project uses **Clean Architecture** - respect layer boundaries
- **Settings dirty tracking** uses JSON snapshot comparison (`buildSnapshot()` + `hasPendingChanges()`)
+332
View File
@@ -0,0 +1,332 @@
# Contributing to Cleanuparr
Thanks for your interest in contributing to Cleanuparr! This guide will help you get started with development.
## Before You Start
### AI usage
In this ever-evolving field of work, AI is now the shiny new tool to help programmers work faster and there's nothing wrong with that.
But it is **very** wrong to rely solely on AI tools to write, review and test your code.
**If you do not have a background in programming and you do not intend to test your code properly, please do not submit AI-generated code.** If you still want to help in other ways such as testing features, that would also help a lot!
### Announce Your Intent
Before starting any work, please let us know what you want to contribute:
- For existing issues: Comment on the issue stating you'd like to work on it
- For new features/changes: Create a new issue first and mention that you want to work on it
This helps us avoid redundant work, git conflicts, and contributions that may not align with the project's direction.
**Wait for approval from the maintainers before proceeding with your contribution.**
## Development Setup
### Prerequisites
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- [Node.js 18+](https://nodejs.org/)
- [Git](https://git-scm.com/)
- (Optional) [Make](https://www.gnu.org/software/make/) for database migrations
- (Optional) IDE: [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio](https://visualstudio.microsoft.com/)
### Repository Setup
1. Fork the repository on GitHub
2. Clone your fork locally:
```bash
git clone https://github.com/YOUR_USERNAME/Cleanuparr.git
cd Cleanuparr
```
3. Add the upstream repository:
```bash
git remote add upstream https://github.com/Cleanuparr/Cleanuparr.git
```
## Backend Development
### Initial Setup
#### 1. Create a GitHub Personal Access Token (PAT)
Cleanuparr uses GitHub Packages for NuGet dependencies. You'll need a PAT with `read:packages` permission:
1. Go to [GitHub Settings > Developer Settings > Personal Access Tokens > Tokens (classic)](https://github.com/settings/tokens)
2. Click "Generate new token" → "Generate new token (classic)"
3. Give it a descriptive name (e.g., "Cleanuparr NuGet Access")
4. Set an expiration (recommend 90 days or longer for development)
5. Select only the `read:packages` scope
6. Click "Generate token" and copy it
#### 2. Configure NuGet Source
Add the Cleanuparr NuGet repository:
```bash
dotnet nuget add source \
--username YOUR_GITHUB_USERNAME \
--password YOUR_GITHUB_PAT \
--store-password-in-clear-text \
--name Cleanuparr \
https://nuget.pkg.github.com/Cleanuparr/index.json
```
Replace `YOUR_GITHUB_USERNAME` and `YOUR_GITHUB_PAT` with your GitHub username and the PAT you created.
### Running the Backend
#### Option 1: Using .NET CLI
Navigate to the backend directory:
```bash
cd code/backend
```
Build the application:
```bash
dotnet build Cleanuparr.Api/Cleanuparr.Api.csproj
```
Run the application:
```bash
dotnet run --project Cleanuparr.Api/Cleanuparr.Api.csproj
```
Run tests:
```bash
dotnet test
```
The API will be available at http://localhost:5000
#### Option 2: Using an IDE
For JetBrains Rider or Visual Studio:
1. Open the solution file: `code/backend/cleanuparr.sln`
2. Set `Cleanuparr.Api` as the startup project
3. Press `F5` to start the application
### Database Migrations
Cleanuparr uses two separate database contexts: `DataContext` and `EventsContext`.
#### Prerequisites
Install Make if not already installed:
- Windows: Install via [Chocolatey](https://chocolatey.org/) (`choco install make`) or use [WSL](https://docs.microsoft.com/windows/wsl/)
- macOS: Install via Homebrew (`brew install make`)
- Linux: Usually pre-installed, or install via package manager (`apt install make`, `yum install make`, etc.)
#### Creating Migrations
From the `code` directory:
For data migrations (DataContext):
```bash
make migrate-data name=YourMigrationName
```
For events migrations (EventsContext):
```bash
make migrate-events name=YourMigrationName
```
Example:
```bash
make migrate-data name=AddUserPreferences
make migrate-events name=AddAuditLogEvents
```
## Frontend Development
### Setup
1. Navigate to the frontend directory:
```bash
cd code/frontend
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm start
```
The UI will be available at http://localhost:4200
## Documentation Development
### Setup
1. Navigate to the docs directory:
```bash
cd docs
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm start
```
The documentation site will be available at http://localhost:3000
## Building with Docker
### Building a Local Docker Image
To build the Docker image locally for testing:
1. Navigate to the `code` directory:
```bash
cd code
```
2. Build the image:
```bash
docker build \
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
-t cleanuparr:local \
-f Dockerfile .
```
Replace `YOUR_GITHUB_USERNAME` and `YOUR_GITHUB_PAT` with your credentials.
3. Run the container:
```bash
docker run -d \
--name cleanuparr-dev \
-p 11011:11011 \
-v /path/to/config:/config \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=Etc/UTC \
cleanuparr:local
```
4. Access the application at http://localhost:11011
### Building for Multiple Architectures
Use Docker Buildx for multi-platform builds:
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
-t cleanuparr:local \
-f Dockerfile .
```
## Code Standards
### Backend (.NET/C#)
- Follow existing conventions and [Microsoft C# Coding Conventions](https://docs.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
- Use meaningful variable and method names
- Add XML documentation comments for public APIs
- Write unit tests whenever possible
### Frontend (Angular/TypeScript)
- Follow existing conventions and the [Angular Style Guide](https://angular.io/guide/styleguide)
- Use TypeScript strict mode
- Write unit tests whenever possible
### Documentation
- Use clear, concise language
- Include code examples where appropriate
- Update relevant documentation when adding/changing features
- Check for spelling and grammar
## Submitting Your Contribution
### 1. Create a Feature Branch
```bash
git checkout -b feature/your-feature-name
# or
git checkout -b fix/your-bug-fix-name
```
### 2. Make Your Changes
- Write clean, well-documented code
- Follow the code standards outlined above
- **Test your changes thoroughly!**
### 3. Commit Your Changes
Write clear, descriptive commit messages:
```bash
git add .
git commit -m "Add feature: brief description of your changes"
```
### 4. Keep Your Branch Updated
```bash
git fetch upstream
git rebase upstream/main
```
### 5. Push to Your Fork
```bash
git push origin feature/your-feature-name
```
### 6. Create a Pull Request
1. Go to the [Cleanuparr repository](https://github.com/Cleanuparr/Cleanuparr)
2. Click "New Pull Request"
3. Select your fork and branch
4. Fill out the PR template with:
- A descriptive title (e.g., "Add support for Prowlarr integration" or "Fix memory leak in download client polling")
- Description of changes
- Related issue number
- Testing performed
- Screenshots (if applicable)
### 7. Code Review Process
- Maintainers will review your PR
- Address any feedback or requested changes
- Once approved, your PR will be merged
## Other Ways to Contribute
### Help Test New Features
We're always looking for testers to help validate new features before they are released. If you'd like to help test upcoming changes:
1. Join our [Discord community](https://discord.gg/SCtMCgtsc4)
2. Let us know you're interested in testing
3. We'll provide you with pre-release builds and testing instructions
Your feedback helps us catch issues early and deliver better releases.
## Getting Help
- Discord: Join our [Discord community](https://discord.gg/SCtMCgtsc4) for real-time help
- Issues: Check existing [GitHub issues](https://github.com/Cleanuparr/Cleanuparr/issues) or create a new one
- Documentation: Review the [complete documentation](https://cleanuparr.github.io/Cleanuparr/)
## License
By contributing to Cleanuparr, you agree that your contributions will be licensed under the same license as the project.
---
Thanks for contributing to Cleanuparr!
+3
View File
@@ -0,0 +1,3 @@
# Cache static files for 5 minutes
/static/*
Cache-Control: public, max-age=300, s-maxage=300
File diff suppressed because one or more lines are too long.

Before

Width:  |  Height:  |  Size: 112 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+13
View File
File diff suppressed because one or more lines are too long.

After

Width:  |  Height:  |  Size: 69 KiB

+97 -28
View File
@@ -1,50 +1,119 @@
<div align="center">
_Love this project? Give it a ⭐️ and let others know!_
# <img width="24px" src="./Logo/256.png" alt="cleanuperr"></img> Cleanuperr
# <img width="24px" src="./Logo/256.png" alt="Cleanuparr"></img> Cleanuparr
_/kliː.nʌp.ər/ — like "cleanuper", someone who does the cleanup. Not "CleanupArr" or "CleanUpArr"._
![Version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fcleanuparr-status.pages.dev%2Fstatus.json&query=%24.version&logo=git&label=version&color=blue)
![Total Downloads](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2FCleanuparr%2FCleanuparr%2Fcleanuparr&query=%24.downloadCount&style=flat&logo=docker&label=Total%20Downloads&color=blue)
[![Tests](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml)
[![Discord](https://img.shields.io/discord/1306721212587573389?color=7289DA&label=Discord&style=for-the-badge&logo=discord)](https://discord.gg/SCtMCgtsc4)
Cleanuperr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, and supported download clients like qBittorrent. It removes incomplete or blocked downloads, updates queues, and enforces blacklists or whitelists to manage file selection. After removing blocked content, Cleanuperr can also trigger a search to replace the deleted shows/movies.
</div>
Cleanuperr was created primarily to address malicious files, such as `*.lnk` or `*.zipx`, that were getting stuck in Sonarr/Radarr and required manual intervention. Some of the reddit posts that made Cleanuperr come to life can be found [here](https://www.reddit.com/r/sonarr/comments/1gqnx16/psa_sonarr_downloaded_a_virus/), [here](https://www.reddit.com/r/sonarr/comments/1gqwklr/sonar_downloaded_a_mkv_file_which_looked_like_a/), [here](https://www.reddit.com/r/sonarr/comments/1gpw2wa/downloaded_waiting_to_import/) and [here](https://www.reddit.com/r/sonarr/comments/1gpi344/downloads_not_importing_no_files_found/).
Cleanuparr is an advanced download manager for the Servarr ecosystem. It works with Sonarr, Radarr, Lidarr, Readarr, and Whisparr alongside download clients like qBittorrent, Transmission, and Deluge. Beyond cleaning up stalled, blocked, and malicious downloads, it searches for missing content and quality upgrades, manages seeding, and removes orphaned files.
Cleanuparr was created primarily to address malicious files, such as `*.lnk` or `*.zipx`, that were getting stuck in Sonarr/Radarr and required manual intervention. Some of the reddit posts that made Cleanuparr come to life can be found [here](https://www.reddit.com/r/sonarr/comments/1gqnx16/psa_sonarr_downloaded_a_virus/), [here](https://www.reddit.com/r/sonarr/comments/1gqwklr/sonar_downloaded_a_mkv_file_which_looked_like_a/), [here](https://www.reddit.com/r/sonarr/comments/1gpw2wa/downloaded_waiting_to_import/) and [here](https://www.reddit.com/r/sonarr/comments/1gpi344/downloads_not_importing_no_files_found/).
> [!IMPORTANT]
> **Features:**
> - Strike system to mark stalled or downloads stuck in metadata downloading.
> - Strike system to mark bad downloads.
> - Remove and block downloads that reached a maximum number of strikes.
> - Remove and block downloads that have a low download speed or high estimated completion time.
> - Remove downloads blocked by qBittorrent or by Cleanuperr's **content blocker**.
> - Trigger a search for downloads removed from the *arrs.
> - Clean up downloads that have been seeding for a certain amount of time.
> - Remove and block downloads that are **failing to be imported** by the arrs.
> - Remove and block downloads that are **stalled** or in **metadata downloading** state.
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**.
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
> - Remove and block known malware based on patterns found by the community.
> - Automatically trigger a search for downloads removed from the arrs.
> - Proactively search for **missing** items across your Radarr and Sonarr libraries.
> - Search for **quality upgrades** for items that haven't met their quality profile's cutoff (a.k.a. **Cutoff Unmet**).
> - Search for **custom format score upgrades** with automatic score tracking.
> - 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).
> - Scan configured directories for **files not claimed by any active torrent**, move them to a dedicated orphaned directory, and optionally auto-purge.
> - Notify on strike or download removal.
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuperr.
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
Cleanuperr 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.
## Sponsored by GitAds
[![Sponsored by GitAds](https://gitads.dev/v1/ad-serve?source=cleanuparr/cleanuparr@github)](https://gitads.dev/v1/ad-track?source=cleanuparr/cleanuparr@github)
## Quick Start
## Screenshots
> [!NOTE]
>
> 1. **Docker (Recommended)**
> Pull the Docker image from `ghcr.io/flmorg/cleanuperr: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).
https://cleanuparr.github.io/Cleanuparr/docs/screenshots
# Docs
## 🎯 Supported Applications
Docs can be found [here](https://flmorg.github.io/cleanuperr/).
### *Arr Applications (latest version)
- **Sonarr**
- **Radarr**
- **Lidarr**
- **Readarr**
- **Whisparr v2**
- **Whisparr v3**
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuperr"> <span style="vertical-align: middle;">Cleanuperr</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>
### Download Clients (latest version)
- **qBittorrent**
- **Transmission**
- **Deluge**
- **µTorrent**
- **rTorrent**
Think of **Cleanuperr** as the janitor of your server; it keeps your download queue spotless, removes clutter, and blocks malicious files. Now imagine combining that with **Huntarr**, the compulsive librarian who finds missing and upgradable media to complete your collection
### Platforms
- **Docker**
- **Windows**
- **macOS**
- **Linux**
- **Unraid**
While **Huntarr** fills in the blanks and improves what you already have, **Cleanuperr** makes sure that only clean downloads get through. If you're aiming for a reliable and self-sufficient setup, **Cleanuperr** and **Huntarr** will take your automated media stack to another level.
## 🚀 Quick Start
<span style="font-size:24px"> ➡️ [**Huntarr**](https://github.com/plexguide/Huntarr.io) <span style="vertical-align: middle">![Huntarr](https://img.shields.io/github/stars/plexguide/Huntarr.io?style=social)</span></span>
```bash
docker run -d --name cleanuparr \
--restart unless-stopped \
-p 11011:11011 \
-v /path/to/config:/config \
-e PORT=11011 \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=Etc/UTC \
ghcr.io/cleanuparr/cleanuparr:latest
```
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed), but not before reading the [Prerequisites](https://cleanuparr.github.io/Cleanuparr/docs/installation/).
> Prefer not to self-host? A managed Cleanuparr instance is available via [ElfHosted](https://store.elfhosted.com/product-category/personal-stacks/?utm_source=github&utm_medium=readme&utm_campaign=cleanuparr-readme), bundled alongside Sonarr/Radarr to keep your queues tidy (7-day trial).
### 🌐 Access the Web Interface
After installation, open your browser and navigate to:
```
http://localhost:11011
```
**Next Steps:** Check out the [📖 Complete Documentation](https://cleanuparr.github.io/Cleanuparr/) for detailed configuration guides and setup instructions.
## 📖 Documentation & Support
- **📚 [Complete Documentation](https://cleanuparr.github.io/Cleanuparr/)** - Installation guides, configuration, and troubleshooting
- **⚙️ [Configuration Guide](https://cleanuparr.github.io/Cleanuparr/docs/category/configuration)** - Set up download clients, *arr apps, and features
- **🔧 [Setup Scenarios](https://cleanuparr.github.io/Cleanuparr/docs/category/setup-scenarios)** - Common use cases and examples
- **💬 [Discord Community](https://discord.gg/SCtMCgtsc4)** - Get help and discuss with other users
- **🔗 [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases)** - Download binaries and view changelog
## 🤝 Contributing
We welcome contributions from the community! Whether it's bug fixes, new features, documentation improvements, or testing, your help is appreciated.
**Before contributing:** Please read our [Contributing Guide](CONTRIBUTING.md) and announce your intent to work on an issue before starting.
- **[Contributing Guide](CONTRIBUTING.md)** - Learn how to set up your development environment and submit contributions
- **[Report Issues](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Found a bug? Let us know!
- **[Feature Requests](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Share your ideas for new features
- **[Help Test Features](https://discord.gg/SCtMCgtsc4)** - Join Discord to test pre-release features and provide feedback
# Credits
Special thanks for inspiration go to:
@@ -56,4 +125,4 @@ Special thanks for inspiration go to:
# Buy me a coffee
If I made your life just a tiny bit easier, consider buying me a coffee!
<a href="https://buymeacoffee.com/flaminel" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
<a href="https://buymeacoffee.com/flaminel" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
+338 -7
View File
@@ -1,11 +1,27 @@
*(sample).*
*sample.avchd
*sample.avi
*sample.mkv
*sample.mov
*sample.mp4
*sample.webm
*sample.wmv
*.000
*.001
*.002
*.004
*.0xe
*.73k
*.73p
*.7z
*.7z.001
*.7z.002
*.89k
*.89z
*.8ck
*.a00
*.a01
*.a02
*.a7r
*.ac
*.acc
@@ -21,8 +37,11 @@
*.ahk
*.ai
*.aif
*.ain
*.air
*.alz
*.ana
*.apex
*.api
*.apk
*.app
@@ -30,15 +49,28 @@
*.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
@@ -46,26 +78,48 @@
*.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
@@ -75,9 +129,15 @@
*.coffee
*.com
*.command
*.comppkg.hauptwerk.rar
*.comppkg_hauptwerk_rar
*.conda
*.conf
*.config
*.cp9
*.cpgz
*.cpl
*.cpt
*.crt
*.cs
*.csh
@@ -85,17 +145,27 @@
*.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
@@ -112,19 +182,27 @@
*.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
@@ -140,36 +218,62 @@
*.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
@@ -182,17 +286,27 @@
*.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
@@ -201,27 +315,51 @@
*.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
@@ -232,22 +370,29 @@
*.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
@@ -266,41 +411,79 @@
*.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
@@ -318,6 +501,7 @@
*.pptx
*.prc
*.prg
*.prs
*.ps
*.ps1
*.ps1xml
@@ -333,9 +517,16 @@
*.psh-reflection
*.psm1
*.pst
*.psz
*.pt
*.pup
*.puz
*.pvd
*.pvmp
*.pvmz
*.pwa
*.pwc
*.pxl
*.pxo
*.py
*.pyc
@@ -343,8 +534,20 @@
*.pyo
*.python
*.pyz
*.q
*.qda
*.qit
*.qpx
*.r0
*.r00
*.r01
*.r02
*.r03
*.r04
*.r1
*.r2
*.r21
*.r30
*.ram
*.rar
*.raw
@@ -355,22 +558,35 @@
*.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
@@ -380,42 +596,86 @@
*.scr
*.script
*.sct
*.sdc
*.sdn
*.sdoc
*.sdocx
*.sea
*.seed
*.sen
*.server
*.service
*.sfg
*.sfm
*.sfs
*.sfv
*.sfx
*.sh
*.shar
*.shb
*.shell
*.shk
*.shortcut
*.shr
*.shs
*.shtml
*.sifz
*.sipa
*.sit
*.sitx
*.sk
*.sldm
*.sln
*.smm
*.smpf
*.snap
*.snagitstamps
*.snappy
*.snb
*.snd
*.snz
*.spa
*.spd
*.spl
*.spm
*.spr
*.spt
*.sql
*.sqf
*.sqx
*.sqz
*.srec
*.srep
*.srt
*.ssm
*.stg
*.stkdoodlz
*.stproj
*.sts
*.sub
*.svg
*.swf
*.sy_
*.sys
*.tar
*.tar.bz2
*.tar.gz
*.tar.gz2
*.tar.lz
*.tar.lzma
*.tar.xz
*.tar.z
*.tar.zip
*.taz
*.tbl
*.tbz
*.tbz2
*.tcp
*.tcx
*.text
*.tf
*.tg
*.tgs
*.tgz
*.thm
*.thmx
@@ -424,19 +684,35 @@
*.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
@@ -448,26 +724,46 @@
*.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
@@ -476,14 +772,26 @@
*.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
@@ -496,24 +804,47 @@
*.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
*sample.avchd
*sample.avi
*sample.mkv
*sample.mov
*sample.mp4
*sample.webm
*sample.wmv
*.zpaq
*.zpi
*.zsplit
*.zst
*.zw
*.zwi
*.zz
Trailer.*
VOSTFR
api
+359 -1
View File
@@ -1,52 +1,410 @@
*.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
*.zipx
*.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
-162
View File
@@ -1,162 +0,0 @@
deployment:
replicas: 1
strategy:
type: RollingUpdate
maxSurge: 1
maxUnavailable: 0
containers:
- name: qbit
image:
repository: ghcr.io/flmorg/cleanuperr
tag: latest
env:
- name: DRY_RUN
value: "false"
- name: LOGGING__LOGLEVEL
value: Verbose
- name: LOGGING__FILE__ENABLED
value: "true"
- name: LOGGING__FILE__PATH
value: /var/logs
- name: LOGGING__ENHANCED
value: "true"
- name: TRIGGERS__QUEUECLEANER
value: 0 0/5 * * * ?
- name: TRIGGERS__CONTENTBLOCKER
value: 0 0/5 * * * ?
- name: QUEUECLEANER__ENABLED
value: "true"
- name: QUEUECLEANER__RUNSEQUENTIALLY
value: "true"
- name: QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES
value: "3"
- name: QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE
value: "false"
- name: QUEUECLEANER__IMPORT_FAILED_DELETE_PRIVATE
value: "false"
- name: QUEUECLEANER__STALLED_MAX_STRIKES
value: "3"
- name: QUEUECLEANER__STALLED_IGNORE_PRIVATE
value: "false"
- name: QUEUECLEANER__STALLED_DELETE_PRIVATE
value: "false"
- name: CONTENTBLOCKER__ENABLED
value: "true"
- name: CONTENTBLOCKER__IGNORE_PRIVATE
value: "true"
- name: CONTENTBLOCKER__DELETE_PRIVATE
value: "false"
- name: DOWNLOADCLEANER__ENABLED
value: "false"
- name: DOWNLOAD_CLIENT
value: qbittorrent
- name: QBITTORRENT__URL
value: http://service.qbittorrent-videos.svc.cluster.local
- name: SONARR__ENABLED
value: "true"
- name: SONARR__SEARCHTYPE
value: Episode
- name: SONARR__BLOCK__TYPE
value: blacklist
- name: SONARR__BLOCK__PATH
value: https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist
- name: SONARR__INSTANCES__0__URL
value: http://service.sonarr-low-res.svc.cluster.local
- name: SONARR__INSTANCES__1__URL
value: http://service.sonarr-high-res.svc.cluster.local
- name: RADARR__ENABLED
value: "true"
- name: RADARR__BLOCK__TYPE
value: blacklist
- name: RADARR__BLOCK__PATH
value: https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist
- name: RADARR__INSTANCES__0__URL
value: http://service.radarr-low-res.svc.cluster.local
- name: RADARR__INSTANCES__1__URL
value: http://service.radarr-high-res.svc.cluster.local
- name: NOTIFIARR__ON_IMPORT_FAILED_STRIKE
value: "true"
- name: NOTIFIARR__ON_STALLED_STRIKE
value: "true"
- name: NOTIFIARR__ON_QUEUE_ITEM_DELETED
value: "true"
- name: NOTIFIARR__ON_DOWNLOAD_CLEANED
value: "true"
- name: NOTIFIARR__CHANNEL_ID
value: "1340708411259748413"
envFromSecret:
- secretName: qbit-auth
envs:
- name: QBITTORRENT__USERNAME
key: QBIT_USER
- name: QBITTORRENT__PASSWORD
key: QBIT_PASS
- secretName: sonarr-auth
envs:
- name: SONARR__INSTANCES__0__APIKEY
key: SNRL_API_KEY
- name: SONARR__INSTANCES__1__APIKEY
key: SNRH_API_KEY
- secretName: radarr-auth
envs:
- name: RADARR__INSTANCES__0__APIKEY
key: RDRL_API_KEY
- name: RADARR__INSTANCES__1__APIKEY
key: RDRH_API_KEY
- secretName: notifiarr-auth
envs:
- name: NOTIFIARR__API_KEY
key: API_KEY
resources:
requests:
cpu: 0m
memory: 0Mi
limits:
cpu: 1000m
memory: 1000Mi
volumeMounts:
- name: storage
mountPath: /var/logs
subPath: cleanuperr/logs
volumes:
- name: storage
type: pvc
typeName: storage-pvc
pvcs:
- name: storage-pvc
storageClassName: local-path-persistent
accessModes:
- ReadWriteOnce
size: 1Gi
volumeMode: Filesystem
vaultSecrets:
- name: qbit-auth
path: secrets/qbittorrent
templates:
QBIT_USER: "{% .Secrets.username %}"
QBIT_PASS: "{% .Secrets.password %}"
- name: radarr-auth
path: secrets/radarr
templates:
RDRL_API_KEY: "{% .Secrets.low_api_key %}"
RDRH_API_KEY: "{% .Secrets.high_api_key %}"
- name: sonarr-auth
path: secrets/sonarr
templates:
SNRL_API_KEY: "{% .Secrets.low_api_key %}"
SNRH_API_KEY: "{% .Secrets.high_api_key %}"
- name: notifiarr-auth
path: secrets/notifiarr
templates:
API_KEY: "{% .Secrets.passthrough_api_key %}"
+87
View File
@@ -0,0 +1,87 @@
# Documentation
*.md
docs/
# Version control
.git/
.gitignore
# IDE files
.vscode/
.idea/
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Node.js
frontend/node_modules/
frontend/dist/
frontend/.angular/
# .NET
backend/bin/
backend/obj/
backend/*/bin/
backend/*/obj/
backend/.vs/
# Build artifacts
artifacts/
dist/
# Test files
backend/**/*Tests/
backend/**/Tests/
# Development files
docker-compose*.yml
test/
# ================================
# Node and build output
# ================================
node_modules
dist
out-tsc
.angular
.cache
.tmp
# ================================
# Testing & Coverage
# ================================
coverage
jest
cypress
cypress/screenshots
cypress/videos
reports
playwright-report
.vite
.vitepress
# ================================
# Environment & log files
# ================================
*.env*
!*.env.production
*.log
*.tsbuildinfo
# ================================
# Docker & local orchestration
# ================================
Dockerfile
Dockerfile.*
.dockerignore
docker-compose.yml
docker-compose*.yml
# ================================
# Miscellaneous
# ================================
*.bak
*.old
@@ -1,6 +0,0 @@
namespace Common.Attributes;
[AttributeUsage(AttributeTargets.Method, Inherited = true)]
public class DryRunSafeguardAttribute : Attribute
{
}
-14
View File
@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.2" />
<PackageReference Include="Serilog" Version="4.2.0" />
</ItemGroup>
</Project>
@@ -1,19 +0,0 @@
using Common.Configuration.ContentBlocker;
namespace Common.Configuration.Arr;
public abstract record ArrConfig
{
public required bool Enabled { get; init; }
public Block Block { get; init; } = new();
public required List<ArrInstance> Instances { get; init; }
}
public readonly record struct Block
{
public BlocklistType Type { get; init; }
public string? Path { get; init; }
}
@@ -1,8 +0,0 @@
namespace Common.Configuration.Arr;
public sealed class ArrInstance
{
public required Uri Url { get; set; }
public required string ApiKey { get; set; }
}
@@ -1,6 +0,0 @@
namespace Common.Configuration.Arr;
public sealed record LidarrConfig : ArrConfig
{
public const string SectionName = "Lidarr";
}
@@ -1,6 +0,0 @@
namespace Common.Configuration.Arr;
public sealed record RadarrConfig : ArrConfig
{
public const string SectionName = "Radarr";
}
@@ -1,8 +0,0 @@
namespace Common.Configuration.Arr;
public sealed record SonarrConfig : ArrConfig
{
public const string SectionName = "Sonarr";
public SonarrSearchType SearchType { get; init; }
}
@@ -1,8 +0,0 @@
namespace Common.Configuration.Arr;
public enum SonarrSearchType
{
Episode,
Season,
Series
}
@@ -1,23 +0,0 @@
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.ContentBlocker;
public sealed record ContentBlockerConfig : IJobConfig, IIgnoredDownloadsConfig
{
public const string SectionName = "ContentBlocker";
public required bool Enabled { get; init; }
[ConfigurationKeyName("IGNORE_PRIVATE")]
public bool IgnorePrivate { get; init; }
[ConfigurationKeyName("DELETE_PRIVATE")]
public bool DeletePrivate { get; init; }
[ConfigurationKeyName("IGNORED_DOWNLOADS_PATH")]
public string? IgnoredDownloadsPath { get; init; }
public void Validate()
{
}
}
@@ -1,45 +0,0 @@
using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadCleaner;
public sealed record CleanCategory : IConfig
{
public required string Name { get; init; }
/// <summary>
/// Max ratio before removing a download.
/// </summary>
[ConfigurationKeyName("MAX_RATIO")]
public required double MaxRatio { get; init; } = -1;
/// <summary>
/// Min number of hours to seed before removing a download, if the ratio has been met.
/// </summary>
[ConfigurationKeyName("MIN_SEED_TIME")]
public required double MinSeedTime { get; init; } = 0;
/// <summary>
/// Number of hours to seed before removing a download.
/// </summary>
[ConfigurationKeyName("MAX_SEED_TIME")]
public required double MaxSeedTime { get; init; } = -1;
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
{
throw new ValidationException($"{nameof(Name)} can not be empty");
}
if (MaxRatio < 0 && MaxSeedTime < 0)
{
throw new ValidationException($"both {nameof(MaxRatio)} and {nameof(MaxSeedTime)} are disabled");
}
if (MinSeedTime < 0)
{
throw new ValidationException($"{nameof(MinSeedTime)} can not be negative");
}
}
}
@@ -1,68 +0,0 @@
using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadCleaner;
public sealed record DownloadCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
{
public const string SectionName = "DownloadCleaner";
public bool Enabled { get; init; }
public List<CleanCategory>? Categories { get; init; }
[ConfigurationKeyName("DELETE_PRIVATE")]
public bool DeletePrivate { get; init; }
[ConfigurationKeyName("IGNORED_DOWNLOADS_PATH")]
public string? IgnoredDownloadsPath { get; init; }
[ConfigurationKeyName("UNLINKED_TARGET_CATEGORY")]
public string UnlinkedTargetCategory { get; init; } = "cleanuperr-unlinked";
[ConfigurationKeyName("UNLINKED_IGNORED_ROOT_DIR")]
public string UnlinkedIgnoredRootDir { get; init; } = string.Empty;
[ConfigurationKeyName("UNLINKED_CATEGORIES")]
public List<string>? UnlinkedCategories { get; init; }
public void Validate()
{
if (!Enabled)
{
return;
}
if (Categories?.GroupBy(x => x.Name).Any(x => x.Count() > 1) is true)
{
throw new ValidationException("duplicated clean categories found");
}
Categories?.ForEach(x => x.Validate());
if (string.IsNullOrEmpty(UnlinkedTargetCategory))
{
return;
}
if (UnlinkedCategories?.Count is null or 0)
{
throw new ValidationException("no unlinked categories configured");
}
if (UnlinkedCategories.Contains(UnlinkedTargetCategory))
{
throw new ValidationException($"{SectionName.ToUpperInvariant()}__UNLINKED_TARGET_CATEGORY should not be present in {SectionName.ToUpperInvariant()}__UNLINKED_CATEGORIES");
}
if (UnlinkedCategories.Any(string.IsNullOrEmpty))
{
throw new ValidationException("empty unlinked category filter found");
}
if (!string.IsNullOrEmpty(UnlinkedIgnoredRootDir) && !Directory.Exists(UnlinkedIgnoredRootDir))
{
throw new ValidationException($"{UnlinkedIgnoredRootDir} root directory does not exist");
}
}
}
@@ -1,24 +0,0 @@
using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadClient;
public sealed record DelugeConfig : IConfig
{
public const string SectionName = "Deluge";
public Uri? Url { get; init; }
[ConfigurationKeyName("URL_BASE")]
public string UrlBase { get; init; } = string.Empty;
public string? Password { get; init; }
public void Validate()
{
if (Url is null)
{
throw new ValidationException($"{nameof(Url)} is empty");
}
}
}
@@ -1,9 +0,0 @@
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadClient;
public sealed record DownloadClientConfig
{
[ConfigurationKeyName("DOWNLOAD_CLIENT")]
public Enums.DownloadClient DownloadClient { get; init; } = Enums.DownloadClient.None;
}
@@ -1,26 +0,0 @@
using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadClient;
public sealed class QBitConfig : IConfig
{
public const string SectionName = "qBittorrent";
public Uri? Url { get; init; }
[ConfigurationKeyName("URL_BASE")]
public string UrlBase { get; init; } = string.Empty;
public string? Username { get; init; }
public string? Password { get; init; }
public void Validate()
{
if (Url is null)
{
throw new ValidationException($"{nameof(Url)} is empty");
}
}
}
@@ -1,26 +0,0 @@
using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadClient;
public record TransmissionConfig : IConfig
{
public const string SectionName = "Transmission";
public Uri? Url { get; init; }
[ConfigurationKeyName("URL_BASE")]
public string UrlBase { get; init; } = "transmission";
public string? Username { get; init; }
public string? Password { get; init; }
public void Validate()
{
if (Url is null)
{
throw new ValidationException($"{nameof(Url)} is empty");
}
}
}
@@ -1,9 +0,0 @@
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.General;
public sealed record DryRunConfig
{
[ConfigurationKeyName("DRY_RUN")]
public bool IsDryRun { get; init; }
}
@@ -1,25 +0,0 @@
using Common.Enums;
using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.General;
public sealed record HttpConfig : IConfig
{
[ConfigurationKeyName("HTTP_MAX_RETRIES")]
public ushort MaxRetries { get; init; }
[ConfigurationKeyName("HTTP_TIMEOUT")]
public ushort Timeout { get; init; } = 100;
[ConfigurationKeyName("HTTP_VALIDATE_CERT")]
public CertificateValidationType CertificateValidation { get; init; } = CertificateValidationType.Enabled;
public void Validate()
{
if (Timeout is 0)
{
throw new ValidationException("HTTP_TIMEOUT must be greater than 0");
}
}
}
@@ -1,12 +0,0 @@
namespace Common.Configuration.General;
public sealed class TriggersConfig
{
public const string SectionName = "Triggers";
public required string QueueCleaner { get; init; }
public required string ContentBlocker { get; init; }
public required string DownloadCleaner { get; init; }
}
@@ -1,6 +0,0 @@
namespace Common.Configuration;
public interface IIgnoredDownloadsConfig
{
string? IgnoredDownloadsPath { get; }
}
-6
View File
@@ -1,6 +0,0 @@
namespace Common.Configuration;
public interface IJobConfig : IConfig
{
bool Enabled { get; init; }
}
@@ -1,12 +0,0 @@
namespace Common.Configuration.Logging;
public class FileLogConfig : IConfig
{
public bool Enabled { get; set; }
public string Path { get; set; } = string.Empty;
public void Validate()
{
}
}
@@ -1,18 +0,0 @@
using Serilog.Events;
namespace Common.Configuration.Logging;
public class LoggingConfig : IConfig
{
public const string SectionName = "Logging";
public LogEventLevel LogLevel { get; set; }
public bool Enhanced { get; set; }
public FileLogConfig? File { get; set; }
public void Validate()
{
}
}
@@ -1,34 +0,0 @@
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.Notification;
public abstract record NotificationConfig
{
[ConfigurationKeyName("ON_IMPORT_FAILED_STRIKE")]
public bool OnImportFailedStrike { get; init; }
[ConfigurationKeyName("ON_STALLED_STRIKE")]
public bool OnStalledStrike { get; init; }
[ConfigurationKeyName("ON_SLOW_STRIKE")]
public bool OnSlowStrike { get; init; }
[ConfigurationKeyName("ON_QUEUE_ITEM_DELETED")]
public bool OnQueueItemDeleted { get; init; }
[ConfigurationKeyName("ON_DOWNLOAD_CLEANED")]
public bool OnDownloadCleaned { get; init; }
[ConfigurationKeyName("ON_CATEGORY_CHANGED")]
public bool OnCategoryChanged { get; init; }
public bool IsEnabled =>
OnImportFailedStrike ||
OnStalledStrike ||
OnSlowStrike ||
OnQueueItemDeleted ||
OnDownloadCleaned ||
OnCategoryChanged;
public abstract bool IsValid();
}
@@ -1,119 +0,0 @@
using Common.CustomDataTypes;
using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.QueueCleaner;
public sealed record QueueCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
{
public const string SectionName = "QueueCleaner";
public required bool Enabled { get; init; }
public required bool RunSequentially { get; init; }
[ConfigurationKeyName("IGNORED_DOWNLOADS_PATH")]
public string? IgnoredDownloadsPath { get; init; }
[ConfigurationKeyName("IMPORT_FAILED_MAX_STRIKES")]
public ushort ImportFailedMaxStrikes { get; init; }
[ConfigurationKeyName("IMPORT_FAILED_IGNORE_PRIVATE")]
public bool ImportFailedIgnorePrivate { get; init; }
[ConfigurationKeyName("IMPORT_FAILED_DELETE_PRIVATE")]
public bool ImportFailedDeletePrivate { get; init; }
[ConfigurationKeyName("IMPORT_FAILED_IGNORE_PATTERNS")]
public IReadOnlyList<string>? ImportFailedIgnorePatterns { get; init; }
[ConfigurationKeyName("STALLED_MAX_STRIKES")]
public ushort StalledMaxStrikes { get; init; }
[ConfigurationKeyName("STALLED_RESET_STRIKES_ON_PROGRESS")]
public bool StalledResetStrikesOnProgress { get; init; }
[ConfigurationKeyName("STALLED_IGNORE_PRIVATE")]
public bool StalledIgnorePrivate { get; init; }
[ConfigurationKeyName("STALLED_DELETE_PRIVATE")]
public bool StalledDeletePrivate { get; init; }
[ConfigurationKeyName("DOWNLOADING_METADATA_MAX_STRIKES")]
public ushort DownloadingMetadataMaxStrikes { get; init; }
[ConfigurationKeyName("SLOW_MAX_STRIKES")]
public ushort SlowMaxStrikes { get; init; }
[ConfigurationKeyName("SLOW_RESET_STRIKES_ON_PROGRESS")]
public bool SlowResetStrikesOnProgress { get; init; }
[ConfigurationKeyName("SLOW_IGNORE_PRIVATE")]
public bool SlowIgnorePrivate { get; init; }
[ConfigurationKeyName("SLOW_DELETE_PRIVATE")]
public bool SlowDeletePrivate { get; init; }
[ConfigurationKeyName("SLOW_MIN_SPEED")]
public string SlowMinSpeed { get; init; } = string.Empty;
public ByteSize SlowMinSpeedByteSize => string.IsNullOrEmpty(SlowMinSpeed) ? new ByteSize(0) : ByteSize.Parse(SlowMinSpeed);
[ConfigurationKeyName("SLOW_MAX_TIME")]
public double SlowMaxTime { get; init; }
[ConfigurationKeyName("SLOW_IGNORE_ABOVE_SIZE")]
public string SlowIgnoreAboveSize { get; init; } = string.Empty;
public ByteSize? SlowIgnoreAboveSizeByteSize => string.IsNullOrEmpty(SlowIgnoreAboveSize) ? null : ByteSize.Parse(SlowIgnoreAboveSize);
public void Validate()
{
if (ImportFailedMaxStrikes is > 0 and < 3)
{
throw new ValidationException($"the minimum value for {SectionName.ToUpperInvariant()}__IMPORT_FAILED_MAX_STRIKES must be 3");
}
if (StalledMaxStrikes is > 0 and < 3)
{
throw new ValidationException($"the minimum value for {SectionName.ToUpperInvariant()}__STALLED_MAX_STRIKES must be 3");
}
if (DownloadingMetadataMaxStrikes is > 0 and < 3)
{
throw new ValidationException($"the minimum value for {SectionName.ToUpperInvariant()}__DOWNLOADING_METADATA_MAX_STRIKES must be 3");
}
if (SlowMaxStrikes is > 0 and < 3)
{
throw new ValidationException($"the minimum value for {SectionName.ToUpperInvariant()}__SLOW_MAX_STRIKES must be 3");
}
if (SlowMaxStrikes > 0)
{
bool isSlowSpeedSet = !string.IsNullOrEmpty(SlowMinSpeed);
if (isSlowSpeedSet && ByteSize.TryParse(SlowMinSpeed, out _) is false)
{
throw new ValidationException($"invalid value for {SectionName.ToUpperInvariant()}__SLOW_MIN_SPEED");
}
if (SlowMaxTime < 0)
{
throw new ValidationException($"invalid value for {SectionName.ToUpperInvariant()}__SLOW_MAX_TIME");
}
if (!isSlowSpeedSet && SlowMaxTime is 0)
{
throw new ValidationException($"either {SectionName.ToUpperInvariant()}__SLOW_MIN_SPEED or {SectionName.ToUpperInvariant()}__SLOW_MAX_STRIKES must be set");
}
bool isSlowIgnoreAboveSizeSet = !string.IsNullOrEmpty(SlowIgnoreAboveSize);
if (isSlowIgnoreAboveSizeSet && ByteSize.TryParse(SlowIgnoreAboveSize, out _) is false)
{
throw new ValidationException($"invalid value for {SectionName.ToUpperInvariant()}__SLOW_IGNORE_ABOVE_SIZE");
}
}
}
}
@@ -1,8 +0,0 @@
namespace Common.Enums;
public enum CertificateValidationType
{
Enabled = 0,
DisabledForLocalAddresses = 1,
Disabled = 2
}
-10
View File
@@ -1,10 +0,0 @@
namespace Common.Enums;
public enum DownloadClient
{
QBittorrent,
Deluge,
Transmission,
None,
Disabled
}
-9
View File
@@ -1,9 +0,0 @@
namespace Common.Helpers;
public static class Constants
{
public static readonly TimeSpan TriggerMaxLimit = TimeSpan.FromHours(6);
public static readonly TimeSpan CacheLimitBuffer = TimeSpan.FromHours(2);
public const string HttpClientWithRetryName = "retry";
}
+82
View File
@@ -0,0 +1,82 @@
# Build Angular frontend
FROM --platform=$BUILDPLATFORM node:25-alpine AS frontend-build
WORKDIR /app
# Copy package files first for better layer caching
COPY frontend/package*.json ./
# Use cache mount for npm to speed up builds
RUN --mount=type=cache,target=/root/.npm \
npm ci && npm install -g @angular/cli
# Copy source code
COPY frontend/ .
# Build with appropriate base-href and deploy-url
RUN npm run build
# Build .NET backend
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG TARGETARCH
ARG VERSION=0.0.1
ARG PACKAGES_USERNAME
ARG PACKAGES_PAT
WORKDIR /app
EXPOSE 11011
# Copy source code
COPY backend/ ./backend/
# Add NuGet source
RUN dotnet nuget add source --username ${PACKAGES_USERNAME} --password ${PACKAGES_PAT} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
# Restore and publish with cache mount
RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
dotnet restore ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj -a $TARGETARCH && \
dotnet publish ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj \
-a $TARGETARCH \
-c Release \
-o /app/publish \
--no-restore \
/p:Version=${VERSION} \
/p:PublishSingleFile=true \
/p:DebugSymbols=false
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:10.0
# Install required packages for user management, timezone support, and Python for Apprise CLI
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
tzdata \
gosu \
python3 \
python3-venv \
&& rm -rf /var/lib/apt/lists/*
# Create virtual environment and install Apprise CLI
ENV VIRTUAL_ENV=/opt/apprise-venv
RUN python3 -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN pip install --no-cache-dir apprise==1.9.6
ENV PUID=1000 \
PGID=1000 \
UMASK=022 \
TZ=Etc/UTC \
HTTP_PORTS=11011
# Fix FileSystemWatcher in Docker: https://github.com/dotnet/dotnet-docker/issues/3546
ENV DOTNET_USE_POLLING_FILE_WATCHER=true
WORKDIR /app
# Copy backend
COPY --from=build /app/publish .
# Copy frontend to wwwroot
COPY --from=frontend-build /app/dist/ui/browser ./wwwroot
# Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["./Cleanuparr"]
-9
View File
@@ -1,9 +0,0 @@
namespace Domain.Enums;
public enum InstanceType
{
Sonarr,
Radarr,
Lidarr,
Readarr
}
@@ -1,8 +0,0 @@
namespace Domain.Models.Arr.Blocking;
public record BlockedItem
{
public required string Hash { get; init; }
public required Uri InstanceUrl { get; init; }
}
@@ -1,8 +0,0 @@
namespace Domain.Models.Arr.Blocking;
public sealed record LidarrBlockedItem : BlockedItem
{
public required long AlbumId { get; init; }
public required long ArtistId { get; init; }
}
@@ -1,6 +0,0 @@
namespace Domain.Models.Arr.Blocking;
public sealed record RadarrBlockedItem : BlockedItem
{
public required long MovieId { get; init; }
}
@@ -1,10 +0,0 @@
namespace Domain.Models.Arr.Blocking;
public sealed record SonarrBlockedItem : BlockedItem
{
public required long EpisodeId { get; init; }
public required long SeasonNumber { get; init; }
public required long SeriesId { get; init; }
}
@@ -1,27 +0,0 @@
using Common.Configuration.Arr;
using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.DownloadClient;
using Common.Configuration.General;
using Common.Configuration.Logging;
using Common.Configuration.QueueCleaner;
namespace Executable.DependencyInjection;
public static class ConfigurationDI
{
public static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) =>
services
.Configure<DryRunConfig>(configuration)
.Configure<QueueCleanerConfig>(configuration.GetSection(QueueCleanerConfig.SectionName))
.Configure<ContentBlockerConfig>(configuration.GetSection(ContentBlockerConfig.SectionName))
.Configure<DownloadCleanerConfig>(configuration.GetSection(DownloadCleanerConfig.SectionName))
.Configure<DownloadClientConfig>(configuration)
.Configure<QBitConfig>(configuration.GetSection(QBitConfig.SectionName))
.Configure<DelugeConfig>(configuration.GetSection(DelugeConfig.SectionName))
.Configure<TransmissionConfig>(configuration.GetSection(TransmissionConfig.SectionName))
.Configure<SonarrConfig>(configuration.GetSection(SonarrConfig.SectionName))
.Configure<RadarrConfig>(configuration.GetSection(RadarrConfig.SectionName))
.Configure<LidarrConfig>(configuration.GetSection(LidarrConfig.SectionName))
.Configure<LoggingConfig>(configuration.GetSection(LoggingConfig.SectionName));
}
@@ -1,81 +0,0 @@
using Common.Configuration.Logging;
using Domain.Enums;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.DownloadCleaner;
using Infrastructure.Verticals.QueueCleaner;
using Serilog;
using Serilog.Events;
using Serilog.Templates;
using Serilog.Templates.Themes;
namespace Executable.DependencyInjection;
public static class LoggingDI
{
public static ILoggingBuilder AddLogging(this ILoggingBuilder builder, IConfiguration configuration)
{
LoggingConfig? config = configuration.GetSection(LoggingConfig.SectionName).Get<LoggingConfig>();
if (!string.IsNullOrEmpty(config?.File?.Path) && !Directory.Exists(config.File.Path))
{
try
{
Directory.CreateDirectory(config.File.Path);
}
catch (Exception exception)
{
throw new Exception($"log file path is not a valid directory | {config.File.Path}", exception);
}
}
LoggerConfiguration logConfig = new();
const string jobNameTemplate = "{#if JobName is not null} {Concat('[',JobName,']'),JOB_PAD}{#end}";
const string instanceNameTemplate = "{#if InstanceName is not null} {Concat('[',InstanceName,']'),ARR_PAD}{#end}";
const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{instanceNameTemplate} {{@m}}\n{{@x}}";
const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{instanceNameTemplate} {{@m:lj}}\n{{@x}}";
LogEventLevel level = LogEventLevel.Information;
List<string> names = [nameof(ContentBlocker), nameof(QueueCleaner), nameof(DownloadCleaner)];
int jobPadding = names.Max(x => x.Length) + 2;
names = [InstanceType.Sonarr.ToString(), InstanceType.Radarr.ToString(), InstanceType.Lidarr.ToString()];
int arrPadding = names.Max(x => x.Length) + 2;
string consoleTemplate = consoleOutputTemplate
.Replace("JOB_PAD", jobPadding.ToString())
.Replace("ARR_PAD", arrPadding.ToString());
string fileTemplate = fileOutputTemplate
.Replace("JOB_PAD", jobPadding.ToString())
.Replace("ARR_PAD", arrPadding.ToString());
if (config is not null)
{
level = config.LogLevel;
if (config.File?.Enabled is true)
{
logConfig.WriteTo.File(
path: Path.Combine(config.File.Path, "cleanuperr-.txt"),
formatter: new ExpressionTemplate(fileTemplate),
fileSizeLimitBytes: 10L * 1024 * 1024,
rollingInterval: RollingInterval.Day,
rollOnFileSizeLimit: true
);
}
}
Log.Logger = logConfig
.MinimumLevel.Is(level)
.MinimumLevel.Override("MassTransit", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.Extensions.Http", LogEventLevel.Warning)
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
.WriteTo.Console(new ExpressionTemplate(consoleTemplate))
.Enrich.FromLogContext()
.Enrich.WithProperty("ApplicationName", "cleanuperr")
.CreateLogger();
return builder
.ClearProviders()
.AddSerilog();
}
}
@@ -1,108 +0,0 @@
using System.Net;
using Common.Configuration.General;
using Common.Helpers;
using Infrastructure.Services;
using Infrastructure.Verticals.DownloadClient.Deluge;
using Infrastructure.Verticals.Notifications.Consumers;
using Infrastructure.Verticals.Notifications.Models;
using MassTransit;
using Microsoft.Extensions.Options;
using Polly;
using Polly.Extensions.Http;
namespace Executable.DependencyInjection;
public static class MainDI
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
services
.AddLogging(builder => builder.ClearProviders().AddConsole())
.AddHttpClients(configuration)
.AddConfiguration(configuration)
.AddMemoryCache(options => {
options.ExpirationScanFrequency = TimeSpan.FromMinutes(1);
})
.AddServices()
.AddQuartzServices(configuration)
.AddNotifications(configuration)
.AddMassTransit(config =>
{
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowStrikeNotification>>();
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
config.UsingInMemory((context, cfg) =>
{
cfg.ReceiveEndpoint("notification-queue", e =>
{
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<SlowStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);
e.ConcurrentMessageLimit = 1;
e.PrefetchCount = 1;
});
});
});
private static IServiceCollection AddHttpClients(this IServiceCollection services, IConfiguration configuration)
{
// add default HttpClient
services.AddHttpClient();
HttpConfig config = configuration.Get<HttpConfig>() ?? new();
config.Validate();
// add retry HttpClient
services
.AddHttpClient(Constants.HttpClientWithRetryName, x =>
{
x.Timeout = TimeSpan.FromSeconds(config.Timeout);
})
.ConfigurePrimaryHttpMessageHandler(provider =>
{
CertificateValidationService service = provider.GetRequiredService<CertificateValidationService>();
return new HttpClientHandler
{
ServerCertificateCustomValidationCallback = service.ShouldByPassValidationError
};
})
.AddRetryPolicyHandler(config);
// add Deluge HttpClient
services
.AddHttpClient(nameof(DelugeService), x =>
{
x.Timeout = TimeSpan.FromSeconds(config.Timeout);
})
.ConfigurePrimaryHttpMessageHandler(_ =>
{
return new HttpClientHandler
{
AllowAutoRedirect = true,
UseCookies = true,
CookieContainer = new CookieContainer(),
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
})
.AddRetryPolicyHandler(config);
return services;
}
private static IHttpClientBuilder AddRetryPolicyHandler(this IHttpClientBuilder builder, HttpConfig config) =>
builder.AddPolicyHandler(
HttpPolicyExtensions
.HandleTransientHttpError()
// do not retry on Unauthorized
.OrResult(response => !response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.Unauthorized)
.WaitAndRetryAsync(config.MaxRetries, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)))
);
}
@@ -1,20 +0,0 @@
using Infrastructure.Verticals.Notifications;
using Infrastructure.Verticals.Notifications.Apprise;
using Infrastructure.Verticals.Notifications.Notifiarr;
namespace Executable.DependencyInjection;
public static class NotificationsDI
{
public static IServiceCollection AddNotifications(this IServiceCollection services, IConfiguration configuration) =>
services
.Configure<NotifiarrConfig>(configuration.GetSection(NotifiarrConfig.SectionName))
.Configure<AppriseConfig>(configuration.GetSection(AppriseConfig.SectionName))
.AddTransient<INotifiarrProxy, NotifiarrProxy>()
.AddTransient<INotificationProvider, NotifiarrProvider>()
.AddTransient<IAppriseProxy, AppriseProxy>()
.AddTransient<INotificationProvider, AppriseProvider>()
.AddTransient<INotificationPublisher, NotificationPublisher>()
.AddTransient<INotificationFactory, NotificationFactory>()
.AddTransient<NotificationService>();
}
@@ -1,144 +0,0 @@
using Common.Configuration;
using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.General;
using Common.Configuration.QueueCleaner;
using Common.Helpers;
using Executable.Jobs;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.DownloadCleaner;
using Infrastructure.Verticals.Jobs;
using Infrastructure.Verticals.QueueCleaner;
using Quartz;
using Quartz.Spi;
namespace Executable.DependencyInjection;
public static class QuartzDI
{
public static IServiceCollection AddQuartzServices(this IServiceCollection services, IConfiguration configuration) =>
services
.AddQuartz(q =>
{
TriggersConfig? config = configuration
.GetRequiredSection(TriggersConfig.SectionName)
.Get<TriggersConfig>();
if (config is null)
{
throw new NullReferenceException("triggers configuration is null");
}
q.AddJobs(configuration, config);
})
.AddQuartzHostedService(opt =>
{
opt.WaitForJobsToComplete = true;
});
private static void AddJobs(
this IServiceCollectionQuartzConfigurator q,
IConfiguration configuration,
TriggersConfig triggersConfig
)
{
ContentBlockerConfig? contentBlockerConfig = configuration
.GetRequiredSection(ContentBlockerConfig.SectionName)
.Get<ContentBlockerConfig>();
q.AddJob<ContentBlocker>(contentBlockerConfig, triggersConfig.ContentBlocker);
QueueCleanerConfig? queueCleanerConfig = configuration
.GetRequiredSection(QueueCleanerConfig.SectionName)
.Get<QueueCleanerConfig>();
if (contentBlockerConfig?.Enabled is true && queueCleanerConfig is { Enabled: true, RunSequentially: true })
{
q.AddJob<QueueCleaner>(queueCleanerConfig, string.Empty);
q.AddJobListener(new JobChainingListener(nameof(ContentBlocker), nameof(QueueCleaner)));
}
else
{
q.AddJob<QueueCleaner>(queueCleanerConfig, triggersConfig.QueueCleaner);
}
DownloadCleanerConfig? downloadCleanerConfig = configuration
.GetRequiredSection(DownloadCleanerConfig.SectionName)
.Get<DownloadCleanerConfig>();
q.AddJob<DownloadCleaner>(downloadCleanerConfig, triggersConfig.DownloadCleaner);
}
private static void AddJob<T>(
this IServiceCollectionQuartzConfigurator q,
IJobConfig? config,
string trigger
) where T: GenericHandler
{
string typeName = typeof(T).Name;
if (config is null)
{
throw new NullReferenceException($"{typeName} configuration is null");
}
if (!config.Enabled)
{
return;
}
bool hasTrigger = trigger.Length > 0;
q.AddJob<GenericJob<T>>(opts =>
{
opts.WithIdentity(typeName);
if (!hasTrigger)
{
// jobs with no triggers need to be stored durably
opts.StoreDurably();
}
});
// skip empty triggers
if (!hasTrigger)
{
return;
}
IOperableTrigger triggerObj = (IOperableTrigger)TriggerBuilder.Create()
.WithIdentity("ExampleTrigger")
.StartNow()
.WithCronSchedule(trigger)
.Build();
IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2);
TimeSpan triggerValue = nextFireTimes[1] - nextFireTimes[0];
if (triggerValue > Constants.TriggerMaxLimit)
{
throw new Exception($"{trigger} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
}
if (triggerValue > StaticConfiguration.TriggerValue)
{
StaticConfiguration.TriggerValue = triggerValue;
}
q.AddTrigger(opts =>
{
opts.ForJob(typeName)
.WithIdentity($"{typeName}-trigger")
.WithCronSchedule(trigger, x =>x.WithMisfireHandlingInstructionDoNothing())
.StartNow();
});
// Startup trigger
q.AddTrigger(opts =>
{
opts.ForJob(typeName)
.WithIdentity($"{typeName}-startup-trigger")
.StartNow();
});
}
}
@@ -1,47 +0,0 @@
using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.QueueCleaner;
using Infrastructure.Interceptors;
using Infrastructure.Providers;
using Infrastructure.Services;
using Infrastructure.Verticals.Arr;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.DownloadCleaner;
using Infrastructure.Verticals.DownloadClient;
using Infrastructure.Verticals.DownloadClient.Deluge;
using Infrastructure.Verticals.DownloadClient.QBittorrent;
using Infrastructure.Verticals.DownloadClient.Transmission;
using Infrastructure.Verticals.Files;
using Infrastructure.Verticals.ItemStriker;
using Infrastructure.Verticals.QueueCleaner;
namespace Executable.DependencyInjection;
public static class ServicesDI
{
public static IServiceCollection AddServices(this IServiceCollection services) =>
services
.AddTransient<IDryRunInterceptor, DryRunInterceptor>()
.AddTransient<CertificateValidationService>()
.AddTransient<SonarrClient>()
.AddTransient<RadarrClient>()
.AddTransient<LidarrClient>()
.AddTransient<QueueCleaner>()
.AddTransient<ContentBlocker>()
.AddTransient<DownloadCleaner>()
.AddTransient<IFilenameEvaluator, FilenameEvaluator>()
.AddTransient<IHardLinkFileService, HardLinkFileService>()
.AddTransient<UnixHardLinkFileService>()
.AddTransient<WindowsHardLinkFileService>()
.AddTransient<DummyDownloadService>()
.AddTransient<QBitService>()
.AddTransient<DelugeService>()
.AddTransient<TransmissionService>()
.AddTransient<ArrQueueIterator>()
.AddTransient<DownloadServiceFactory>()
.AddTransient<IStriker, Striker>()
.AddSingleton<BlocklistProvider>()
.AddSingleton<IgnoredDownloadsProvider<QueueCleanerConfig>>()
.AddSingleton<IgnoredDownloadsProvider<ContentBlockerConfig>>()
.AddSingleton<IgnoredDownloadsProvider<DownloadCleanerConfig>>();
}
-31
View File
@@ -1,31 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<AssemblyName>cleanuperr</AssemblyName>
<TargetFramework>net9.0</TargetFramework>
<Version Condition="'$(Version)' == ''">0.0.1</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-Executable-6108b2ba-f035-47bc-addf-aaf5e20da4b8</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.2" />
<PackageReference Include="Quartz" Version="3.13.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.13.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.13.1" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />
<ProjectReference Include="..\Infrastructure\Infrastructure.csproj" />
</ItemGroup>
</Project>
-23
View File
@@ -1,23 +0,0 @@
using System.Reflection;
namespace Executable;
public static class HostExtensions
{
public static IHost Init(this IHost host)
{
ILogger<Program> logger = host.Services.GetRequiredService<ILogger<Program>>();
Version? version = Assembly.GetExecutingAssembly().GetName().Version;
logger.LogInformation(
version is null
? "cleanuperr version not detected"
: $"cleanuperr v{version.Major}.{version.Minor}.{version.Build}"
);
logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName);
return host;
}
}
-33
View File
@@ -1,33 +0,0 @@
using Infrastructure.Verticals.Jobs;
using Quartz;
using Serilog.Context;
namespace Executable.Jobs;
[DisallowConcurrentExecution]
public sealed class GenericJob<T> : IJob
where T : IHandler
{
private readonly ILogger<GenericJob<T>> _logger;
private readonly T _handler;
public GenericJob(ILogger<GenericJob<T>> logger, T handler)
{
_logger = logger;
_handler = handler;
}
public async Task Execute(IJobExecutionContext context)
{
using var _ = LogContext.PushProperty("JobName", typeof(T).Name);
try
{
await _handler.ExecuteAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "{name} failed", typeof(T).Name);
}
}
}
-12
View File
@@ -1,12 +0,0 @@
using Executable;
using Executable.DependencyInjection;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddInfrastructure(builder.Configuration);
builder.Logging.AddLogging(builder.Configuration);
var host = builder.Build();
host.Init();
host.Run();
@@ -1,145 +0,0 @@
{
"DRY_RUN": true,
"HTTP_MAX_RETRIES": 0,
"HTTP_TIMEOUT": 100,
"HTTP_VALIDATE_CERT": "enabled",
"Logging": {
"LogLevel": "Verbose",
"Enhanced": true,
"File": {
"Enabled": false,
"Path": ""
}
},
"Triggers": {
"QueueCleaner": "0/10 * * * * ?",
"ContentBlocker": "0/10 * * * * ?",
"DownloadCleaner": "0/10 * * * * ?"
},
"ContentBlocker": {
"Enabled": true,
"IGNORE_PRIVATE": true,
"DELETE_PRIVATE": false,
"IGNORED_DOWNLOADS_PATH": "../test/data/cleanuperr/ignored_downloads"
},
"QueueCleaner": {
"Enabled": true,
"RunSequentially": true,
"IGNORED_DOWNLOADS_PATH": "../test/data/cleanuperr/ignored_downloads",
"IMPORT_FAILED_MAX_STRIKES": 3,
"IMPORT_FAILED_IGNORE_PRIVATE": true,
"IMPORT_FAILED_DELETE_PRIVATE": false,
"IMPORT_FAILED_IGNORE_PATTERNS": [
"file is a sample"
],
"STALLED_MAX_STRIKES": 3,
"STALLED_RESET_STRIKES_ON_PROGRESS": true,
"STALLED_IGNORE_PRIVATE": true,
"STALLED_DELETE_PRIVATE": false,
"DOWNLOADING_METADATA_MAX_STRIKES": 3,
"SLOW_MAX_STRIKES": 5,
"SLOW_RESET_STRIKES_ON_PROGRESS": true,
"SLOW_IGNORE_PRIVATE": false,
"SLOW_DELETE_PRIVATE": false,
"SLOW_MIN_SPEED": "1MB",
"SLOW_MAX_TIME": 20,
"SLOW_IGNORE_ABOVE_SIZE": "4GB"
},
"DownloadCleaner": {
"Enabled": false,
"DELETE_PRIVATE": false,
"CATEGORIES": [
{
"Name": "tv-sonarr",
"MAX_RATIO": -1,
"MIN_SEED_TIME": 0,
"MAX_SEED_TIME": 240
}
],
"UNLINKED_TARGET_CATEGORY": "cleanuperr-unlinked",
"UNLINKED_IGNORED_ROOT_DIR": "",
"UNLINKED_CATEGORIES": [
"tv-sonarr",
"radarr"
],
"IGNORED_DOWNLOADS_PATH": "../test/data/cleanuperr/ignored_downloads"
},
"DOWNLOAD_CLIENT": "qbittorrent",
"qBittorrent": {
"Url": "http://localhost:8080",
"URL_BASE": "",
"Username": "test",
"Password": "testing"
},
"Deluge": {
"Url": "http://localhost:8112",
"URL_BASE": "",
"Password": "testing"
},
"Transmission": {
"Url": "http://localhost:9091",
"URL_BASE": "transmission",
"Username": "test",
"Password": "testing"
},
"Sonarr": {
"Enabled": true,
"SearchType": "Episode",
"Block": {
"Type": "blacklist",
"Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist"
},
"Instances": [
{
"Url": "http://localhost:8989",
"ApiKey": "425d1e713f0c405cbbf359ac0502c1f4"
}
]
},
"Radarr": {
"Enabled": true,
"Block": {
"Type": "blacklist",
"Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist"
},
"Instances": [
{
"Url": "http://localhost:7878",
"ApiKey": "8b7454f668e54c5b8f44f56f93969761"
}
]
},
"Lidarr": {
"Enabled": true,
"Block": {
"Type": "blacklist",
"Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist"
},
"Instances": [
{
"Url": "http://localhost:8686",
"ApiKey": "7f677cfdc074414397af53dd633860c5"
}
]
},
"Notifiarr": {
"ON_IMPORT_FAILED_STRIKE": true,
"ON_STALLED_STRIKE": true,
"ON_SLOW_STRIKE": true,
"ON_QUEUE_ITEM_DELETED": true,
"ON_DOWNLOAD_CLEANED": true,
"ON_CATEGORY_CHANGED": true,
"API_KEY": "",
"CHANNEL_ID": ""
},
"Apprise": {
"ON_IMPORT_FAILED_STRIKE": true,
"ON_STALLED_STRIKE": true,
"ON_SLOW_STRIKE": true,
"ON_QUEUE_ITEM_DELETED": true,
"ON_DOWNLOAD_CLEANED": true,
"ON_CATEGORY_CHANGED": true,
"URL": "http://localhost:8000",
"KEY": ""
}
}
-132
View File
@@ -1,132 +0,0 @@
{
"DRY_RUN": false,
"HTTP_MAX_RETRIES": 0,
"HTTP_TIMEOUT": 100,
"HTTP_VALIDATE_CERT": "enabled",
"Logging": {
"LogLevel": "Information",
"Enhanced": true,
"File": {
"Enabled": false,
"Path": ""
}
},
"Triggers": {
"QueueCleaner": "0 0/5 * * * ?",
"ContentBlocker": "0 0/5 * * * ?",
"DownloadCleaner": "0 0 * * * ?"
},
"ContentBlocker": {
"Enabled": false,
"IGNORE_PRIVATE": false,
"IGNORED_DOWNLOADS_PATH": ""
},
"QueueCleaner": {
"Enabled": false,
"RunSequentially": true,
"IGNORED_DOWNLOADS_PATH": "",
"IMPORT_FAILED_MAX_STRIKES": 0,
"IMPORT_FAILED_IGNORE_PRIVATE": false,
"IMPORT_FAILED_DELETE_PRIVATE": false,
"IMPORT_FAILED_IGNORE_PATTERNS": [],
"STALLED_MAX_STRIKES": 0,
"STALLED_RESET_STRIKES_ON_PROGRESS": false,
"STALLED_IGNORE_PRIVATE": false,
"STALLED_DELETE_PRIVATE": false,
"DOWNLOADING_METADATA_MAX_STRIKES": 0,
"SLOW_MAX_STRIKES": 0,
"SLOW_RESET_STRIKES_ON_PROGRESS": true,
"SLOW_IGNORE_PRIVATE": false,
"SLOW_DELETE_PRIVATE": false,
"SLOW_MIN_SPEED": "",
"SLOW_MAX_TIME": 0,
"SLOW_IGNORE_ABOVE_SIZE": ""
},
"DownloadCleaner": {
"Enabled": false,
"DELETE_PRIVATE": false,
"CATEGORIES": [],
"UNLINKED_TARGET_CATEGORY": "cleanuperr-unlinked",
"UNLINKED_IGNORED_ROOT_DIR": "",
"UNLINKED_CATEGORIES": [],
"IGNORED_DOWNLOADS_PATH": ""
},
"DOWNLOAD_CLIENT": "none",
"qBittorrent": {
"Url": "http://localhost:8080",
"URL_BASE": "",
"Username": "",
"Password": ""
},
"Deluge": {
"Url": "http://localhost:8112",
"URL_BASE": "",
"Password": "testing"
},
"Transmission": {
"Url": "http://localhost:9091",
"URL_BASE": "transmission",
"Username": "test",
"Password": "testing"
},
"Sonarr": {
"Enabled": false,
"SearchType": "Episode",
"Block": {
"Type": "blacklist",
"Path": ""
},
"Instances": [
{
"Url": "http://localhost:8989",
"ApiKey": ""
}
]
},
"Radarr": {
"Enabled": false,
"Block": {
"Type": "blacklist",
"Path": ""
},
"Instances": [
{
"Url": "http://localhost:7878",
"ApiKey": ""
}
]
},
"Lidarr": {
"Enabled": false,
"Block": {
"Type": "blacklist",
"Path": ""
},
"Instances": [
{
"Url": "http://localhost:8686",
"ApiKey": ""
}
]
},
"Notifiarr": {
"ON_IMPORT_FAILED_STRIKE": false,
"ON_STALLED_STRIKE": false,
"ON_SLOW_STRIKE": false,
"ON_QUEUE_ITEM_DELETED": false,
"ON_DOWNLOAD_CLEANED": false,
"ON_CATEGORY_CHANGED": false,
"API_KEY": "",
"CHANNEL_ID": ""
},
"Apprise": {
"ON_IMPORT_FAILED_STRIKE": false,
"ON_STALLED_STRIKE": false,
"ON_SLOW_STRIKE": false,
"ON_QUEUE_ITEM_DELETED": false,
"ON_DOWNLOAD_CLEANED": false,
"ON_CATEGORY_CHANGED": false,
"URL": "",
"KEY": ""
}
}
@@ -1,33 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Infrastructure\Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -1,80 +0,0 @@
using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.QueueCleaner;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.DownloadClient;
using Infrastructure.Verticals.Files;
using Infrastructure.Verticals.ItemStriker;
using Infrastructure.Verticals.Notifications;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
namespace Infrastructure.Tests.Verticals.DownloadClient;
public class DownloadServiceFixture : IDisposable
{
public ILogger<DownloadService> Logger { get; set; }
public IMemoryCache Cache { get; set; }
public IStriker Striker { get; set; }
public DownloadServiceFixture()
{
Logger = Substitute.For<ILogger<DownloadService>>();
Cache = Substitute.For<IMemoryCache>();
Striker = Substitute.For<IStriker>();
}
public TestDownloadService CreateSut(
QueueCleanerConfig? queueCleanerConfig = null,
ContentBlockerConfig? contentBlockerConfig = null
)
{
queueCleanerConfig ??= new QueueCleanerConfig
{
Enabled = true,
RunSequentially = true,
StalledResetStrikesOnProgress = true,
StalledMaxStrikes = 3
};
var queueCleanerOptions = Substitute.For<IOptions<QueueCleanerConfig>>();
queueCleanerOptions.Value.Returns(queueCleanerConfig);
contentBlockerConfig ??= new ContentBlockerConfig
{
Enabled = true
};
var contentBlockerOptions = Substitute.For<IOptions<ContentBlockerConfig>>();
contentBlockerOptions.Value.Returns(contentBlockerConfig);
var downloadCleanerOptions = Substitute.For<IOptions<DownloadCleanerConfig>>();
downloadCleanerOptions.Value.Returns(new DownloadCleanerConfig());
var filenameEvaluator = Substitute.For<IFilenameEvaluator>();
var notifier = Substitute.For<INotificationPublisher>();
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
var hardlinkFileService = Substitute.For<IHardLinkFileService>();
return new TestDownloadService(
Logger,
queueCleanerOptions,
contentBlockerOptions,
downloadCleanerOptions,
Cache,
filenameEvaluator,
Striker,
notifier,
dryRunInterceptor,
hardlinkFileService
);
}
public void Dispose()
{
// Cleanup if needed
}
}
@@ -1,214 +0,0 @@
using Common.Configuration.DownloadCleaner;
using Domain.Enums;
using Domain.Models.Cache;
using Infrastructure.Helpers;
using Infrastructure.Verticals.Context;
using Infrastructure.Verticals.DownloadClient;
using NSubstitute;
using NSubstitute.ClearExtensions;
using Shouldly;
namespace Infrastructure.Tests.Verticals.DownloadClient;
public class DownloadServiceTests : IClassFixture<DownloadServiceFixture>
{
private readonly DownloadServiceFixture _fixture;
public DownloadServiceTests(DownloadServiceFixture fixture)
{
_fixture = fixture;
_fixture.Cache.ClearSubstitute();
_fixture.Striker.ClearSubstitute();
}
public class ResetStrikesOnProgressTests : DownloadServiceTests
{
public ResetStrikesOnProgressTests(DownloadServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void WhenStalledStrikeDisabled_ShouldNotResetStrikes()
{
// Arrange
TestDownloadService sut = _fixture.CreateSut(queueCleanerConfig: new()
{
Enabled = true,
RunSequentially = true,
StalledResetStrikesOnProgress = false,
});
// Act
sut.ResetStalledStrikesOnProgress("test-hash", 100);
// Assert
_fixture.Cache.ReceivedCalls().ShouldBeEmpty();
}
[Fact]
public void WhenProgressMade_ShouldResetStrikes()
{
// Arrange
const string hash = "test-hash";
StalledCacheItem stalledCacheItem = new StalledCacheItem { Downloaded = 100 };
_fixture.Cache.TryGetValue(Arg.Any<object>(), out Arg.Any<object?>())
.Returns(x =>
{
x[1] = stalledCacheItem;
return true;
});
TestDownloadService sut = _fixture.CreateSut();
// Act
sut.ResetStalledStrikesOnProgress(hash, 200);
// Assert
_fixture.Cache.Received(1).Remove(CacheKeys.Strike(StrikeType.Stalled, hash));
}
[Fact]
public void WhenNoProgress_ShouldNotResetStrikes()
{
// Arrange
const string hash = "test-hash";
StalledCacheItem stalledCacheItem = new StalledCacheItem { Downloaded = 200 };
_fixture.Cache
.TryGetValue(Arg.Any<object>(), out Arg.Any<object?>())
.Returns(x =>
{
x[1] = stalledCacheItem;
return true;
});
TestDownloadService sut = _fixture.CreateSut();
// Act
sut.ResetStalledStrikesOnProgress(hash, 100);
// Assert
_fixture.Cache.DidNotReceive().Remove(Arg.Any<object>());
}
}
public class StrikeAndCheckLimitTests : DownloadServiceTests
{
public StrikeAndCheckLimitTests(DownloadServiceFixture fixture) : base(fixture)
{
}
}
public class ShouldCleanDownloadTests : DownloadServiceTests
{
public ShouldCleanDownloadTests(DownloadServiceFixture fixture) : base(fixture)
{
ContextProvider.Set("downloadName", "test-download");
}
[Fact]
public void WhenRatioAndMinSeedTimeReached_ShouldReturnTrue()
{
// Arrange
CleanCategory category = new()
{
Name = "test",
MaxRatio = 1.0,
MinSeedTime = 1,
MaxSeedTime = -1
};
const double ratio = 1.5;
TimeSpan seedingTime = TimeSpan.FromHours(2);
TestDownloadService sut = _fixture.CreateSut();
// Act
var result = sut.ShouldCleanDownload(ratio, seedingTime, category);
// Assert
result.ShouldSatisfyAllConditions(
() => result.ShouldClean.ShouldBeTrue(),
() => result.Reason.ShouldBe(CleanReason.MaxRatioReached)
);
}
[Fact]
public void WhenRatioReachedAndMinSeedTimeNotReached_ShouldReturnFalse()
{
// Arrange
CleanCategory category = new()
{
Name = "test",
MaxRatio = 1.0,
MinSeedTime = 3,
MaxSeedTime = -1
};
const double ratio = 1.5;
TimeSpan seedingTime = TimeSpan.FromHours(2);
TestDownloadService sut = _fixture.CreateSut();
// Act
var result = sut.ShouldCleanDownload(ratio, seedingTime, category);
// Assert
result.ShouldSatisfyAllConditions(
() => result.ShouldClean.ShouldBeFalse(),
() => result.Reason.ShouldBe(CleanReason.None)
);
}
[Fact]
public void WhenMaxSeedTimeReached_ShouldReturnTrue()
{
// Arrange
CleanCategory category = new()
{
Name = "test",
MaxRatio = -1,
MinSeedTime = 0,
MaxSeedTime = 1
};
const double ratio = 0.5;
TimeSpan seedingTime = TimeSpan.FromHours(2);
TestDownloadService sut = _fixture.CreateSut();
// Act
SeedingCheckResult result = sut.ShouldCleanDownload(ratio, seedingTime, category);
// Assert
result.ShouldSatisfyAllConditions(
() => result.ShouldClean.ShouldBeTrue(),
() => result.Reason.ShouldBe(CleanReason.MaxSeedTimeReached)
);
}
[Fact]
public void WhenNeitherConditionMet_ShouldReturnFalse()
{
// Arrange
CleanCategory category = new()
{
Name = "test",
MaxRatio = 2.0,
MinSeedTime = 0,
MaxSeedTime = 3
};
const double ratio = 1.0;
TimeSpan seedingTime = TimeSpan.FromHours(1);
TestDownloadService sut = _fixture.CreateSut();
// Act
var result = sut.ShouldCleanDownload(ratio, seedingTime, category);
// Assert
result.ShouldSatisfyAllConditions(
() => result.ShouldClean.ShouldBeFalse(),
() => result.Reason.ShouldBe(CleanReason.None)
);
}
}
}
@@ -1,54 +0,0 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.QueueCleaner;
using Domain.Enums;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.DownloadClient;
using Infrastructure.Verticals.Files;
using Infrastructure.Verticals.ItemStriker;
using Infrastructure.Verticals.Notifications;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Infrastructure.Tests.Verticals.DownloadClient;
public class TestDownloadService : DownloadService
{
public TestDownloadService(
ILogger<DownloadService> logger,
IOptions<QueueCleanerConfig> queueCleanerConfig,
IOptions<ContentBlockerConfig> contentBlockerConfig,
IOptions<DownloadCleanerConfig> downloadCleanerConfig,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
INotificationPublisher notifier,
IDryRunInterceptor dryRunInterceptor,
IHardLinkFileService hardLinkFileService
) : base(
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService
)
{
}
public override void Dispose() { }
public override Task LoginAsync() => Task.CompletedTask;
public override Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads) => Task.FromResult(new DownloadCheckResult());
public override Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, BlocklistType blocklistType,
ConcurrentBag<string> patterns, ConcurrentBag<Regex> regexes, IReadOnlyList<string> ignoredDownloads) => Task.FromResult(new BlockFilesResult());
public override Task DeleteDownload(string hash) => Task.CompletedTask;
public override Task CreateCategoryAsync(string name) => Task.CompletedTask;
public override Task<List<object>?> GetSeedingDownloads() => Task.FromResult<List<object>?>(null);
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) => null;
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories) => null;
public override Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads) => Task.CompletedTask;
public override Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads) => Task.CompletedTask;
// Expose protected methods for testing
public new void ResetStalledStrikesOnProgress(string hash, long downloaded) => base.ResetStalledStrikesOnProgress(hash, downloaded);
public new SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, CleanCategory category) => base.ShouldCleanDownload(ratio, seedingTime, category);
}
@@ -1,43 +0,0 @@
using Infrastructure.Helpers;
using Transmission.API.RPC.Entity;
namespace Infrastructure.Extensions;
public static class TransmissionExtensions
{
public static bool ShouldIgnore(this TorrentInfo download, IReadOnlyList<string> ignoredDownloads)
{
foreach (string value in ignoredDownloads)
{
if (download.HashString?.Equals(value, StringComparison.InvariantCultureIgnoreCase) ?? false)
{
return true;
}
if (download.GetCategory().Equals(value, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
bool? hasIgnoredTracker = download.Trackers?
.Any(x => UriService.GetDomain(x.Announce)?.EndsWith(value, StringComparison.InvariantCultureIgnoreCase) ?? false);
if (hasIgnoredTracker is true)
{
return true;
}
}
return false;
}
public static string GetCategory(this TorrentInfo download)
{
if (string.IsNullOrEmpty(download.DownloadDir))
{
return string.Empty;
}
return Path.GetFileName(Path.TrimEndingDirectorySeparator(download.DownloadDir));
}
}
-26
View File
@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />
<ProjectReference Include="..\Domain\Domain.csproj" />
</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" Version="8.3.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
<PackageReference Include="Quartz" Version="3.13.1" />
<PackageReference Include="Scrutor" Version="6.0.1" />
</ItemGroup>
</Project>
@@ -1,77 +0,0 @@
using System.Reflection;
using Common.Attributes;
using Common.Configuration.General;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Infrastructure.Interceptors;
public class DryRunInterceptor : IDryRunInterceptor
{
private readonly ILogger<DryRunInterceptor> _logger;
private readonly DryRunConfig _config;
public DryRunInterceptor(ILogger<DryRunInterceptor> logger, IOptions<DryRunConfig> config)
{
_logger = logger;
_config = config.Value;
}
public void Intercept(Action action)
{
MethodInfo methodInfo = action.Method;
if (IsDryRun(methodInfo))
{
_logger.LogInformation("[DRY RUN] skipping method: {name}", methodInfo.Name);
return;
}
action();
}
public Task InterceptAsync(Delegate action, params object[] parameters)
{
MethodInfo methodInfo = action.Method;
if (IsDryRun(methodInfo))
{
_logger.LogInformation("[DRY RUN] skipping method: {name}", methodInfo.Name);
return Task.CompletedTask;
}
object? result = action.DynamicInvoke(parameters);
if (result is Task task)
{
return task;
}
return Task.CompletedTask;
}
public Task<T?> InterceptAsync<T>(Delegate action, params object[] parameters)
{
MethodInfo methodInfo = action.Method;
if (IsDryRun(methodInfo))
{
_logger.LogInformation("[DRY RUN] skipping method: {name}", methodInfo.Name);
return Task.FromResult(default(T));
}
object? result = action.DynamicInvoke(parameters);
if (result is Task<T?> task)
{
return task;
}
return Task.FromResult(default(T));
}
private bool IsDryRun(MethodInfo method)
{
return method.GetCustomAttributes(typeof(DryRunSafeguardAttribute), true).Any() && _config.IsDryRun;
}
}
@@ -1,10 +0,0 @@
namespace Infrastructure.Interceptors;
public interface IDryRunInterceptor
{
void Intercept(Action action);
Task InterceptAsync(Delegate action, params object[] parameters);
Task<T?> InterceptAsync<T>(Delegate action, params object[] parameters);
}
@@ -1,82 +0,0 @@
using Common.Configuration;
using Infrastructure.Helpers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Infrastructure.Providers;
public sealed class IgnoredDownloadsProvider<T>
where T : IIgnoredDownloadsConfig
{
private readonly ILogger<IgnoredDownloadsProvider<T>> _logger;
private IIgnoredDownloadsConfig _config;
private readonly IMemoryCache _cache;
private DateTime _lastModified = DateTime.MinValue;
public IgnoredDownloadsProvider(ILogger<IgnoredDownloadsProvider<T>> logger, IOptionsMonitor<T> config, IMemoryCache cache)
{
_config = config.CurrentValue;
config.OnChange((newValue) => _config = newValue);
_logger = logger;
_cache = cache;
if (string.IsNullOrEmpty(_config.IgnoredDownloadsPath))
{
return;
}
if (!File.Exists(_config.IgnoredDownloadsPath))
{
throw new FileNotFoundException("file not found", _config.IgnoredDownloadsPath);
}
}
public async Task<IReadOnlyList<string>> GetIgnoredDownloads()
{
if (string.IsNullOrEmpty(_config.IgnoredDownloadsPath))
{
return Array.Empty<string>();
}
FileInfo fileInfo = new(_config.IgnoredDownloadsPath);
if (fileInfo.LastWriteTime > _lastModified ||
!_cache.TryGetValue(CacheKeys.IgnoredDownloads(typeof(T).Name), out IReadOnlyList<string>? ignoredDownloads) ||
ignoredDownloads is null)
{
_lastModified = fileInfo.LastWriteTime;
return await LoadFile();
}
return ignoredDownloads;
}
private async Task<IReadOnlyList<string>> LoadFile()
{
try
{
if (string.IsNullOrEmpty(_config.IgnoredDownloadsPath))
{
return Array.Empty<string>();
}
string[] ignoredDownloads = (await File.ReadAllLinesAsync(_config.IgnoredDownloadsPath))
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToArray();
_cache.Set(CacheKeys.IgnoredDownloads(typeof(T).Name), ignoredDownloads);
_logger.LogInformation("ignored downloads reloaded");
return ignoredDownloads;
}
catch (Exception exception)
{
_logger.LogError(exception, "error while reading ignored downloads file | {file}", _config.IgnoredDownloadsPath);
}
return Array.Empty<string>();
}
}
@@ -1,215 +0,0 @@
using Common.Attributes;
using Common.Configuration.Arr;
using Common.Configuration.Logging;
using Common.Configuration.QueueCleaner;
using Common.Helpers;
using Domain.Enums;
using Domain.Models.Arr;
using Domain.Models.Arr.Queue;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.Arr.Interfaces;
using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
namespace Infrastructure.Verticals.Arr;
public abstract class ArrClient : IArrClient
{
protected readonly ILogger<ArrClient> _logger;
protected readonly HttpClient _httpClient;
protected readonly LoggingConfig _loggingConfig;
protected readonly QueueCleanerConfig _queueCleanerConfig;
protected readonly IStriker _striker;
protected readonly IDryRunInterceptor _dryRunInterceptor;
protected ArrClient(
ILogger<ArrClient> logger,
IHttpClientFactory httpClientFactory,
IOptions<LoggingConfig> loggingConfig,
IOptions<QueueCleanerConfig> queueCleanerConfig,
IStriker striker,
IDryRunInterceptor dryRunInterceptor
)
{
_logger = logger;
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
_loggingConfig = loggingConfig.Value;
_queueCleanerConfig = queueCleanerConfig.Value;
_striker = striker;
_dryRunInterceptor = dryRunInterceptor;
}
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/{GetQueueUrlPath().TrimStart('/')}";
uriBuilder.Query = GetQueueUrlQuery(page);
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
try
{
response.EnsureSuccessStatusCode();
}
catch
{
_logger.LogError("queue list failed | {uri}", uriBuilder.Uri);
throw;
}
string responseBody = await response.Content.ReadAsStringAsync();
QueueListResponse? queueResponse = JsonConvert.DeserializeObject<QueueListResponse>(responseBody);
if (queueResponse is null)
{
throw new Exception($"unrecognized queue list response | {uriBuilder.Uri} | {responseBody}");
}
return queueResponse;
}
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload)
{
if (_queueCleanerConfig.ImportFailedIgnorePrivate && isPrivateDownload)
{
// ignore private trackers
_logger.LogDebug("skip failed import check | download is private | {name}", record.Title);
return false;
}
bool hasWarn() => record.TrackedDownloadStatus
.Equals("warning", StringComparison.InvariantCultureIgnoreCase);
bool isImportBlocked() => record.TrackedDownloadState
.Equals("importBlocked", StringComparison.InvariantCultureIgnoreCase);
bool isImportPending() => record.TrackedDownloadState
.Equals("importPending", StringComparison.InvariantCultureIgnoreCase);
bool isImportFailed() => record.TrackedDownloadState
.Equals("importFailed", StringComparison.InvariantCultureIgnoreCase);
bool isFailedLidarr() => instanceType is InstanceType.Lidarr &&
(record.Status.Equals("failed", StringComparison.InvariantCultureIgnoreCase) ||
record.Status.Equals("completed", StringComparison.InvariantCultureIgnoreCase)) &&
hasWarn();
if (hasWarn() && (isImportBlocked() || isImportPending() || isImportFailed()) || isFailedLidarr())
{
if (HasIgnoredPatterns(record))
{
_logger.LogDebug("skip failed import check | contains ignored pattern | {name}", record.Title);
return false;
}
return await _striker.StrikeAndCheckLimit(
record.DownloadId,
record.Title,
_queueCleanerConfig.ImportFailedMaxStrikes,
StrikeType.ImportFailed
);
}
return false;
}
public virtual async Task DeleteQueueItemAsync(
ArrInstance arrInstance,
QueueRecord record,
bool removeFromClient,
DeleteReason deleteReason
)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/{GetQueueDeleteUrlPath(record.Id).TrimStart('/')}";
uriBuilder.Query = GetQueueDeleteUrlQuery(removeFromClient);
try
{
using HttpRequestMessage request = new(HttpMethod.Delete, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
_logger.LogInformation(
removeFromClient
? "queue item deleted with reason {reason} | {url} | {title}"
: "queue item removed from arr with reason {reason} | {url} | {title}",
deleteReason.ToString(),
arrInstance.Url,
record.Title
);
}
catch
{
_logger.LogError("queue delete failed | {uri} | {title}", uriBuilder.Uri, record.Title);
throw;
}
}
public abstract Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
public virtual bool IsRecordValid(QueueRecord record)
{
if (string.IsNullOrEmpty(record.DownloadId))
{
_logger.LogDebug("skip | download id is null for {title}", record.Title);
return false;
}
return true;
}
protected abstract string GetQueueUrlPath();
protected abstract string GetQueueUrlQuery(int page);
protected abstract string GetQueueDeleteUrlPath(long recordId);
protected abstract string GetQueueDeleteUrlQuery(bool removeFromClient);
protected virtual void SetApiKey(HttpRequestMessage request, string apiKey)
{
request.Headers.Add("x-api-key", apiKey);
}
[DryRunSafeguard]
protected virtual async Task<HttpResponseMessage> SendRequestAsync(HttpRequestMessage request)
{
HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
return response;
}
private bool HasIgnoredPatterns(QueueRecord record)
{
if (_queueCleanerConfig.ImportFailedIgnorePatterns?.Count is null or 0)
{
// no patterns are configured
return false;
}
if (record.StatusMessages?.Count is null or 0)
{
// no status message found
return false;
}
HashSet<string> messages = record.StatusMessages
.SelectMany(x => x.Messages ?? Enumerable.Empty<string>())
.ToHashSet();
record.StatusMessages.Select(x => x.Title)
.ToList()
.ForEach(x => messages.Add(x));
return messages.Any(
m => _queueCleanerConfig.ImportFailedIgnorePatterns.Any(
p => !string.IsNullOrWhiteSpace(p.Trim()) && m.Contains(p, StringComparison.InvariantCultureIgnoreCase)
)
);
}
}
@@ -1,19 +0,0 @@
using Common.Configuration.Arr;
using Domain.Enums;
using Domain.Models.Arr;
using Domain.Models.Arr.Queue;
namespace Infrastructure.Verticals.Arr.Interfaces;
public interface IArrClient
{
Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page);
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload);
Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason);
Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
bool IsRecordValid(QueueRecord record);
}
@@ -1,5 +0,0 @@
namespace Infrastructure.Verticals.Arr.Interfaces;
public interface ILidarrClient : IArrClient
{
}
@@ -1,5 +0,0 @@
namespace Infrastructure.Verticals.Arr.Interfaces;
public interface IRadarrClient : IArrClient
{
}
@@ -1,5 +0,0 @@
namespace Infrastructure.Verticals.Arr.Interfaces;
public interface ISonarrClient : IArrClient
{
}
@@ -1,163 +0,0 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text.RegularExpressions;
using Common.Configuration.Arr;
using Common.Configuration.ContentBlocker;
using Common.Helpers;
using Domain.Enums;
using Infrastructure.Helpers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Infrastructure.Verticals.ContentBlocker;
public sealed class BlocklistProvider
{
private readonly ILogger<BlocklistProvider> _logger;
private readonly SonarrConfig _sonarrConfig;
private readonly RadarrConfig _radarrConfig;
private readonly LidarrConfig _lidarrConfig;
private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
private bool _initialized;
public BlocklistProvider(
ILogger<BlocklistProvider> logger,
IOptions<SonarrConfig> sonarrConfig,
IOptions<RadarrConfig> radarrConfig,
IOptions<LidarrConfig> lidarrConfig,
IMemoryCache cache,
IHttpClientFactory httpClientFactory
)
{
_logger = logger;
_sonarrConfig = sonarrConfig.Value;
_radarrConfig = radarrConfig.Value;
_lidarrConfig = lidarrConfig.Value;
_cache = cache;
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
}
public async Task LoadBlocklistsAsync()
{
if (_initialized)
{
_logger.LogTrace("blocklists already loaded");
return;
}
try
{
await LoadPatternsAndRegexesAsync(_sonarrConfig, InstanceType.Sonarr);
await LoadPatternsAndRegexesAsync(_radarrConfig, InstanceType.Radarr);
await LoadPatternsAndRegexesAsync(_lidarrConfig, InstanceType.Lidarr);
_initialized = true;
}
catch
{
_logger.LogError("failed to load blocklists");
throw;
}
}
public BlocklistType GetBlocklistType(InstanceType instanceType)
{
_cache.TryGetValue(CacheKeys.BlocklistType(instanceType), out BlocklistType? blocklistType);
return blocklistType ?? BlocklistType.Blacklist;
}
public ConcurrentBag<string> GetPatterns(InstanceType instanceType)
{
_cache.TryGetValue(CacheKeys.BlocklistPatterns(instanceType), out ConcurrentBag<string>? patterns);
return patterns ?? [];
}
public ConcurrentBag<Regex> GetRegexes(InstanceType instanceType)
{
_cache.TryGetValue(CacheKeys.BlocklistRegexes(instanceType), out ConcurrentBag<Regex>? regexes);
return regexes ?? [];
}
private async Task LoadPatternsAndRegexesAsync(ArrConfig arrConfig, InstanceType instanceType)
{
if (!arrConfig.Enabled)
{
return;
}
if (string.IsNullOrEmpty(arrConfig.Block.Path))
{
return;
}
string[] filePatterns = await ReadContentAsync(arrConfig.Block.Path);
long startTime = Stopwatch.GetTimestamp();
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
const string regexId = "regex:";
ConcurrentBag<string> patterns = [];
ConcurrentBag<Regex> regexes = [];
Parallel.ForEach(filePatterns, options, pattern =>
{
if (!pattern.StartsWith(regexId))
{
patterns.Add(pattern);
return;
}
pattern = pattern[regexId.Length..];
try
{
Regex regex = new(pattern, RegexOptions.Compiled);
regexes.Add(regex);
}
catch (ArgumentException)
{
_logger.LogWarning("invalid regex | {pattern}", pattern);
}
});
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
_cache.Set(CacheKeys.BlocklistType(instanceType), arrConfig.Block.Type);
_cache.Set(CacheKeys.BlocklistPatterns(instanceType), patterns);
_cache.Set(CacheKeys.BlocklistRegexes(instanceType), regexes);
_logger.LogDebug("loaded {count} patterns", patterns.Count);
_logger.LogDebug("loaded {count} regexes", regexes.Count);
_logger.LogDebug("blocklist loaded in {elapsed} ms | {path}", elapsed.TotalMilliseconds, arrConfig.Block.Path);
}
private async Task<string[]> ReadContentAsync(string path)
{
if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
{
// http(s) url
return await ReadFromUrlAsync(path);
}
if (File.Exists(path))
{
// local file path
return await File.ReadAllLinesAsync(path);
}
throw new ArgumentException($"blocklist not found | {path}");
}
private async Task<string[]> ReadFromUrlAsync(string url)
{
using HttpResponseMessage response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadAsStringAsync())
.Split(['\r','\n'], StringSplitOptions.RemoveEmptyEntries);
}
}
@@ -1,152 +0,0 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Common.Configuration.Arr;
using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadClient;
using Domain.Enums;
using Domain.Models.Arr;
using Domain.Models.Arr.Queue;
using Infrastructure.Providers;
using Infrastructure.Verticals.Arr;
using Infrastructure.Verticals.Arr.Interfaces;
using Infrastructure.Verticals.Context;
using Infrastructure.Verticals.DownloadClient;
using Infrastructure.Verticals.Jobs;
using Infrastructure.Verticals.Notifications;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Serilog.Context;
namespace Infrastructure.Verticals.ContentBlocker;
public sealed class ContentBlocker : GenericHandler
{
private readonly ContentBlockerConfig _config;
private readonly BlocklistProvider _blocklistProvider;
private readonly IgnoredDownloadsProvider<ContentBlockerConfig> _ignoredDownloadsProvider;
public ContentBlocker(
ILogger<ContentBlocker> logger,
IOptions<ContentBlockerConfig> config,
IOptions<DownloadClientConfig> downloadClientConfig,
IOptions<SonarrConfig> sonarrConfig,
IOptions<RadarrConfig> radarrConfig,
IOptions<LidarrConfig> lidarrConfig,
SonarrClient sonarrClient,
RadarrClient radarrClient,
LidarrClient lidarrClient,
ArrQueueIterator arrArrQueueIterator,
BlocklistProvider blocklistProvider,
DownloadServiceFactory downloadServiceFactory,
INotificationPublisher notifier,
IgnoredDownloadsProvider<ContentBlockerConfig> ignoredDownloadsProvider
) : base(
logger, downloadClientConfig,
sonarrConfig, radarrConfig, lidarrConfig,
sonarrClient, radarrClient, lidarrClient,
arrArrQueueIterator, downloadServiceFactory,
notifier
)
{
_config = config.Value;
_blocklistProvider = blocklistProvider;
_ignoredDownloadsProvider = ignoredDownloadsProvider;
}
public override async Task ExecuteAsync()
{
if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None or Common.Enums.DownloadClient.Disabled)
{
_logger.LogWarning("download client is not set");
return;
}
bool blocklistIsConfigured = _sonarrConfig.Enabled && !string.IsNullOrEmpty(_sonarrConfig.Block.Path) ||
_radarrConfig.Enabled && !string.IsNullOrEmpty(_radarrConfig.Block.Path) ||
_lidarrConfig.Enabled && !string.IsNullOrEmpty(_lidarrConfig.Block.Path);
if (!blocklistIsConfigured)
{
_logger.LogWarning("no blocklist is configured");
return;
}
await _blocklistProvider.LoadBlocklistsAsync();
await base.ExecuteAsync();
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
{
IReadOnlyList<string> ignoredDownloads = await _ignoredDownloadsProvider.GetIgnoredDownloads();
using var _ = LogContext.PushProperty("InstanceName", instanceType.ToString());
HashSet<SearchItem> itemsToBeRefreshed = [];
IArrClient arrClient = GetClient(instanceType);
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
// push to context
ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), instance.Url);
ContextProvider.Set(nameof(InstanceType), instanceType);
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
{
var groups = items
.GroupBy(x => x.DownloadId)
.ToList();
foreach (var group in groups)
{
QueueRecord record = group.First();
if (record.Protocol is not "torrent")
{
continue;
}
if (string.IsNullOrEmpty(record.DownloadId))
{
_logger.LogDebug("skip | download id is null for {title}", record.Title);
continue;
}
if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase))
{
_logger.LogInformation("skip | {title} | ignored", record.Title);
continue;
}
// push record to context
ContextProvider.Set(nameof(QueueRecord), record);
_logger.LogDebug("searching unwanted files for {title}", record.Title);
BlockFilesResult result = await _downloadService
.BlockUnwantedFilesAsync(record.DownloadId, blocklistType, patterns, regexes, ignoredDownloads);
if (!result.ShouldRemove)
{
continue;
}
_logger.LogDebug("all files are marked as unwanted | {hash}", record.Title);
itemsToBeRefreshed.Add(GetRecordSearchItem(instanceType, record, group.Count() > 1));
bool removeFromClient = true;
if (result.IsPrivate && !_config.DeletePrivate)
{
removeFromClient = false;
}
await arrClient.DeleteQueueItemAsync(instance, record, removeFromClient, DeleteReason.AllFilesBlocked);
await _notifier.NotifyQueueItemDeleted(removeFromClient, DeleteReason.AllFilesBlocked);
}
});
await arrClient.RefreshItemsAsync(instance, itemsToBeRefreshed);
}
}
@@ -1,24 +0,0 @@
using System.Collections.Immutable;
namespace Infrastructure.Verticals.Context;
public static class ContextProvider
{
private static readonly AsyncLocal<ImmutableDictionary<string, object>> _asyncLocalDict = new();
public static void Set(string key, object value)
{
ImmutableDictionary<string, object> currentDict = _asyncLocalDict.Value ?? ImmutableDictionary<string, object>.Empty;
_asyncLocalDict.Value = currentDict.SetItem(key, value);
}
public static object? Get(string key)
{
return _asyncLocalDict.Value?.TryGetValue(key, out object? value) is true ? value : null;
}
public static T Get<T>(string key) where T : class
{
return Get(key) as T ?? throw new Exception($"failed to get \"{key}\" from context");
}
}
@@ -1,151 +0,0 @@
using Common.Configuration.Arr;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.DownloadClient;
using Domain.Enums;
using Domain.Models.Arr.Queue;
using Infrastructure.Providers;
using Infrastructure.Verticals.Arr;
using Infrastructure.Verticals.Arr.Interfaces;
using Infrastructure.Verticals.DownloadClient;
using Infrastructure.Verticals.Jobs;
using Infrastructure.Verticals.Notifications;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Serilog.Context;
namespace Infrastructure.Verticals.DownloadCleaner;
public sealed class DownloadCleaner : GenericHandler
{
private readonly DownloadCleanerConfig _config;
private readonly IgnoredDownloadsProvider<DownloadCleanerConfig> _ignoredDownloadsProvider;
private readonly HashSet<string> _excludedHashes = [];
private static bool _hardLinkCategoryCreated;
public DownloadCleaner(
ILogger<DownloadCleaner> logger,
IOptions<DownloadCleanerConfig> config,
IOptions<DownloadClientConfig> downloadClientConfig,
IOptions<SonarrConfig> sonarrConfig,
IOptions<RadarrConfig> radarrConfig,
IOptions<LidarrConfig> lidarrConfig,
SonarrClient sonarrClient,
RadarrClient radarrClient,
LidarrClient lidarrClient,
ArrQueueIterator arrArrQueueIterator,
DownloadServiceFactory downloadServiceFactory,
INotificationPublisher notifier,
IgnoredDownloadsProvider<DownloadCleanerConfig> ignoredDownloadsProvider
) : base(
logger, downloadClientConfig,
sonarrConfig, radarrConfig, lidarrConfig,
sonarrClient, radarrClient, lidarrClient,
arrArrQueueIterator, downloadServiceFactory,
notifier
)
{
_config = config.Value;
_config.Validate();
_ignoredDownloadsProvider = ignoredDownloadsProvider;
}
public override async Task ExecuteAsync()
{
if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None or Common.Enums.DownloadClient.Disabled)
{
_logger.LogWarning("download client is not set");
return;
}
bool isUnlinkedEnabled = !string.IsNullOrEmpty(_config.UnlinkedTargetCategory) && _config.UnlinkedCategories?.Count > 0;
bool isCleaningEnabled = _config.Categories?.Count > 0;
if (!isUnlinkedEnabled && !isCleaningEnabled)
{
_logger.LogWarning("{name} is not configured properly", nameof(DownloadCleaner));
return;
}
IReadOnlyList<string> ignoredDownloads = await _ignoredDownloadsProvider.GetIgnoredDownloads();
await _downloadService.LoginAsync();
List<object>? downloads = await _downloadService.GetSeedingDownloads();
if (downloads?.Count is null or 0)
{
_logger.LogDebug("no seeding downloads found");
return;
}
_logger.LogTrace("found {count} seeding downloads", downloads.Count);
List<object>? downloadsToChangeCategory = null;
if (isUnlinkedEnabled)
{
if (!_hardLinkCategoryCreated)
{
_logger.LogDebug("creating category {cat}", _config.UnlinkedTargetCategory);
await _downloadService.CreateCategoryAsync(_config.UnlinkedTargetCategory);
_hardLinkCategoryCreated = true;
}
downloadsToChangeCategory = _downloadService.FilterDownloadsToChangeCategoryAsync(downloads, _config.UnlinkedCategories);
}
// wait for the downloads to appear in the arr queue
await Task.Delay(10 * 1000);
await ProcessArrConfigAsync(_sonarrConfig, InstanceType.Sonarr, true);
await ProcessArrConfigAsync(_radarrConfig, InstanceType.Radarr, true);
await ProcessArrConfigAsync(_lidarrConfig, InstanceType.Lidarr, true);
if (isUnlinkedEnabled)
{
_logger.LogTrace("found {count} potential downloads to change category", downloadsToChangeCategory?.Count);
await _downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory, _excludedHashes, ignoredDownloads);
_logger.LogTrace("finished changing category");
}
if (_config.Categories?.Count is null or 0)
{
return;
}
List<object>? downloadsToClean = _downloadService.FilterDownloadsToBeCleanedAsync(downloads, _config.Categories);
// release unused objects
downloads = null;
_logger.LogTrace("found {count} potential downloads to clean", downloadsToClean?.Count);
await _downloadService.CleanDownloadsAsync(downloadsToClean, _config.Categories, _excludedHashes, ignoredDownloads);
_logger.LogTrace("finished cleaning downloads");
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
{
using var _ = LogContext.PushProperty("InstanceName", instanceType.ToString());
IArrClient arrClient = GetClient(instanceType);
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
{
var groups = items
.Where(x => !string.IsNullOrEmpty(x.DownloadId))
.GroupBy(x => x.DownloadId)
.ToList();
foreach (QueueRecord record in groups.Select(group => group.First()))
{
_excludedHashes.Add(record.DownloadId.ToLowerInvariant());
}
});
}
public override void Dispose()
{
_downloadService.Dispose();
}
}
@@ -1,14 +0,0 @@
namespace Infrastructure.Verticals.DownloadClient;
public sealed record BlockFilesResult
{
/// <summary>
/// True if the download should be removed; otherwise false.
/// </summary>
public bool ShouldRemove { get; set; }
/// <summary>
/// True if the download is private; otherwise false.
/// </summary>
public bool IsPrivate { get; set; }
}
@@ -1,548 +0,0 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Text.RegularExpressions;
using Common.Attributes;
using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.DownloadClient;
using Common.Configuration.QueueCleaner;
using Common.CustomDataTypes;
using Common.Exceptions;
using Domain.Enums;
using Domain.Models.Deluge.Response;
using Infrastructure.Extensions;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.Context;
using Infrastructure.Verticals.Files;
using Infrastructure.Verticals.ItemStriker;
using Infrastructure.Verticals.Notifications;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Infrastructure.Verticals.DownloadClient.Deluge;
public class DelugeService : DownloadService, IDelugeService
{
private readonly DelugeClient _client;
public DelugeService(
ILogger<DelugeService> logger,
IOptions<DelugeConfig> config,
IHttpClientFactory httpClientFactory,
IOptions<QueueCleanerConfig> queueCleanerConfig,
IOptions<ContentBlockerConfig> contentBlockerConfig,
IOptions<DownloadCleanerConfig> downloadCleanerConfig,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
INotificationPublisher notifier,
IDryRunInterceptor dryRunInterceptor,
IHardLinkFileService hardLinkFileService
) : base(
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService
)
{
config.Value.Validate();
_client = new (config, httpClientFactory);
}
public override async Task LoginAsync()
{
await _client.LoginAsync();
if (!await _client.IsConnected() && !await _client.Connect())
{
throw new FatalException("Deluge WebUI is not connected to the daemon");
}
}
/// <inheritdoc/>
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
{
hash = hash.ToLowerInvariant();
DelugeContents? contents = null;
DownloadCheckResult result = new();
DownloadStatus? download = await _client.GetTorrentStatus(hash);
if (download?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return result;
}
result.IsPrivate = download.Private;
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
return result;
}
try
{
contents = await _client.GetTorrentFiles(hash);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
}
bool shouldRemove = contents?.Contents?.Count > 0;
ProcessFiles(contents.Contents, (_, file) =>
{
if (file.Priority > 0)
{
shouldRemove = false;
}
});
if (shouldRemove)
{
// remove if all files are unwanted
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesSkipped;
return result;
}
// remove if download is stuck
(result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download);
return result;
}
/// <inheritdoc/>
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash,
BlocklistType blocklistType,
ConcurrentBag<string> patterns,
ConcurrentBag<Regex> regexes, IReadOnlyList<string> ignoredDownloads)
{
hash = hash.ToLowerInvariant();
DownloadStatus? download = await _client.GetTorrentStatus(hash);
BlockFilesResult result = new();
if (download?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return result;
}
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
return result;
}
result.IsPrivate = download.Private;
if (_contentBlockerConfig.IgnorePrivate && download.Private)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
return result;
}
DelugeContents? contents = null;
try
{
contents = await _client.GetTorrentFiles(hash);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
}
if (contents is null)
{
return result;
}
Dictionary<int, int> priorities = [];
bool hasPriorityUpdates = false;
long totalFiles = 0;
long totalUnwantedFiles = 0;
ProcessFiles(contents.Contents, (name, file) =>
{
totalFiles++;
int priority = file.Priority;
if (file.Priority is 0)
{
totalUnwantedFiles++;
}
if (file.Priority is not 0 && !_filenameEvaluator.IsValid(name, blocklistType, patterns, regexes))
{
totalUnwantedFiles++;
priority = 0;
hasPriorityUpdates = true;
_logger.LogInformation("unwanted file found | {file}", file.Path);
}
priorities.Add(file.Index, priority);
});
if (!hasPriorityUpdates)
{
return result;
}
_logger.LogDebug("changing priorities | torrent {hash}", hash);
List<int> sortedPriorities = priorities
.OrderBy(x => x.Key)
.Select(x => x.Value)
.ToList();
if (totalUnwantedFiles == totalFiles)
{
// Skip marking files as unwanted. The download will be removed completely.
result.ShouldRemove = true;
return result;
}
await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, hash, sortedPriorities);
return result;
}
public override async Task<List<object>?> GetSeedingDownloads()
{
return (await _client.GetStatusForAllTorrents())
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => x.State?.Equals("seeding", StringComparison.InvariantCultureIgnoreCase) is true)
.Cast<object>()
.ToList();
}
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) =>
downloads
?.Cast<DownloadStatus>()
.Where(x => categories.Any(cat => cat.Name.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
.Cast<object>()
.ToList();
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories) =>
downloads
?.Cast<DownloadStatus>()
.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
.Cast<object>()
.ToList();
/// <inheritdoc/>
public override async Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes,
IReadOnlyList<string> ignoredDownloads)
{
if (downloads?.Count is null or 0)
{
return;
}
foreach (DownloadStatus download in downloads)
{
if (string.IsNullOrEmpty(download.Hash))
{
continue;
}
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
{
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
continue;
}
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
continue;
}
CleanCategory? category = categoriesToClean
.FirstOrDefault(x => x.Name.Equals(download.Label, StringComparison.InvariantCultureIgnoreCase));
if (category is null)
{
continue;
}
if (!_downloadCleanerConfig.DeletePrivate && download.Private)
{
_logger.LogDebug("skip | download is private | {name}", download.Name);
continue;
}
ContextProvider.Set("downloadName", download.Name);
ContextProvider.Set("hash", download.Hash);
TimeSpan seedingTime = TimeSpan.FromSeconds(download.SeedingTime);
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, seedingTime, category);
if (!result.ShouldClean)
{
continue;
}
await _dryRunInterceptor.InterceptAsync(DeleteDownload, download.Hash);
_logger.LogInformation(
"download cleaned | {reason} reached | {name}",
result.Reason is CleanReason.MaxRatioReached
? "MAX_RATIO & MIN_SEED_TIME"
: "MAX_SEED_TIME",
download.Name
);
await _notifier.NotifyDownloadCleaned(download.Ratio, seedingTime, category.Name, result.Reason);
}
}
public override async Task CreateCategoryAsync(string name)
{
IReadOnlyList<string> existingLabels = await _client.GetLabels();
if (existingLabels.Contains(name, StringComparer.InvariantCultureIgnoreCase))
{
return;
}
await _dryRunInterceptor.InterceptAsync(CreateLabel, name);
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
{
if (downloads?.Count is null or 0)
{
return;
}
if (!string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir))
{
_hardLinkFileService.PopulateFileCounts(_downloadCleanerConfig.UnlinkedIgnoredRootDir);
}
foreach (DownloadStatus download in downloads.Cast<DownloadStatus>())
{
if (string.IsNullOrEmpty(download.Hash) || string.IsNullOrEmpty(download.Name) || string.IsNullOrEmpty(download.Label))
{
continue;
}
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
{
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
continue;
}
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
continue;
}
ContextProvider.Set("downloadName", download.Name);
ContextProvider.Set("hash", download.Hash);
DelugeContents? contents = null;
try
{
contents = await _client.GetTorrentFiles(download.Hash);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find torrent files for {name}", download.Name);
continue;
}
bool hasHardlinks = false;
ProcessFiles(contents?.Contents, (_, file) =>
{
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.DownloadLocation, file.Path).Split(['\\', '/']));
if (file.Priority <= 0)
{
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
return;
}
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir));
if (hardlinkCount < 0)
{
_logger.LogDebug("skip | could not get file properties | {file}", filePath);
hasHardlinks = true;
return;
}
if (hardlinkCount > 0)
{
hasHardlinks = true;
}
});
if (hasHardlinks)
{
_logger.LogDebug("skip | download has hardlinks | {name}", download.Name);
continue;
}
await _dryRunInterceptor.InterceptAsync(ChangeLabel, download.Hash, _downloadCleanerConfig.UnlinkedTargetCategory);
_logger.LogInformation("category changed for {name}", download.Name);
await _notifier.NotifyCategoryChanged(download.Label, _downloadCleanerConfig.UnlinkedTargetCategory);
download.Label = _downloadCleanerConfig.UnlinkedTargetCategory;
}
}
/// <inheritdoc/>
[DryRunSafeguard]
public override async Task DeleteDownload(string hash)
{
hash = hash.ToLowerInvariant();
await _client.DeleteTorrents([hash]);
}
[DryRunSafeguard]
protected async Task CreateLabel(string name)
{
await _client.CreateLabel(name);
}
[DryRunSafeguard]
protected virtual async Task ChangeFilesPriority(string hash, List<int> sortedPriorities)
{
await _client.ChangeFilesPriority(hash, sortedPriorities);
}
[DryRunSafeguard]
protected virtual async Task ChangeLabel(string hash, string newLabel)
{
await _client.SetTorrentLabel(hash, newLabel);
}
private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(DownloadStatus status)
{
(bool ShouldRemove, DeleteReason Reason) result = await CheckIfSlow(status);
if (result.ShouldRemove)
{
return result;
}
return await CheckIfStuck(status);
}
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(DownloadStatus download)
{
if (_queueCleanerConfig.SlowMaxStrikes is 0)
{
return (false, DeleteReason.None);
}
if (download.State is null || !download.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
{
return (false, DeleteReason.None);
}
if (download.DownloadSpeed <= 0)
{
return (false, DeleteReason.None);
}
if (_queueCleanerConfig.SlowIgnorePrivate && download.Private)
{
// ignore private trackers
_logger.LogDebug("skip slow check | download is private | {name}", download.Name);
return (false, DeleteReason.None);
}
if (download.Size > (_queueCleanerConfig.SlowIgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue))
{
_logger.LogDebug("skip slow check | download is too large | {name}", download.Name);
return (false, DeleteReason.None);
}
ByteSize minSpeed = _queueCleanerConfig.SlowMinSpeedByteSize;
ByteSize currentSpeed = new ByteSize(download.DownloadSpeed);
SmartTimeSpan maxTime = SmartTimeSpan.FromHours(_queueCleanerConfig.SlowMaxTime);
SmartTimeSpan currentTime = SmartTimeSpan.FromSeconds(download.Eta);
return await CheckIfSlow(
download.Hash!,
download.Name!,
minSpeed,
currentSpeed,
maxTime,
currentTime
);
}
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(DownloadStatus status)
{
if (_queueCleanerConfig.StalledMaxStrikes is 0)
{
return (false, DeleteReason.None);
}
if (_queueCleanerConfig.StalledIgnorePrivate && status.Private)
{
// ignore private trackers
_logger.LogDebug("skip stalled check | download is private | {name}", status.Name);
return (false, DeleteReason.None);
}
if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
{
return (false, DeleteReason.None);
}
if (status.Eta > 0)
{
return (false, DeleteReason.None);
}
ResetStalledStrikesOnProgress(status.Hash!, status.TotalDone);
return (await _striker.StrikeAndCheckLimit(status.Hash!, status.Name!, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled), DeleteReason.Stalled);
}
private static void ProcessFiles(Dictionary<string, DelugeFileOrDirectory>? contents, Action<string, DelugeFileOrDirectory> processFile)
{
if (contents is null)
{
return;
}
foreach (var (name, data) in contents)
{
switch (data.Type)
{
case "file":
processFile(name, data);
break;
case "dir" when data.Contents is not null:
// Recurse into subdirectories
ProcessFiles(data.Contents, processFile);
break;
}
}
}
public override void Dispose()
{
}
}
@@ -1,5 +0,0 @@
namespace Infrastructure.Verticals.DownloadClient.Deluge;
public interface IDelugeService : IDownloadService
{
}
Loaded 100 of 1644 files, more files were not shown because too many files have changed in this diff. Show more