Compare commits

..

33 Commits

Author SHA1 Message Date
Flaminel b8ce225ccc Fix Deluge service crashing when download is not found (#97) 2025-03-20 00:09:58 +02:00
Flaminel f21f7388b7 Add download client customizable url base (#43) 2025-03-20 00:09:24 +02:00
Flaminel a1354f231a Add base path support for arrs (#96) 2025-03-20 00:08:51 +02:00
Flaminel 4bc1c33e81 Add option to explicitly disable the download client (#93) 2025-03-19 16:02:46 +02:00
Flaminel 32bcbab523 added docs for FreeBSD 2025-03-19 01:26:04 +02:00
Flaminel b94ae21e11 update permissive blacklist 2025-03-13 10:16:52 +02:00
Flaminel a92ebd75c2 Update docs (#88) 2025-03-11 23:42:21 +02:00
Flaminel e6d3929fc9 Restrict max strikes to a minimum value (#87) 2025-03-11 23:35:07 +02:00
Flaminel a68e13af35 Fix notifications when poster is not found (#89) 2025-03-11 23:34:44 +02:00
Flaminel 324c3ace8f Fix multiple runs on queue cleaner when download cleaner is enabled (#90) 2025-03-11 23:34:27 +02:00
Flaminel 3a9d5d9085 Fix patterns being loaded for disabled arrs (#80) 2025-03-11 23:18:34 +02:00
Flaminel 89a6eaf0ce Disable cleanup on torrent items if download client is not configured (#85) 2025-03-10 00:13:40 +02:00
Flaminel 027c4a0f4d Add option to ignore specific downloads (#79) 2025-03-09 23:38:27 +02:00
Flaminel 81990c6768 fixed missing README link 2025-03-03 22:37:22 +02:00
Flaminel ba02aa0e49 Fix notifications failing when poster image is not set (#78) 2025-03-02 22:48:21 +02:00
Flaminel 5adbdbd920 Fix weird time zone display name on startup (#70) 2025-02-25 21:32:19 +02:00
Flaminel b3b211d956 Add configurable time zone (#69) 2025-02-24 23:21:44 +02:00
Flaminel 279bd6d82d Fix Deluge timeout not being configurable (#68) 2025-02-24 18:32:44 +02:00
Flaminel 5dced28228 fixed errors on download cleaner when download client is none (#67) 2025-02-24 12:43:06 +02:00
Flaminel 51bdaf64e4 Fix interceptor memory leaks (#66) 2025-02-23 17:50:08 +02:00
Flaminel 9c8e0ebedc updated README 2025-02-18 13:16:49 +02:00
Flaminel e1bea8a8c8 updated README 2025-02-17 23:59:36 +02:00
Marius Nechifor a6d3820104 Improve Transmission category detection (#62) 2025-02-17 02:48:27 +02:00
Flaminel 36c793a5fb updated chart values 2025-02-16 17:43:35 +02:00
Flaminel aade8a91c3 fixed dummy download service 2025-02-16 12:17:31 +02:00
Flaminel 3fe7c3de1a added null check for torrent properties 2025-02-16 03:37:50 +02:00
Marius Nechifor 596a5aed8d Add download cleaner and dry run (#58) 2025-02-16 03:20:00 +02:00
Marius Nechifor 19b3675701 Add Notifiarr support (#52) 2025-02-16 03:17:54 +02:00
Flaminel 1713d0fd1e updated README 2025-02-03 23:05:31 +02:00
Flaminel 3a95a302c0 updated issue templates 2025-02-03 20:40:28 +02:00
Marius Nechifor e738ba2334 Fix queue items with no title not being processed (#54) 2025-02-02 18:20:42 +02:00
Marius Nechifor c813215f3e Add more Lidarr checks for failed imports (#48) 2025-01-28 19:10:07 +02:00
Flaminel 0f63a2d271 updated README 2025-01-26 01:36:08 +02:00
119 changed files with 4273 additions and 531 deletions
+4 -8
View File
@@ -14,8 +14,12 @@ body:
options: options:
- label: Reviewed the documentation. - label: Reviewed the documentation.
required: true required: true
- label: Ensured I am using ghcr.io/flmorg/cleanuperr docker repository.
required: true
- label: Ensured I am using the latest version. - label: Ensured I am using the latest version.
required: true required: true
- label: Enabled debug logging.
required: true
- type: textarea - type: textarea
id: what-happened id: what-happened
attributes: attributes:
@@ -23,14 +27,6 @@ body:
description: If applicable, mention what you expected to happen. description: If applicable, mention what you expected to happen.
validations: validations:
required: true required: true
- type: input
id: version
attributes:
label: Version
description: What version of our software are you running?
placeholder: e.g. 1.3.0 or latest
validations:
required: true
- type: dropdown - type: dropdown
id: os id: os
attributes: attributes:
+13
View File
@@ -7,6 +7,19 @@ body:
attributes: attributes:
value: | value: |
If you are experiencing unexpected behavior, please consider submitting a bug report instead. If you are experiencing unexpected behavior, please consider submitting a bug report instead.
- type: checkboxes
id: init
attributes:
label: "Before submitting a help request, I have:"
options:
- label: Reviewed the documentation.
required: true
- label: Ensured I am using ghcr.io/flmorg/cleanuperr docker repository.
required: true
- label: Ensured I am using the latest version.
required: true
- label: Enabled debug logging.
required: true
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
+204 -137
View File
@@ -1,35 +1,83 @@
_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="cleanuperr"></img> cleanuperr
[![Discord](https://img.shields.io/discord/1306721212587573389?color=7289DA&label=Discord&style=for-the-badge&logo=discord)](https://discord.gg/sWggpnmGNY)
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. 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.
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/). 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/).
The tool 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. > [!IMPORTANT]
> **Features:**
> - Strike system to mark stalled or downloads stuck in metadata downloading.
> - Remove and block downloads that reached a maximum number of strikes.
> - 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.
> - Notify on strike or download removal.
> - Ignore certain torrent hashes, categories, tags or trackers from processing.
Refer to the [Environment variables](#Environment-variables) section for detailed configuration instructions and the [Setup](#Setup) section for an in-depth explanation of the cleanup process. 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.
## Key features
- Marks unwanted files as skip/unwanted in the download client.
- Automatically strikes stalled or stuck downloads.
- Removes and blocks downloads that reached the maximum number of strikes or are marked as unwanted by the download client or by cleanuperr and triggers a search for removed downloads.
## Important note
Only the **latest versions** of the following apps are supported, or earlier versions that have the same API as the latest version:
- qBittorrent
- Deluge
- Transmission
- Sonarr
- Radarr
- Lidarr
This tool is actively developed and still a work in progress, so using the `latest` Docker tag may result in breaking changes. Join the Discord server if you want to reach out to me quickly (or just stay updated on new releases) so we can squash those pesky bugs together:
> [!WARNING]
> This tool is actively developed and still a work in progress, so using the `latest` Docker tag may result in breaking changes. Join the Discord server if you want to reach out to me quickly (or just stay updated on new releases) so we can squash those pesky bugs together:
>
> https://discord.gg/sWggpnmGNY > https://discord.gg/sWggpnmGNY
## Table of contents:
- [Naming choice](#naming-choice)
- [Quick Start](#quick-start)
- [How it works](#how-it-works)
- [Content blocker](#1-content-blocker-will)
- [Queue cleaner](#2-queue-cleaner-will)
- [Download cleaner](#3-download-cleaner-will)
- [Setup](#setup-examples)
- [Usage](#usage)
- [Docker](#docker)
- [Environment Variables](#environment-variables)
- [Docker Compose](#docker-compose-example)
- [Windows](#windows)
- [Linux](#linux)
- [MacOS](#macos)
- [FreeBSD](#freebsd)
- [Credits](#credits)
## Naming choice
I've had people asking why it's `cleanuperr` and not `cleanuparr` and that I should change it. This name was intentional.
I've seen a few discussions on this type of naming and I've decided that I didn't deserve the `arr` moniker since `cleanuperr` is not a fork of `NZB.Drone` and it does not have any affiliation with the arrs. I still wanted to keep the naming style close enough though, to suggest a correlation between them.
## Quick Start
> [!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).
> [!TIP]
> Refer to the [Environment variables](#environment-variables) section for detailed configuration instructions and the [Setup examples](#setup-examples) section for an in-depth explanation of the cleanup process.
> [!IMPORTANT]
> Only the **latest versions** of the following apps are supported, or earlier versions that have the same API as the latest version:
> - qBittorrent
> - Deluge
> - Transmission
> - Sonarr
> - Radarr
> - Lidarr
# How it works # How it works
1. **Content blocker** will: #### 1. **Content blocker** will:
- Run every 5 minutes (or configured cron). - Run every 5 minutes (or configured cron).
- Process all items in the *arr queue. - Process all items in the *arr queue.
- Find the corresponding item from the download client for each queue item. - Find the corresponding item from the download client for each queue item.
@@ -40,10 +88,10 @@ This tool is actively developed and still a work in progress, so using the `late
- It will be removed from the *arr's queue and blocked. - It will be removed from the *arr's queue and blocked.
- It will be deleted from the download client. - It will be deleted from the download client.
- A new search will be triggered for the *arr item. - A new search will be triggered for the *arr item.
2. **Queue cleaner** will: #### 2. **Queue cleaner** will:
- Run every 5 minutes (or configured cron, or right after `content blocker`). - Run every 5 minutes (or configured cron, or right after `content blocker`).
- Process all items in the *arr queue. - Process all items in the *arr queue.
- Check each queue item if it is **stalled (download speed is 0)**, **stuck in matadata downloading** or **failed to be imported**. - Check each queue item if it is **stalled (download speed is 0)**, **stuck in metadata downloading** or **failed to be imported**.
- If it is, the item receives a **strike** and will continue to accumulate strikes every time it meets any of these conditions. - If it is, the item receives a **strike** and will continue to accumulate strikes every time it meets any of these conditions.
- Check each queue item if it meets one of the following condition in the download client: - Check each queue item if it meets one of the following condition in the download client:
- **Marked as completed, but 0 bytes have been downloaded** (due to files being blocked by qBittorrent or the **content blocker**). - **Marked as completed, but 0 bytes have been downloaded** (due to files being blocked by qBittorrent or the **content blocker**).
@@ -53,8 +101,11 @@ This tool is actively developed and still a work in progress, so using the `late
- It will be removed from the *arr's queue and blocked. - It will be removed from the *arr's queue and blocked.
- It will be deleted from the download client. - It will be deleted from the download client.
- A new search will be triggered for the *arr item. - A new search will be triggered for the *arr item.
#### 3. **Download cleaner** will:
- Run every hour (or configured cron).
- Automatically clean up downloads that have been seeding for a certain amount of time.
# Setup # Setup examples
## Using qBittorrent's built-in feature (works only with qBittorrent) ## Using qBittorrent's built-in feature (works only with qBittorrent)
@@ -69,7 +120,7 @@ This tool is actively developed and still a work in progress, so using the `late
## Using cleanuperr's blocklist (works with all supported download clients) ## Using cleanuperr's blocklist (works with all supported download clients)
1. Set both `QUEUECLEANER__ENABLED` and `CONTENTBLOCKER__ENABLED` to `true` in your environment variables. 1. Set both `QUEUECLEANER__ENABLED` and `CONTENTBLOCKER__ENABLED` to `true` in your environment variables.
2. Configure and enable either a **blacklist** or a **whitelist** as described in the [Arr variables](#Arr-variables) section. 2. Configure and enable either a **blacklist** or a **whitelist** as described in the [Arr variables](variables.md#Arr-settings) section.
3. Once configured, cleanuperr will perform the following tasks: 3. Once configured, cleanuperr will perform the following tasks:
- Execute the **content blocker** job, as explained in the [How it works](#how-it-works) section. - Execute the **content blocker** job, as explained in the [How it works](#how-it-works) section.
- Execute the **queue cleaner** job, as explained in the [How it works](#how-it-works) section. - Execute the **queue cleaner** job, as explained in the [How it works](#how-it-works) section.
@@ -81,19 +132,45 @@ This tool is actively developed and still a work in progress, so using the `late
3. Optionally set failed import message patterns to ignore using `QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__<NUMBER>`. 3. Optionally set failed import message patterns to ignore using `QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__<NUMBER>`.
4. Set `DOWNLOAD_CLIENT` to `none`. 4. Set `DOWNLOAD_CLIENT` to `none`.
**No other action involving a download client would work (e.g. content blocking, removing stalled downloads, excluding private trackers).** > [!WARNING]
> When `DOWNLOAD_CLIENT=none`, no other action involving a download client would work (e.g. content blocking, removing stalled downloads, excluding private trackers).
## Usage ## Usage
### Docker compose yaml ### <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/brands/docker.svg" height="20" style="vertical-align: middle;"> <span style="vertical-align: middle;">Docker</span>
### **Environment variables**
**Jump to:**
- [General settings](variables.md#general-settings)
- [Queue Cleaner settings](variables.md#queue-cleaner-settings)
- [Content Blocker settings](variables.md#content-blocker-settings)
- [Download Cleaner settings](variables.md#download-cleaner-settings)
- [Download Client settings](variables.md#download-client-settings)
- [Arr settings](variables.md#arr-settings)
- [Notification settings](variables.md#notification-settings)
- [Advanced settings](variables.md#advanced-settings)
### Docker compose example
> [!NOTE]
>
> This example contains all settings and should be modified to fit your needs.
``` ```
version: "3.3" version: "3.3"
services: services:
cleanuperr: cleanuperr:
image: ghcr.io/flmorg/cleanuperr:latest
restart: unless-stopped
volumes: volumes:
- ./cleanuperr/logs:/var/logs - ./cleanuperr/logs:/var/logs
- ./cleanuperr/ignored.txt:/ignored.txt
environment: environment:
- TZ=America/New_York
- DRY_RUN=false
- LOGGING__LOGLEVEL=Information - LOGGING__LOGLEVEL=Information
- LOGGING__FILE__ENABLED=false - LOGGING__FILE__ENABLED=false
- LOGGING__FILE__PATH=/var/logs/ - LOGGING__FILE__PATH=/var/logs/
@@ -101,8 +178,10 @@ services:
- TRIGGERS__QUEUECLEANER=0 0/5 * * * ? - TRIGGERS__QUEUECLEANER=0 0/5 * * * ?
- TRIGGERS__CONTENTBLOCKER=0 0/5 * * * ? - TRIGGERS__CONTENTBLOCKER=0 0/5 * * * ?
- TRIGGERS__DOWNLOADCLEANER=0 0 * * * ?
- QUEUECLEANER__ENABLED=true - QUEUECLEANER__ENABLED=true
- QUEUECLEANER__IGNORED_DOWNLOADS_PATH=/ignored.txt
- QUEUECLEANER__RUNSEQUENTIALLY=true - QUEUECLEANER__RUNSEQUENTIALLY=true
- QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES=5 - QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES=5
- QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE=false - QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE=false
@@ -115,22 +194,38 @@ services:
- QUEUECLEANER__STALLED_DELETE_PRIVATE=false - QUEUECLEANER__STALLED_DELETE_PRIVATE=false
- CONTENTBLOCKER__ENABLED=true - CONTENTBLOCKER__ENABLED=true
- CONTENTBLOCKER__IGNORED_DOWNLOADS_PATH=/ignored.txt
- CONTENTBLOCKER__IGNORE_PRIVATE=false - CONTENTBLOCKER__IGNORE_PRIVATE=false
- CONTENTBLOCKER__DELETE_PRIVATE=false - CONTENTBLOCKER__DELETE_PRIVATE=false
- DOWNLOADCLEANER__ENABLED=true
- DOWNLOADCLEANER__IGNORED_DOWNLOADS_PATH=/ignored.txt
- DOWNLOADCLEANER__DELETE_PRIVATE=false
- DOWNLOADCLEANER__CATEGORIES__0__NAME=tv-sonarr
- DOWNLOADCLEANER__CATEGORIES__0__MAX_RATIO=-1
- DOWNLOADCLEANER__CATEGORIES__0__MIN_SEED_TIME=0
- DOWNLOADCLEANER__CATEGORIES__0__MAX_SEED_TIME=240
- DOWNLOADCLEANER__CATEGORIES__1__NAME=radarr
- DOWNLOADCLEANER__CATEGORIES__1__MAX_RATIO=-1
- DOWNLOADCLEANER__CATEGORIES__1__MIN_SEED_TIME=0
- DOWNLOADCLEANER__CATEGORIES__1__MAX_SEED_TIME=240
- DOWNLOAD_CLIENT=none - DOWNLOAD_CLIENT=none
# OR # OR
# - DOWNLOAD_CLIENT=qBittorrent # - DOWNLOAD_CLIENT=qBittorrent
# - QBITTORRENT__URL=http://localhost:8080 # - QBITTORRENT__URL=http://localhost:8080
# - QBITTORRENT__URL_BASE=myCustomPath
# - QBITTORRENT__USERNAME=user # - QBITTORRENT__USERNAME=user
# - QBITTORRENT__PASSWORD=pass # - QBITTORRENT__PASSWORD=pass
# OR # OR
# - DOWNLOAD_CLIENT=deluge # - DOWNLOAD_CLIENT=deluge
# - DELUGE__URL_BASE=myCustomPath
# - DELUGE__URL=http://localhost:8112 # - DELUGE__URL=http://localhost:8112
# - DELUGE__PASSWORD=testing # - DELUGE__PASSWORD=testing
# OR # OR
# - DOWNLOAD_CLIENT=transmission # - DOWNLOAD_CLIENT=transmission
# - TRANSMISSION__URL=http://localhost:9091 # - TRANSMISSION__URL=http://localhost:9091
# - TRANSMISSION__URL_BASE=myCustomPath
# - TRANSMISSION__USERNAME=test # - TRANSMISSION__USERNAME=test
# - TRANSMISSION__PASSWORD=testing # - TRANSMISSION__PASSWORD=testing
@@ -158,141 +253,113 @@ services:
- LIDARR__INSTANCES__0__APIKEY=secret5 - LIDARR__INSTANCES__0__APIKEY=secret5
- LIDARR__INSTANCES__1__URL=http://radarr:8687 - LIDARR__INSTANCES__1__URL=http://radarr:8687
- LIDARR__INSTANCES__1__APIKEY=secret6 - LIDARR__INSTANCES__1__APIKEY=secret6
image: ghcr.io/flmorg/cleanuperr:latest
restart: unless-stopped - NOTIFIARR__ON_IMPORT_FAILED_STRIKE=true
- NOTIFIARR__ON_STALLED_STRIKE=true
- NOTIFIARR__ON_QUEUE_ITEM_DELETED=true
- NOTIFIARR__ON_DOWNLOAD_CLEANED=true
- NOTIFIARR__API_KEY=notifiarr_secret
- NOTIFIARR__CHANNEL_ID=discord_channel_id
``` ```
## Environment variables ### <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/brands/windows.svg" height="20" style="vertical-align: middle;"> <span style="vertical-align: middle;">Windows</span>
### General variables 1. Download the zip file from [releases](https://github.com/flmorg/cleanuperr/releases).
<details> 2. Extract the zip file into `C:\example\directory`.
<summary>Click here</summary> 3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](#environment-variables).
4. Execute `cleanuperr.exe`.
| Variable | Required | Description | Default value | > [!TIP]
|---|---|---|---| > ### Run as a Windows Service
| LOGGING__LOGLEVEL | No | Can be `Verbose`, `Debug`, `Information`, `Warning`, `Error` or `Fatal`. | `Information` | > Check out this stackoverflow answer on how to do it: https://stackoverflow.com/a/15719678
| LOGGING__FILE__ENABLED | No | Enable or disable logging to file. | false |
| LOGGING__FILE__PATH | No | Directory where to save the log files. | empty |
| LOGGING__ENHANCED | No | Enhance logs whenever possible.<br>A more detailed description is provided. [here](variables.md#LOGGING__ENHANCED) | true |
|||||
| TRIGGERS__QUEUECLEANER | Yes if queue cleaner is enabled | - [Quartz cron trigger](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html).<br>- Can be a max of 6h interval.<br>- **Is ignored if `QUEUECLEANER__RUNSEQUENTIALLY=true` and `CONTENTBLOCKER__ENABLED=true`**. | 0 0/5 * * * ? |
| TRIGGERS__CONTENTBLOCKER | Yes if content blocker is enabled | - [Quartz cron trigger](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html).<br>- Can be a max of 6h interval. | 0 0/5 * * * ? |
|||||
| QUEUECLEANER__ENABLED | No | Enable or disable the queue cleaner. | true |
| QUEUECLEANER__RUNSEQUENTIALLY | No | If set to true, the queue cleaner will run after the content blocker instead of running in parallel, streamlining the cleaning process. | true |
| QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES | No | - After how many strikes should a failed import be removed.<br>- 0 means never. | 0 |
| QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE | No | Whether to ignore failed imports from private trackers. | false |
| QUEUECLEANER__IMPORT_FAILED_DELETE_PRIVATE | No | - Whether to delete failed imports of private downloads from the download client.<br>- Does not have any effect if `QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE` is `true`.<br>- **Set this to `true` if you don't care about seeding, ratio, H&R and potentially losing your tracker account.** | false |
| QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__0 | No | - First pattern to look for when an import is failed.<br>- If the specified message pattern is found, the item is skipped. | empty |
| QUEUECLEANER__STALLED_MAX_STRIKES | No | - After how many strikes should a stalled download be removed.<br>- 0 means never. | 0 |
| QUEUECLEANER__STALLED_RESET_STRIKES_ON_PROGRESS | No | Removes strikes if any download progress was made since last checked. | false |
| QUEUECLEANER__STALLED_IGNORE_PRIVATE | No | Whether to ignore stalled downloads from private trackers. | false |
| QUEUECLEANER__STALLED_DELETE_PRIVATE | No | - Whether to delete stalled private downloads from the download client.<br>- Does not have any effect if `QUEUECLEANER__STALLED_IGNORE_PRIVATE` is `true`.<br>- **Set this to `true` if you don't care about seeding, ratio, H&R and potentially losing your tracker account.** | false |
|||||
| CONTENTBLOCKER__ENABLED | No | Enable or disable the content blocker. | false |
| CONTENTBLOCKER__IGNORE_PRIVATE | No | Whether to ignore downloads from private trackers. | false |
| CONTENTBLOCKER__DELETE_PRIVATE | No | - Whether to delete private downloads that have all files blocked from the download client.<br>- Does not have any effect if `CONTENTBLOCKER__IGNORE_PRIVATE` is `true`.<br>- **Set this to `true` if you don't care about seeding, ratio, H&R and potentially losing your tracker account.** | false |
</details>
### Download client variables ### <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/brands/linux.svg" height="20" style="vertical-align: middle;"> <span style="vertical-align: middle;">Linux</span>
<details>
<summary>Click here</summary>
| Variable | Required | Description | Default value | 1. Download the zip file from [releases](https://github.com/flmorg/cleanuperr/releases).
|---|---|---|---| 2. Extract the zip file into `/example/directory`.
| DOWNLOAD_CLIENT | No | Download client that is used by *arrs<br>Can be `qbittorrent`, `deluge`, `transmission` or `none` | `none` | 3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](#environment-variables).
| QBITTORRENT__URL | No | qBittorrent instance url | http://localhost:8112 | 4. Open a terminal and execute these commands:
| QBITTORRENT__USERNAME | No | qBittorrent user | empty | ```
| QBITTORRENT__PASSWORD | No | qBittorrent password | empty | cd /example/directory
||||| chmod +x cleanuperr
| DELUGE__URL | No | Deluge instance url | http://localhost:8080 | ./cleanuperr
| DELUGE__PASSWORD | No | Deluge password | empty | ```
|||||
| TRANSMISSION__URL | No | Transmission instance url | http://localhost:9091 |
| TRANSMISSION__USERNAME | No | Transmission user | empty |
| TRANSMISSION__PASSWORD | No | Transmission password | empty |
</details>
### Arr variables ### <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/brands/apple.svg" height="20" style="vertical-align: middle;"> <span style="vertical-align: middle;">MacOS</span>
<details>
<summary>Click here</summary>
| Variable | Required | Description | Default value | 1. Download the zip file from [releases](https://github.com/flmorg/cleanuperr/releases).
|---|---|---|---| 2. Extract the zip file into `/example/directory`.
| SONARR__ENABLED | No | Enable or disable Sonarr cleanup | false | 3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](#environment-variables).
| SONARR__BLOCK__TYPE | No | Block type<br>Can be `blacklist` or `whitelist` | `blacklist` | 4. Open a terminal and execute these commands:
| SONARR__BLOCK__PATH | No | Path to the blocklist (local file or url)<br>Needs to be json compatible | empty | ```
| SONARR__SEARCHTYPE | No | What to search for after removing a queue item<br>Can be `Episode`, `Season` or `Series` | `Episode` | cd /example/directory
| SONARR__INSTANCES__0__URL | No | First Sonarr instance url | http://localhost:8989 | chmod +x cleanuperr
| SONARR__INSTANCES__0__APIKEY | No | First Sonarr instance API key | empty | ./cleanuperr
||||| ```
| RADARR__ENABLED | No | Enable or disable Radarr cleanup | false |
| RADARR__BLOCK__TYPE | No | Block type<br>Can be `blacklist` or `whitelist` | `blacklist` |
| RADARR__BLOCK__PATH | No | Path to the blocklist (local file or url)<br>Needs to be json compatible | empty |
| RADARR__INSTANCES__0__URL | No | First Radarr instance url | http://localhost:7878 |
| RADARR__INSTANCES__0__APIKEY | No | First Radarr instance API key | empty |
|||||
| LIDARR__ENABLED | No | Enable or disable LIDARR cleanup | false |
| LIDARR__BLOCK__TYPE | No | Block type<br>Can be `blacklist` or `whitelist` | `blacklist` |
| LIDARR__BLOCK__PATH | No | Path to the blocklist (local file or url)<br>Needs to be json compatible | empty |
| LIDARR__INSTANCES__0__URL | No | First LIDARR instance url | http://localhost:8686 |
| LIDARR__INSTANCES__0__APIKEY | No | First LIDARR instance API key | empty |
</details>
### Advanced variables > [!IMPORTANT]
<details> > Some people have experienced problems when trying to execute cleanuperr on MacOS because the system actively blocked the file for not being signed.
<summary>Click here</summary> > As per [this](), you may need to also execute this command:
> ```
> codesign --sign - --force --preserve-metadata=entitlements,requirements,flags,runtime /example/directory/cleanuperr
> ```
| Variable | Required | Description | Default value | ### <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/brands/freebsd.svg" height="20" style="vertical-align: middle;"> <span style="vertical-align: middle;">FreeBSD</span>
|---|---|---|---|
| HTTP_MAX_RETRIES | No | The number of times to retry a failed HTTP call (to *arrs, download clients etc.) | 0 |
| HTTP_TIMEOUT | No | The number of seconds to wait before failing an HTTP call (to *arrs, download clients etc.) | 100 |
</details>
# 1. Installation:
```
# install dependencies
pkg install -y git icu libinotify libunwind wget
### To be noted # set up the dotnet SDK
cd ~
wget -q https://github.com/Thefrank/dotnet-freebsd-crossbuild/releases/download/v9.0.104-amd64-freebsd-14/dotnet-sdk-9.0.104-freebsd-x64.tar.gz
export DOTNET_ROOT=$(pwd)/.dotnet
mkdir -p "$DOTNET_ROOT" && tar zxf dotnet-sdk-9.0.104-freebsd-x64.tar.gz -C "$DOTNET_ROOT"
export PATH=$PATH:$DOTNET_ROOT:$DOTNET_ROOT/tools
1. The queue cleaner and content blocker can be enabled or disabled separately, if you want to run only one of them. # download NuGet dependencies
2. Only one download client can be enabled at a time. If you have more than one download client, you should deploy multiple instances of cleanuperr. mkdir -p /tmp/nuget
3. The blocklists (blacklist/whitelist) should have a single pattern on each line and supports the following: wget -q -P /tmp/nuget/ https://github.com/Thefrank/dotnet-freebsd-crossbuild/releases/download/v9.0.104-amd64-freebsd-14/Microsoft.AspNetCore.App.Runtime.freebsd-x64.9.0.3.nupkg
``` wget -q -P /tmp/nuget/ https://github.com/Thefrank/dotnet-freebsd-crossbuild/releases/download/v9.0.104-amd64-freebsd-14/Microsoft.NETCore.App.Host.freebsd-x64.9.0.3.nupkg
*example // file name ends with "example" wget -q -P /tmp/nuget/ https://github.com/Thefrank/dotnet-freebsd-crossbuild/releases/download/v9.0.104-amd64-freebsd-14/Microsoft.NETCore.App.Runtime.freebsd-x64.9.0.3.nupkg
example* // file name starts with "example"
*example* // file name has "example" in the name
example // file name is exactly the word "example"
regex:<ANY_REGEX> // regex that needs to be marked at the start of the line with "regex:"
```
4. Multiple Sonarr/Radarr/Lidarr instances can be specified using this format, where `<NUMBER>` starts from `0`:
```
SONARR__INSTANCES__<NUMBER>__URL
SONARR__INSTANCES__<NUMBER>__APIKEY
```
5. Multiple failed import patterns can be specified using this format, where `<NUMBER>` starts from 0:
```
QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__<NUMBER>
```
6. [This blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist) and [this whitelist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/whitelist) can be used for Sonarr and Radarr, but they are not suitable for other *arrs.
#
### Binaries (if you're not using Docker) # add NuGet source
dotnet nuget add source /tmp/nuget --name tmp
1. Download the binaries from [releases](https://github.com/flmorg/cleanuperr/releases). # add GitHub NuGet source
2. Extract them from the zip file. # a PAT (Personal Access Token) can be generated here https://github.com/settings/tokens
3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [above](#environment-variables). dotnet nuget add source --username <YOUR_USERNAME> --password <YOUR_PERSONAL_ACCESS_TOKEN> --store-password-in-clear-text --name flmorg https://nuget.pkg.github.com/flmorg/index.json
```
2. Building:
```
# clone the project
git clone https://github.com/flmorg/cleanuperr.git
cd cleanuperr
### Run as a Windows Service # build and publish the app
dotnet publish code/Executable/Executable.csproj -c Release --self-contained -o artifacts /p:PublishSingleFile=true
Check out this stackoverflow answer on how to do it: https://stackoverflow.com/a/15719678 # move the files to permanent destination
mv artifacts/cleanuperr /example/directory/
mv artifacts/appsettings.json /example/directory/
```
3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](#environment-variables).
4. Run the app:
```
cd /example/directory
chmod +x cleanuperr
./cleanuperr
```
# Credits # Credits
Special thanks for inspiration go to: Special thanks for inspiration go to:
- [ThijmenGThN/swaparr](https://github.com/ThijmenGThN/swaparr) - [ThijmenGThN/swaparr](https://github.com/ThijmenGThN/swaparr)
- [ManiMatter/decluttarr](https://github.com/ManiMatter/decluttarr) - [ManiMatter/decluttarr](https://github.com/ManiMatter/decluttarr)
- [PaeyMoopy/sonarr-radarr-queue-cleaner](https://github.com/PaeyMoopy/sonarr-radarr-queue-cleaner) - [PaeyMoopy/sonarr-radarr-queue-cleaner](https://github.com/PaeyMoopy/sonarr-radarr-queue-cleaner)
- [Sonarr](https://github.com/Sonarr/Sonarr) & [Radarr](https://github.com/Radarr/Radarr) for the logo - [Sonarr](https://github.com/Sonarr/Sonarr) & [Radarr](https://github.com/Radarr/Radarr)
# Buy me a coffee # Buy me a coffee
If I made your life just a tiny bit easier, consider buying 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>
+1
View File
@@ -1,4 +1,5 @@
*.apk *.apk
*.arj
*.bat *.bat
*.bin *.bin
*.bmp *.bmp
+27 -1
View File
@@ -10,6 +10,9 @@ deployment:
repository: ghcr.io/flmorg/cleanuperr repository: ghcr.io/flmorg/cleanuperr
tag: latest tag: latest
env: env:
- name: DRY_RUN
value: "false"
- name: LOGGING__LOGLEVEL - name: LOGGING__LOGLEVEL
value: Debug value: Debug
- name: LOGGING__FILE__ENABLED - name: LOGGING__FILE__ENABLED
@@ -18,6 +21,7 @@ deployment:
value: /var/logs value: /var/logs
- name: LOGGING__ENHANCED - name: LOGGING__ENHANCED
value: "true" value: "true"
- name: TRIGGERS__QUEUECLEANER - name: TRIGGERS__QUEUECLEANER
value: 0 0/5 * * * ? value: 0 0/5 * * * ?
- name: TRIGGERS__CONTENTBLOCKER - name: TRIGGERS__CONTENTBLOCKER
@@ -47,6 +51,9 @@ deployment:
- name: CONTENTBLOCKER__DELETE_PRIVATE - name: CONTENTBLOCKER__DELETE_PRIVATE
value: "false" value: "false"
- name: DOWNLOADCLEANER__ENABLED
value: "false"
- name: DOWNLOAD_CLIENT - name: DOWNLOAD_CLIENT
value: qbittorrent value: qbittorrent
- name: QBITTORRENT__URL - name: QBITTORRENT__URL
@@ -75,6 +82,17 @@ deployment:
value: http://service.radarr-low-res.svc.cluster.local value: http://service.radarr-low-res.svc.cluster.local
- name: RADARR__INSTANCES__1__URL - name: RADARR__INSTANCES__1__URL
value: http://service.radarr-high-res.svc.cluster.local 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: envFromSecret:
- secretName: qbit-auth - secretName: qbit-auth
envs: envs:
@@ -94,6 +112,10 @@ deployment:
key: RDRL_API_KEY key: RDRL_API_KEY
- name: RADARR__INSTANCES__1__APIKEY - name: RADARR__INSTANCES__1__APIKEY
key: RDRH_API_KEY key: RDRH_API_KEY
- secretName: notifiarr-auth
envs:
- name: NOTIFIARR__API_KEY
key: API_KEY
resources: resources:
requests: requests:
cpu: 0m cpu: 0m
@@ -133,4 +155,8 @@ vaultSecrets:
path: secrets/sonarr path: secrets/sonarr
templates: templates:
SNRL_API_KEY: "{% .Secrets.low_api_key %}" SNRL_API_KEY: "{% .Secrets.low_api_key %}"
SNRH_API_KEY: "{% .Secrets.high_api_key %}" SNRH_API_KEY: "{% .Secrets.high_api_key %}"
- name: notifiarr-auth
path: secrets/notifiarr
templates:
API_KEY: "{% .Secrets.passthrough_api_key %}"
@@ -0,0 +1,6 @@
namespace Common.Attributes;
[AttributeUsage(AttributeTargets.Method, Inherited = true)]
public class DryRunSafeguardAttribute : Attribute
{
}
+1 -1
View File
@@ -7,7 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.2" />
<PackageReference Include="Serilog" Version="4.2.0" /> <PackageReference Include="Serilog" Version="4.2.0" />
</ItemGroup> </ItemGroup>
+3 -3
View File
@@ -11,9 +11,9 @@ public abstract record ArrConfig
public required List<ArrInstance> Instances { get; init; } public required List<ArrInstance> Instances { get; init; }
} }
public record Block public readonly record struct Block
{ {
public BlocklistType Type { get; set; } public BlocklistType Type { get; init; }
public string? Path { get; set; } public string? Path { get; init; }
} }
@@ -2,7 +2,7 @@
namespace Common.Configuration.ContentBlocker; namespace Common.Configuration.ContentBlocker;
public sealed record ContentBlockerConfig : IJobConfig public sealed record ContentBlockerConfig : IJobConfig, IIgnoredDownloadsConfig
{ {
public const string SectionName = "ContentBlocker"; public const string SectionName = "ContentBlocker";
@@ -13,6 +13,9 @@ public sealed record ContentBlockerConfig : IJobConfig
[ConfigurationKeyName("DELETE_PRIVATE")] [ConfigurationKeyName("DELETE_PRIVATE")]
public bool DeletePrivate { get; init; } public bool DeletePrivate { get; init; }
[ConfigurationKeyName("IGNORED_DOWNLOADS_PATH")]
public string? IgnoredDownloadsPath { get; init; }
public void Validate() public void Validate()
{ {
@@ -0,0 +1,45 @@
using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadCleaner;
public sealed record Category : 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");
}
}
}
@@ -0,0 +1,39 @@
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<Category>? Categories { get; init; }
[ConfigurationKeyName("DELETE_PRIVATE")]
public bool DeletePrivate { get; init; }
[ConfigurationKeyName("IGNORED_DOWNLOADS_PATH")]
public string? IgnoredDownloadsPath { get; init; }
public void Validate()
{
if (!Enabled)
{
return;
}
if (Categories?.Count is null or 0)
{
throw new ValidationException("no categories configured");
}
if (Categories?.GroupBy(x => x.Name).Any(x => x.Count() > 1) is true)
{
throw new ValidationException("duplicated categories found");
}
Categories?.ForEach(x => x.Validate());
}
}
@@ -1,4 +1,7 @@
namespace Common.Configuration.DownloadClient; using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadClient;
public sealed record DelugeConfig : IConfig public sealed record DelugeConfig : IConfig
{ {
@@ -6,13 +9,16 @@ public sealed record DelugeConfig : IConfig
public Uri? Url { get; init; } public Uri? Url { get; init; }
[ConfigurationKeyName("URL_BASE")]
public string UrlBase { get; init; } = string.Empty;
public string? Password { get; init; } public string? Password { get; init; }
public void Validate() public void Validate()
{ {
if (Url is null) if (Url is null)
{ {
throw new ArgumentNullException(nameof(Url)); throw new ValidationException($"{nameof(Url)} is empty");
} }
} }
} }
@@ -1,4 +1,7 @@
namespace Common.Configuration.DownloadClient; using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadClient;
public sealed class QBitConfig : IConfig public sealed class QBitConfig : IConfig
{ {
@@ -6,6 +9,9 @@ public sealed class QBitConfig : IConfig
public Uri? Url { get; init; } public Uri? Url { get; init; }
[ConfigurationKeyName("URL_BASE")]
public string UrlBase { get; init; } = string.Empty;
public string? Username { get; init; } public string? Username { get; init; }
public string? Password { get; init; } public string? Password { get; init; }
@@ -14,7 +20,7 @@ public sealed class QBitConfig : IConfig
{ {
if (Url is null) if (Url is null)
{ {
throw new ArgumentNullException(nameof(Url)); throw new ValidationException($"{nameof(Url)} is empty");
} }
} }
} }
@@ -1,4 +1,7 @@
namespace Common.Configuration.DownloadClient; using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.DownloadClient;
public record TransmissionConfig : IConfig public record TransmissionConfig : IConfig
{ {
@@ -6,6 +9,9 @@ public record TransmissionConfig : IConfig
public Uri? Url { get; init; } public Uri? Url { get; init; }
[ConfigurationKeyName("URL_BASE")]
public string UrlBase { get; init; } = "transmission";
public string? Username { get; init; } public string? Username { get; init; }
public string? Password { get; init; } public string? Password { get; init; }
@@ -14,7 +20,7 @@ public record TransmissionConfig : IConfig
{ {
if (Url is null) if (Url is null)
{ {
throw new ArgumentNullException(nameof(Url)); throw new ValidationException($"{nameof(Url)} is empty");
} }
} }
} }
@@ -0,0 +1,9 @@
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.General;
public sealed record DryRunConfig
{
[ConfigurationKeyName("DRY_RUN")]
public bool IsDryRun { get; init; }
}
@@ -1,8 +1,9 @@
using Microsoft.Extensions.Configuration; using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.General; namespace Common.Configuration.General;
public class HttpConfig : IConfig public sealed record HttpConfig : IConfig
{ {
[ConfigurationKeyName("HTTP_MAX_RETRIES")] [ConfigurationKeyName("HTTP_MAX_RETRIES")]
public ushort MaxRetries { get; init; } public ushort MaxRetries { get; init; }
@@ -14,7 +15,7 @@ public class HttpConfig : IConfig
{ {
if (Timeout is 0) if (Timeout is 0)
{ {
throw new ArgumentException("HTTP_TIMEOUT must be greater than 0"); throw new ValidationException("HTTP_TIMEOUT must be greater than 0");
} }
} }
} }
@@ -7,4 +7,6 @@ public sealed class TriggersConfig
public required string QueueCleaner { get; init; } public required string QueueCleaner { get; init; }
public required string ContentBlocker { get; init; } public required string ContentBlocker { get; init; }
public required string DownloadCleaner { get; init; }
} }
@@ -0,0 +1,6 @@
namespace Common.Configuration;
public interface IIgnoredDownloadsConfig
{
string? IgnoredDownloadsPath { get; }
}
@@ -0,0 +1,22 @@
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_QUEUE_ITEM_DELETED")]
public bool OnQueueItemDeleted { get; init; }
[ConfigurationKeyName("ON_DOWNLOAD_CLEANED")]
public bool OnDownloadCleaned { get; init; }
public bool IsEnabled => OnImportFailedStrike || OnStalledStrike || OnQueueItemDeleted || OnDownloadCleaned;
public abstract bool IsValid();
}
@@ -1,8 +1,9 @@
using Microsoft.Extensions.Configuration; using Common.Exceptions;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.QueueCleaner; namespace Common.Configuration.QueueCleaner;
public sealed record QueueCleanerConfig : IJobConfig public sealed record QueueCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
{ {
public const string SectionName = "QueueCleaner"; public const string SectionName = "QueueCleaner";
@@ -10,6 +11,9 @@ public sealed record QueueCleanerConfig : IJobConfig
public required bool RunSequentially { get; init; } public required bool RunSequentially { get; init; }
[ConfigurationKeyName("IGNORED_DOWNLOADS_PATH")]
public string? IgnoredDownloadsPath { get; init; }
[ConfigurationKeyName("IMPORT_FAILED_MAX_STRIKES")] [ConfigurationKeyName("IMPORT_FAILED_MAX_STRIKES")]
public ushort ImportFailedMaxStrikes { get; init; } public ushort ImportFailedMaxStrikes { get; init; }
@@ -36,5 +40,14 @@ public sealed record QueueCleanerConfig : IJobConfig
public void Validate() public void Validate()
{ {
if (ImportFailedMaxStrikes is > 0 and < 3)
{
throw new ValidationException("the minimum value for IMPORT_FAILED_MAX_STRIKES must be 3");
}
if (StalledMaxStrikes is > 0 and < 3)
{
throw new ValidationException("the minimum value for STALLED_MAX_STRIKES must be 3");
}
} }
} }
+2 -1
View File
@@ -5,5 +5,6 @@ public enum DownloadClient
QBittorrent, QBittorrent,
Deluge, Deluge,
Transmission, Transmission,
None None,
Disabled
} }
@@ -0,0 +1,12 @@
namespace Common.Exceptions;
public sealed class ValidationException : Exception
{
public ValidationException()
{
}
public ValidationException(string message) : base(message)
{
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Domain.Enums;
public enum CleanReason
{
None,
MaxRatioReached,
MaxSeedTimeReached,
}
+8
View File
@@ -0,0 +1,8 @@
namespace Domain.Enums;
public enum DeleteReason
{
Stalled,
ImportFailed,
AllFilesBlocked
}
+8
View File
@@ -0,0 +1,8 @@
namespace Domain.Models.Arr.Queue;
public record Image
{
public required string CoverType { get; init; }
public required Uri RemoteUrl { get; init; }
}
@@ -0,0 +1,8 @@
namespace Domain.Models.Arr.Queue;
public record LidarrImage
{
public required string CoverType { get; init; }
public required Uri Url { get; init; }
}
@@ -0,0 +1,6 @@
namespace Domain.Models.Arr.Queue;
public sealed record QueueAlbum
{
public List<LidarrImage> Images { get; init; } = [];
}
@@ -0,0 +1,6 @@
namespace Domain.Models.Arr.Queue;
public sealed record QueueMovie
{
public List<Image> Images { get; init; } = [];
}
@@ -7,14 +7,20 @@ public sealed record QueueRecord
public long EpisodeId { get; init; } public long EpisodeId { get; init; }
public long SeasonNumber { get; init; } public long SeasonNumber { get; init; }
public QueueSeries? Series { get; init; }
// Radarr // Radarr
public long MovieId { get; init; } public long MovieId { get; init; }
public QueueSeries? Movie { get; init; }
// Lidarr // Lidarr
public long ArtistId { get; init; } public long ArtistId { get; init; }
public long AlbumId { get; init; } public long AlbumId { get; init; }
public QueueAlbum? Album { get; init; }
// common // common
public required string Title { get; init; } public required string Title { get; init; }
public string Status { get; init; } public string Status { get; init; }
@@ -0,0 +1,6 @@
namespace Domain.Models.Arr.Queue;
public sealed record QueueSeries
{
public List<Image> Images { get; init; } = [];
}
@@ -16,4 +16,18 @@ public sealed record TorrentStatus
[JsonProperty("total_done")] [JsonProperty("total_done")]
public long TotalDone { get; init; } public long TotalDone { get; init; }
public string? Label { get; init; }
[JsonProperty("seeding_time")]
public long SeedingTime { get; init; }
public float Ratio { get; init; }
public required IReadOnlyList<Tracker> Trackers { get; init; }
}
public sealed record Tracker
{
public required Uri Url { get; init; }
} }
@@ -1,6 +1,8 @@
using Common.Configuration.Arr; using Common.Configuration.Arr;
using Common.Configuration.ContentBlocker; using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.DownloadClient; using Common.Configuration.DownloadClient;
using Common.Configuration.General;
using Common.Configuration.Logging; using Common.Configuration.Logging;
using Common.Configuration.QueueCleaner; using Common.Configuration.QueueCleaner;
@@ -10,8 +12,10 @@ public static class ConfigurationDI
{ {
public static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) => public static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) =>
services services
.Configure<DryRunConfig>(configuration)
.Configure<QueueCleanerConfig>(configuration.GetSection(QueueCleanerConfig.SectionName)) .Configure<QueueCleanerConfig>(configuration.GetSection(QueueCleanerConfig.SectionName))
.Configure<ContentBlockerConfig>(configuration.GetSection(ContentBlockerConfig.SectionName)) .Configure<ContentBlockerConfig>(configuration.GetSection(ContentBlockerConfig.SectionName))
.Configure<DownloadCleanerConfig>(configuration.GetSection(DownloadCleanerConfig.SectionName))
.Configure<DownloadClientConfig>(configuration) .Configure<DownloadClientConfig>(configuration)
.Configure<QBitConfig>(configuration.GetSection(QBitConfig.SectionName)) .Configure<QBitConfig>(configuration.GetSection(QBitConfig.SectionName))
.Configure<DelugeConfig>(configuration.GetSection(DelugeConfig.SectionName)) .Configure<DelugeConfig>(configuration.GetSection(DelugeConfig.SectionName))
@@ -1,6 +1,7 @@
using Common.Configuration.Logging; using Common.Configuration.Logging;
using Domain.Enums; using Domain.Enums;
using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.DownloadCleaner;
using Infrastructure.Verticals.QueueCleaner; using Infrastructure.Verticals.QueueCleaner;
using Serilog; using Serilog;
using Serilog.Events; using Serilog.Events;
@@ -33,7 +34,7 @@ public static class LoggingDI
const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{instanceNameTemplate} {{@m}}\n{{@x}}"; 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}}"; const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{instanceNameTemplate} {{@m:lj}}\n{{@x}}";
LogEventLevel level = LogEventLevel.Information; LogEventLevel level = LogEventLevel.Information;
List<string> names = [nameof(ContentBlocker), nameof(QueueCleaner)]; List<string> names = [nameof(ContentBlocker), nameof(QueueCleaner), nameof(DownloadCleaner)];
int jobPadding = names.Max(x => x.Length) + 2; int jobPadding = names.Max(x => x.Length) + 2;
names = [InstanceType.Sonarr.ToString(), InstanceType.Radarr.ToString(), InstanceType.Lidarr.ToString()]; names = [InstanceType.Sonarr.ToString(), InstanceType.Radarr.ToString(), InstanceType.Lidarr.ToString()];
int arrPadding = names.Max(x => x.Length) + 2; int arrPadding = names.Max(x => x.Length) + 2;
@@ -63,6 +64,7 @@ public static class LoggingDI
Log.Logger = logConfig Log.Logger = logConfig
.MinimumLevel.Is(level) .MinimumLevel.Is(level)
.MinimumLevel.Override("MassTransit", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information) .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.Extensions.Http", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.Extensions.Http", LogEventLevel.Warning)
.MinimumLevel.Override("Quartz", LogEventLevel.Warning) .MinimumLevel.Override("Quartz", LogEventLevel.Warning)
+26 -2
View File
@@ -2,6 +2,9 @@
using Common.Configuration.General; using Common.Configuration.General;
using Common.Helpers; using Common.Helpers;
using Infrastructure.Verticals.DownloadClient.Deluge; using Infrastructure.Verticals.DownloadClient.Deluge;
using Infrastructure.Verticals.Notifications.Consumers;
using Infrastructure.Verticals.Notifications.Models;
using MassTransit;
using Polly; using Polly;
using Polly.Extensions.Http; using Polly.Extensions.Http;
@@ -16,7 +19,28 @@ public static class MainDI
.AddConfiguration(configuration) .AddConfiguration(configuration)
.AddMemoryCache() .AddMemoryCache()
.AddServices() .AddServices()
.AddQuartzServices(configuration); .AddQuartzServices(configuration)
.AddNotifications(configuration)
.AddMassTransit(config =>
{
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
config.UsingInMemory((context, cfg) =>
{
cfg.ReceiveEndpoint("notification-queue", e =>
{
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
e.ConcurrentMessageLimit = 1;
e.PrefetchCount = 1;
});
});
});
private static IServiceCollection AddHttpClients(this IServiceCollection services, IConfiguration configuration) private static IServiceCollection AddHttpClients(this IServiceCollection services, IConfiguration configuration)
{ {
@@ -38,7 +62,7 @@ public static class MainDI
services services
.AddHttpClient(nameof(DelugeService), x => .AddHttpClient(nameof(DelugeService), x =>
{ {
x.Timeout = TimeSpan.FromSeconds(5); x.Timeout = TimeSpan.FromSeconds(config.Timeout);
}) })
.ConfigurePrimaryHttpMessageHandler(_ => .ConfigurePrimaryHttpMessageHandler(_ =>
{ {
@@ -0,0 +1,16 @@
using Infrastructure.Verticals.Notifications;
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))
.AddTransient<INotifiarrProxy, NotifiarrProxy>()
.AddTransient<INotificationProvider, NotifiarrProvider>()
.AddTransient<INotificationPublisher, NotificationPublisher>()
.AddTransient<INotificationFactory, NotificationFactory>()
.AddTransient<NotificationService>();
}
@@ -1,10 +1,12 @@
using Common.Configuration; using Common.Configuration;
using Common.Configuration.ContentBlocker; using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.General; using Common.Configuration.General;
using Common.Configuration.QueueCleaner; using Common.Configuration.QueueCleaner;
using Common.Helpers; using Common.Helpers;
using Executable.Jobs; using Executable.Jobs;
using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.DownloadCleaner;
using Infrastructure.Verticals.Jobs; using Infrastructure.Verticals.Jobs;
using Infrastructure.Verticals.QueueCleaner; using Infrastructure.Verticals.QueueCleaner;
using Quartz; using Quartz;
@@ -53,12 +55,18 @@ public static class QuartzDI
if (contentBlockerConfig?.Enabled is true && queueCleanerConfig is { Enabled: true, RunSequentially: true }) if (contentBlockerConfig?.Enabled is true && queueCleanerConfig is { Enabled: true, RunSequentially: true })
{ {
q.AddJob<QueueCleaner>(queueCleanerConfig, string.Empty); q.AddJob<QueueCleaner>(queueCleanerConfig, string.Empty);
q.AddJobListener(new JobChainingListener(nameof(QueueCleaner))); q.AddJobListener(new JobChainingListener(nameof(ContentBlocker), nameof(QueueCleaner)));
} }
else else
{ {
q.AddJob<QueueCleaner>(queueCleanerConfig, triggersConfig.QueueCleaner); 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>( private static void AddJob<T>(
@@ -109,7 +117,7 @@ public static class QuartzDI
if (triggerValue > Constants.TriggerMaxLimit) if (triggerValue > Constants.TriggerMaxLimit)
{ {
throw new Exception($"{trigger} should have a fire time of maximum 1 hour"); throw new Exception($"{trigger} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
} }
if (triggerValue > StaticConfiguration.TriggerValue) if (triggerValue > StaticConfiguration.TriggerValue)
@@ -1,5 +1,11 @@
using Infrastructure.Verticals.Arr; using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.QueueCleaner;
using Infrastructure.Interceptors;
using Infrastructure.Providers;
using Infrastructure.Verticals.Arr;
using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.DownloadCleaner;
using Infrastructure.Verticals.DownloadClient; using Infrastructure.Verticals.DownloadClient;
using Infrastructure.Verticals.DownloadClient.Deluge; using Infrastructure.Verticals.DownloadClient.Deluge;
using Infrastructure.Verticals.DownloadClient.QBittorrent; using Infrastructure.Verticals.DownloadClient.QBittorrent;
@@ -13,12 +19,14 @@ public static class ServicesDI
{ {
public static IServiceCollection AddServices(this IServiceCollection services) => public static IServiceCollection AddServices(this IServiceCollection services) =>
services services
.AddTransient<IDryRunInterceptor, DryRunInterceptor>()
.AddTransient<SonarrClient>() .AddTransient<SonarrClient>()
.AddTransient<RadarrClient>() .AddTransient<RadarrClient>()
.AddTransient<LidarrClient>() .AddTransient<LidarrClient>()
.AddTransient<QueueCleaner>() .AddTransient<QueueCleaner>()
.AddTransient<ContentBlocker>() .AddTransient<ContentBlocker>()
.AddTransient<FilenameEvaluator>() .AddTransient<DownloadCleaner>()
.AddTransient<IFilenameEvaluator, FilenameEvaluator>()
.AddTransient<DummyDownloadService>() .AddTransient<DummyDownloadService>()
.AddTransient<QBitService>() .AddTransient<QBitService>()
.AddTransient<DelugeService>() .AddTransient<DelugeService>()
@@ -26,5 +34,8 @@ public static class ServicesDI
.AddTransient<ArrQueueIterator>() .AddTransient<ArrQueueIterator>()
.AddTransient<DownloadServiceFactory>() .AddTransient<DownloadServiceFactory>()
.AddSingleton<BlocklistProvider>() .AddSingleton<BlocklistProvider>()
.AddSingleton<Striker>(); .AddSingleton<IStriker, Striker>()
.AddSingleton<IgnoredDownloadsProvider<QueueCleanerConfig>>()
.AddSingleton<IgnoredDownloadsProvider<ContentBlockerConfig>>()
.AddSingleton<IgnoredDownloadsProvider<DownloadCleanerConfig>>();
} }
+3 -3
View File
@@ -10,9 +10,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.2" />
<PackageReference Include="Quartz" Version="3.13.1" /> <PackageReference Include="Quartz" Version="3.13.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.13.1" /> <PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.13.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.13.1" /> <PackageReference Include="Quartz.Extensions.Hosting" Version="3.13.1" />
+23
View File
@@ -0,0 +1,23 @@
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;
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ namespace Executable.Jobs;
[DisallowConcurrentExecution] [DisallowConcurrentExecution]
public sealed class GenericJob<T> : IJob public sealed class GenericJob<T> : IJob
where T : GenericHandler where T : IHandler
{ {
private readonly ILogger<GenericJob<T>> _logger; private readonly ILogger<GenericJob<T>> _logger;
private readonly T _handler; private readonly T _handler;
+2 -11
View File
@@ -1,4 +1,4 @@
using System.Reflection; using Executable;
using Executable.DependencyInjection; using Executable.DependencyInjection;
var builder = Host.CreateApplicationBuilder(args); var builder = Host.CreateApplicationBuilder(args);
@@ -7,15 +7,6 @@ builder.Services.AddInfrastructure(builder.Configuration);
builder.Logging.AddLogging(builder.Configuration); builder.Logging.AddLogging(builder.Configuration);
var host = builder.Build(); var host = builder.Build();
host.Init();
var logger = host.Services.GetRequiredService<ILogger<Program>>();
var version = Assembly.GetExecutingAssembly().GetName().Version;
logger.LogInformation(
version is null
? "cleanuperr version not detected"
: $"cleanuperr v{version.Major}.{version.Minor}.{version.Build}"
);
host.Run(); host.Run();
+30 -2
View File
@@ -1,4 +1,5 @@
{ {
"DRY_RUN": true,
"HTTP_MAX_RETRIES": 0, "HTTP_MAX_RETRIES": 0,
"HTTP_TIMEOUT": 10, "HTTP_TIMEOUT": 10,
"Logging": { "Logging": {
@@ -11,16 +12,19 @@
}, },
"Triggers": { "Triggers": {
"QueueCleaner": "0/10 * * * * ?", "QueueCleaner": "0/10 * * * * ?",
"ContentBlocker": "0/10 * * * * ?" "ContentBlocker": "0/10 * * * * ?",
"DownloadCleaner": "0/10 * * * * ?"
}, },
"ContentBlocker": { "ContentBlocker": {
"Enabled": true, "Enabled": true,
"IGNORE_PRIVATE": true, "IGNORE_PRIVATE": true,
"DELETE_PRIVATE": false "DELETE_PRIVATE": false,
"IGNORED_DOWNLOADS_PATH": "../test/data/cleanuperr/ignored_downloads"
}, },
"QueueCleaner": { "QueueCleaner": {
"Enabled": true, "Enabled": true,
"RunSequentially": true, "RunSequentially": true,
"IGNORED_DOWNLOADS_PATH": "../test/data/cleanuperr/ignored_downloads",
"IMPORT_FAILED_MAX_STRIKES": 5, "IMPORT_FAILED_MAX_STRIKES": 5,
"IMPORT_FAILED_IGNORE_PRIVATE": true, "IMPORT_FAILED_IGNORE_PRIVATE": true,
"IMPORT_FAILED_DELETE_PRIVATE": false, "IMPORT_FAILED_DELETE_PRIVATE": false,
@@ -32,18 +36,34 @@
"STALLED_IGNORE_PRIVATE": true, "STALLED_IGNORE_PRIVATE": true,
"STALLED_DELETE_PRIVATE": false "STALLED_DELETE_PRIVATE": false
}, },
"DownloadCleaner": {
"Enabled": false,
"DELETE_PRIVATE": false,
"CATEGORIES": [
{
"Name": "tv-sonarr",
"MAX_RATIO": -1,
"MIN_SEED_TIME": 0,
"MAX_SEED_TIME": -1
}
],
"IGNORED_DOWNLOADS_PATH": "../test/data/cleanuperr/ignored_downloads"
},
"DOWNLOAD_CLIENT": "qbittorrent", "DOWNLOAD_CLIENT": "qbittorrent",
"qBittorrent": { "qBittorrent": {
"Url": "http://localhost:8080", "Url": "http://localhost:8080",
"URL_BASE": "",
"Username": "test", "Username": "test",
"Password": "testing" "Password": "testing"
}, },
"Deluge": { "Deluge": {
"Url": "http://localhost:8112", "Url": "http://localhost:8112",
"URL_BASE": "",
"Password": "testing" "Password": "testing"
}, },
"Transmission": { "Transmission": {
"Url": "http://localhost:9091", "Url": "http://localhost:9091",
"URL_BASE": "transmission",
"Username": "test", "Username": "test",
"Password": "testing" "Password": "testing"
}, },
@@ -86,5 +106,13 @@
"ApiKey": "7f677cfdc074414397af53dd633860c5" "ApiKey": "7f677cfdc074414397af53dd633860c5"
} }
] ]
},
"Notifiarr": {
"ON_IMPORT_FAILED_STRIKE": true,
"ON_STALLED_STRIKE": true,
"ON_QUEUE_ITEM_DELETED": true,
"ON_DOWNLOAD_CLEANED": true,
"API_KEY": "",
"CHANNEL_ID": ""
} }
} }
+23 -2
View File
@@ -1,4 +1,5 @@
{ {
"DRY_RUN": false,
"HTTP_MAX_RETRIES": 0, "HTTP_MAX_RETRIES": 0,
"HTTP_TIMEOUT": 100, "HTTP_TIMEOUT": 100,
"Logging": { "Logging": {
@@ -11,15 +12,18 @@
}, },
"Triggers": { "Triggers": {
"QueueCleaner": "0 0/5 * * * ?", "QueueCleaner": "0 0/5 * * * ?",
"ContentBlocker": "0 0/5 * * * ?" "ContentBlocker": "0 0/5 * * * ?",
"DownloadCleaner": "0 0 * * * ?"
}, },
"ContentBlocker": { "ContentBlocker": {
"Enabled": false, "Enabled": false,
"IGNORE_PRIVATE": false "IGNORE_PRIVATE": false,
"IGNORED_DOWNLOADS_PATH": ""
}, },
"QueueCleaner": { "QueueCleaner": {
"Enabled": true, "Enabled": true,
"RunSequentially": true, "RunSequentially": true,
"IGNORED_DOWNLOADS_PATH": "",
"IMPORT_FAILED_MAX_STRIKES": 0, "IMPORT_FAILED_MAX_STRIKES": 0,
"IMPORT_FAILED_IGNORE_PRIVATE": false, "IMPORT_FAILED_IGNORE_PRIVATE": false,
"IMPORT_FAILED_DELETE_PRIVATE": false, "IMPORT_FAILED_DELETE_PRIVATE": false,
@@ -29,18 +33,27 @@
"STALLED_IGNORE_PRIVATE": false, "STALLED_IGNORE_PRIVATE": false,
"STALLED_DELETE_PRIVATE": false "STALLED_DELETE_PRIVATE": false
}, },
"DownloadCleaner": {
"Enabled": false,
"DELETE_PRIVATE": false,
"CATEGORIES": [],
"IGNORED_DOWNLOADS_PATH": ""
},
"DOWNLOAD_CLIENT": "none", "DOWNLOAD_CLIENT": "none",
"qBittorrent": { "qBittorrent": {
"Url": "http://localhost:8080", "Url": "http://localhost:8080",
"URL_BASE": "",
"Username": "", "Username": "",
"Password": "" "Password": ""
}, },
"Deluge": { "Deluge": {
"Url": "http://localhost:8112", "Url": "http://localhost:8112",
"URL_BASE": "",
"Password": "testing" "Password": "testing"
}, },
"Transmission": { "Transmission": {
"Url": "http://localhost:9091", "Url": "http://localhost:9091",
"URL_BASE": "transmission",
"Username": "test", "Username": "test",
"Password": "testing" "Password": "testing"
}, },
@@ -83,5 +96,13 @@
"ApiKey": "" "ApiKey": ""
} }
] ]
},
"Notifiarr": {
"ON_IMPORT_FAILED_STRIKE": false,
"ON_STALLED_STRIKE": false,
"ON_QUEUE_ITEM_DELETED": false,
"ON_DOWNLOAD_CLEANED": false,
"API_KEY": "",
"CHANNEL_ID": ""
} }
} }
@@ -0,0 +1,33 @@
<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>
@@ -0,0 +1,20 @@
using Infrastructure.Verticals.ContentBlocker;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace Infrastructure.Tests.Verticals.ContentBlocker;
public class FilenameEvaluatorFixture
{
public ILogger<FilenameEvaluator> Logger { get; }
public FilenameEvaluatorFixture()
{
Logger = Substitute.For<ILogger<FilenameEvaluator>>();
}
public FilenameEvaluator CreateSut()
{
return new FilenameEvaluator(Logger);
}
}
@@ -0,0 +1,219 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Common.Configuration.ContentBlocker;
using Shouldly;
namespace Infrastructure.Tests.Verticals.ContentBlocker;
public class FilenameEvaluatorTests : IClassFixture<FilenameEvaluatorFixture>
{
private readonly FilenameEvaluatorFixture _fixture;
public FilenameEvaluatorTests(FilenameEvaluatorFixture fixture)
{
_fixture = fixture;
}
public class PatternTests : FilenameEvaluatorTests
{
public PatternTests(FilenameEvaluatorFixture fixture) : base(fixture) { }
[Fact]
public void WhenNoPatterns_ShouldReturnTrue()
{
// Arrange
var sut = _fixture.CreateSut();
var patterns = new ConcurrentBag<string>();
var regexes = new ConcurrentBag<Regex>();
// Act
var result = sut.IsValid("test.txt", BlocklistType.Blacklist, patterns, regexes);
// Assert
result.ShouldBeTrue();
}
[Theory]
[InlineData("test.txt", "test.txt", true)] // Exact match
[InlineData("test.txt", "*.txt", true)] // End wildcard
[InlineData("test.txt", "test.*", true)] // Start wildcard
[InlineData("test.txt", "*test*", true)] // Both wildcards
[InlineData("test.txt", "other.txt", false)] // No match
public void Blacklist_ShouldMatchPatterns(string filename, string pattern, bool shouldBeBlocked)
{
// Arrange
var sut = _fixture.CreateSut();
var patterns = new ConcurrentBag<string> { pattern };
var regexes = new ConcurrentBag<Regex>();
// Act
var result = sut.IsValid(filename, BlocklistType.Blacklist, patterns, regexes);
// Assert
result.ShouldBe(!shouldBeBlocked);
}
[Theory]
[InlineData("test.txt", "test.txt", true)] // Exact match
[InlineData("test.txt", "*.txt", true)] // End wildcard
[InlineData("test.txt", "test.*", true)] // Start wildcard
[InlineData("test.txt", "*test*", true)] // Both wildcards
[InlineData("test.txt", "other.txt", false)] // No match
public void Whitelist_ShouldMatchPatterns(string filename, string pattern, bool shouldBeAllowed)
{
// Arrange
var sut = _fixture.CreateSut();
var patterns = new ConcurrentBag<string> { pattern };
var regexes = new ConcurrentBag<Regex>();
// Act
var result = sut.IsValid(filename, BlocklistType.Whitelist, patterns, regexes);
// Assert
result.ShouldBe(shouldBeAllowed);
}
[Theory]
[InlineData("TEST.TXT", "test.txt")]
[InlineData("test.txt", "TEST.TXT")]
public void ShouldBeCaseInsensitive(string filename, string pattern)
{
// Arrange
var sut = _fixture.CreateSut();
var patterns = new ConcurrentBag<string> { pattern };
var regexes = new ConcurrentBag<Regex>();
// Act
var result = sut.IsValid(filename, BlocklistType.Blacklist, patterns, regexes);
// Assert
result.ShouldBeFalse();
}
[Fact]
public void MultiplePatterns_ShouldMatchAny()
{
// Arrange
var sut = _fixture.CreateSut();
var patterns = new ConcurrentBag<string>
{
"other.txt",
"*.pdf",
"test.*"
};
var regexes = new ConcurrentBag<Regex>();
// Act
var result = sut.IsValid("test.txt", BlocklistType.Blacklist, patterns, regexes);
// Assert
result.ShouldBeFalse();
}
}
public class RegexTests : FilenameEvaluatorTests
{
public RegexTests(FilenameEvaluatorFixture fixture) : base(fixture) { }
[Fact]
public void WhenNoRegexes_ShouldReturnTrue()
{
// Arrange
var sut = _fixture.CreateSut();
var patterns = new ConcurrentBag<string>();
var regexes = new ConcurrentBag<Regex>();
// Act
var result = sut.IsValid("test.txt", BlocklistType.Blacklist, patterns, regexes);
// Assert
result.ShouldBeTrue();
}
[Theory]
[InlineData(@"test\d+\.txt", "test123.txt", true)]
[InlineData(@"test\d+\.txt", "test.txt", false)]
public void Blacklist_ShouldMatchRegexes(string pattern, string filename, bool shouldBeBlocked)
{
// Arrange
var sut = _fixture.CreateSut();
var patterns = new ConcurrentBag<string>();
var regexes = new ConcurrentBag<Regex> { new Regex(pattern, RegexOptions.IgnoreCase) };
// Act
var result = sut.IsValid(filename, BlocklistType.Blacklist, patterns, regexes);
// Assert
result.ShouldBe(!shouldBeBlocked);
}
[Theory]
[InlineData(@"test\d+\.txt", "test123.txt", true)]
[InlineData(@"test\d+\.txt", "test.txt", false)]
public void Whitelist_ShouldMatchRegexes(string pattern, string filename, bool shouldBeAllowed)
{
// Arrange
var sut = _fixture.CreateSut();
var patterns = new ConcurrentBag<string>();
var regexes = new ConcurrentBag<Regex> { new Regex(pattern, RegexOptions.IgnoreCase) };
// Act
var result = sut.IsValid(filename, BlocklistType.Whitelist, patterns, regexes);
// Assert
result.ShouldBe(shouldBeAllowed);
}
[Theory]
[InlineData(@"TEST\d+\.TXT", "test123.txt")]
[InlineData(@"test\d+\.txt", "TEST123.TXT")]
public void ShouldBeCaseInsensitive(string pattern, string filename)
{
// Arrange
var sut = _fixture.CreateSut();
var patterns = new ConcurrentBag<string>();
var regexes = new ConcurrentBag<Regex> { new Regex(pattern, RegexOptions.IgnoreCase) };
// Act
var result = sut.IsValid(filename, BlocklistType.Blacklist, patterns, regexes);
// Assert
result.ShouldBeFalse();
}
}
public class CombinedTests : FilenameEvaluatorTests
{
public CombinedTests(FilenameEvaluatorFixture fixture) : base(fixture) { }
[Fact]
public void WhenBothPatternsAndRegexes_ShouldMatchBoth()
{
// Arrange
var sut = _fixture.CreateSut();
var patterns = new ConcurrentBag<string> { "*.txt" };
var regexes = new ConcurrentBag<Regex> { new Regex(@"test\d+", RegexOptions.IgnoreCase) };
// Act
var result = sut.IsValid("test123.txt", BlocklistType.Blacklist, patterns, regexes);
// Assert
result.ShouldBeFalse();
}
[Fact]
public void WhenPatternMatchesButRegexDoesNot_ShouldReturnFalse()
{
// Arrange
var sut = _fixture.CreateSut();
var patterns = new ConcurrentBag<string> { "*.txt" };
var regexes = new ConcurrentBag<Regex> { new Regex(@"test\d+", RegexOptions.IgnoreCase) };
// Act
var result = sut.IsValid("other.txt", BlocklistType.Whitelist, patterns, regexes);
// Assert
result.ShouldBeFalse();
}
}
}
@@ -0,0 +1,77 @@
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.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>();
return new TestDownloadService(
Logger,
queueCleanerOptions,
contentBlockerOptions,
downloadCleanerOptions,
Cache,
filenameEvaluator,
Striker,
notifier,
dryRunInterceptor
);
}
public void Dispose()
{
// Cleanup if needed
}
}
@@ -0,0 +1,235 @@
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.ResetStrikesOnProgress("test-hash", 100);
// Assert
_fixture.Cache.ReceivedCalls().ShouldBeEmpty();
}
[Fact]
public void WhenProgressMade_ShouldResetStrikes()
{
// Arrange
const string hash = "test-hash";
CacheItem cacheItem = new CacheItem { Downloaded = 100 };
_fixture.Cache.TryGetValue(Arg.Any<object>(), out Arg.Any<object?>())
.Returns(x =>
{
x[1] = cacheItem;
return true;
});
TestDownloadService sut = _fixture.CreateSut();
// Act
sut.ResetStrikesOnProgress(hash, 200);
// Assert
_fixture.Cache.Received(1).Remove(CacheKeys.Strike(StrikeType.Stalled, hash));
}
[Fact]
public void WhenNoProgress_ShouldNotResetStrikes()
{
// Arrange
const string hash = "test-hash";
CacheItem cacheItem = new CacheItem { Downloaded = 200 };
_fixture.Cache
.TryGetValue(Arg.Any<object>(), out Arg.Any<object?>())
.Returns(x =>
{
x[1] = cacheItem;
return true;
});
TestDownloadService sut = _fixture.CreateSut();
// Act
sut.ResetStrikesOnProgress(hash, 100);
// Assert
_fixture.Cache.DidNotReceive().Remove(Arg.Any<object>());
}
}
public class StrikeAndCheckLimitTests : DownloadServiceTests
{
public StrikeAndCheckLimitTests(DownloadServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task ShouldDelegateCallToStriker()
{
// Arrange
const string hash = "test-hash";
const string itemName = "test-item";
_fixture.Striker.StrikeAndCheckLimit(hash, itemName, 3, StrikeType.Stalled)
.Returns(true);
TestDownloadService sut = _fixture.CreateSut();
// Act
bool result = await sut.StrikeAndCheckLimit(hash, itemName);
// Assert
result.ShouldBeTrue();
await _fixture.Striker
.Received(1)
.StrikeAndCheckLimit(hash, itemName, 3, StrikeType.Stalled);
}
}
public class ShouldCleanDownloadTests : DownloadServiceTests
{
public ShouldCleanDownloadTests(DownloadServiceFixture fixture) : base(fixture)
{
ContextProvider.Set("downloadName", "test-download");
}
[Fact]
public void WhenRatioAndMinSeedTimeReached_ShouldReturnTrue()
{
// Arrange
Category 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
Category 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
Category 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
Category 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)
);
}
}
}
@@ -0,0 +1,50 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
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.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
) : base(
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
filenameEvaluator, striker, notifier, dryRunInterceptor
)
{
}
public override void Dispose() { }
public override Task LoginAsync() => Task.CompletedTask;
public override Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads) => Task.FromResult(new StalledResult());
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<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories) => Task.FromResult<List<object>?>(null);
public override Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
IReadOnlyList<string> ignoredDownloads) => Task.CompletedTask;
// Expose protected methods for testing
public new void ResetStrikesOnProgress(string hash, long downloaded) => base.ResetStrikesOnProgress(hash, downloaded);
public new Task<bool> StrikeAndCheckLimit(string hash, string itemName) => base.StrikeAndCheckLimit(hash, itemName);
public new SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, Category category) => base.ShouldCleanDownload(ratio, seedingTime, category);
}
@@ -0,0 +1,29 @@
using Domain.Models.Deluge.Response;
namespace Infrastructure.Extensions;
public static class DelugeExtensions
{
public static bool ShouldIgnore(this TorrentStatus download, IReadOnlyList<string> ignoredDownloads)
{
foreach (string value in ignoredDownloads)
{
if (download.Hash?.Equals(value, StringComparison.InvariantCultureIgnoreCase) ?? false)
{
return true;
}
if (download.Label?.Equals(value, StringComparison.InvariantCultureIgnoreCase) ?? false)
{
return true;
}
if (download.Trackers.Any(x => x.Url.Host.EndsWith(value, StringComparison.InvariantCultureIgnoreCase)))
{
return true;
}
}
return false;
}
}
@@ -0,0 +1,42 @@
using QBittorrent.Client;
namespace Infrastructure.Extensions;
public static class QBitExtensions
{
public static bool ShouldIgnore(this TorrentInfo download, IReadOnlyList<string> ignoredDownloads)
{
foreach (string value in ignoredDownloads)
{
if (download.Hash.Equals(value, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
if (download.Category.Equals(value, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
if (download.Tags.Contains(value, StringComparer.InvariantCultureIgnoreCase))
{
return true;
}
}
return false;
}
public static bool ShouldIgnore(this TorrentTracker tracker, IReadOnlyList<string> ignoredDownloads)
{
foreach (string value in ignoredDownloads)
{
if (tracker.Url.Host.EndsWith(value, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
}
return false;
}
}
@@ -0,0 +1,42 @@
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 => new Uri(x.Announce!).Host.EndsWith(value, StringComparison.InvariantCultureIgnoreCase));
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));
}
}
+2
View File
@@ -11,4 +11,6 @@ public static class CacheKeys
public static string BlocklistRegexes(InstanceType instanceType) => $"{instanceType.ToString()}_regexes"; public static string BlocklistRegexes(InstanceType instanceType) => $"{instanceType.ToString()}_regexes";
public static string Item(string hash) => $"item_{hash}"; public static string Item(string hash) => $"item_{hash}";
public static string IgnoredDownloads(string name) => $"{name}_ignored";
} }
+5 -2
View File
@@ -14,9 +14,12 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FLM.QBittorrent" Version="1.0.0" /> <PackageReference Include="FLM.QBittorrent" Version="1.0.0" />
<PackageReference Include="FLM.Transmission" Version="1.0.2" /> <PackageReference Include="FLM.Transmission" Version="1.0.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" /> <PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.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="Quartz" Version="3.13.1" /> <PackageReference Include="Quartz" Version="3.13.1" />
<PackageReference Include="Scrutor" Version="6.0.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,77 @@
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;
}
}
@@ -0,0 +1,10 @@
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);
}
@@ -0,0 +1,82 @@
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>();
}
}
+47 -27
View File
@@ -1,3 +1,4 @@
using Common.Attributes;
using Common.Configuration.Arr; using Common.Configuration.Arr;
using Common.Configuration.Logging; using Common.Configuration.Logging;
using Common.Configuration.QueueCleaner; using Common.Configuration.QueueCleaner;
@@ -5,6 +6,8 @@ using Common.Helpers;
using Domain.Enums; using Domain.Enums;
using Domain.Models.Arr; using Domain.Models.Arr;
using Domain.Models.Arr.Queue; using Domain.Models.Arr.Queue;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.Arr.Interfaces;
using Infrastructure.Verticals.ItemStriker; using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -12,35 +15,39 @@ using Newtonsoft.Json;
namespace Infrastructure.Verticals.Arr; namespace Infrastructure.Verticals.Arr;
public abstract class ArrClient public abstract class ArrClient : IArrClient
{ {
protected readonly ILogger<ArrClient> _logger; protected readonly ILogger<ArrClient> _logger;
protected readonly HttpClient _httpClient; protected readonly HttpClient _httpClient;
protected readonly LoggingConfig _loggingConfig; protected readonly LoggingConfig _loggingConfig;
protected readonly QueueCleanerConfig _queueCleanerConfig; protected readonly QueueCleanerConfig _queueCleanerConfig;
protected readonly Striker _striker; protected readonly IStriker _striker;
protected readonly IDryRunInterceptor _dryRunInterceptor;
protected ArrClient( protected ArrClient(
ILogger<ArrClient> logger, ILogger<ArrClient> logger,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IOptions<LoggingConfig> loggingConfig, IOptions<LoggingConfig> loggingConfig,
IOptions<QueueCleanerConfig> queueCleanerConfig, IOptions<QueueCleanerConfig> queueCleanerConfig,
Striker striker IStriker striker,
IDryRunInterceptor dryRunInterceptor
) )
{ {
_logger = logger; _logger = logger;
_striker = striker;
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName); _httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
_loggingConfig = loggingConfig.Value; _loggingConfig = loggingConfig.Value;
_queueCleanerConfig = queueCleanerConfig.Value; _queueCleanerConfig = queueCleanerConfig.Value;
_striker = striker; _striker = striker;
_dryRunInterceptor = dryRunInterceptor;
} }
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page) public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
{ {
Uri uri = new(arrInstance.Url, GetQueueUrlPath(page)); UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/{GetQueueUrlPath().TrimStart('/')}";
uriBuilder.Query = GetQueueUrlQuery(page);
using HttpRequestMessage request = new(HttpMethod.Get, uri); using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request); using HttpResponseMessage response = await _httpClient.SendAsync(request);
@@ -51,7 +58,7 @@ public abstract class ArrClient
} }
catch catch
{ {
_logger.LogError("queue list failed | {uri}", uri); _logger.LogError("queue list failed | {uri}", uriBuilder.Uri);
throw; throw;
} }
@@ -60,13 +67,13 @@ public abstract class ArrClient
if (queueResponse is null) if (queueResponse is null)
{ {
throw new Exception($"unrecognized queue list response | {uri} | {responseBody}"); throw new Exception($"unrecognized queue list response | {uriBuilder.Uri} | {responseBody}");
} }
return queueResponse; return queueResponse;
} }
public virtual bool ShouldRemoveFromQueue(QueueRecord record, bool isPrivateDownload) public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload)
{ {
if (_queueCleanerConfig.ImportFailedIgnorePrivate && isPrivateDownload) if (_queueCleanerConfig.ImportFailedIgnorePrivate && isPrivateDownload)
{ {
@@ -83,8 +90,12 @@ public abstract class ArrClient
.Equals("importPending", StringComparison.InvariantCultureIgnoreCase); .Equals("importPending", StringComparison.InvariantCultureIgnoreCase);
bool isImportFailed() => record.TrackedDownloadState bool isImportFailed() => record.TrackedDownloadState
.Equals("importFailed", StringComparison.InvariantCultureIgnoreCase); .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())) if (hasWarn() && (isImportBlocked() || isImportPending() || isImportFailed()) || isFailedLidarr())
{ {
if (HasIgnoredPatterns(record)) if (HasIgnoredPatterns(record))
{ {
@@ -92,7 +103,7 @@ public abstract class ArrClient
return false; return false;
} }
return _striker.StrikeAndCheckLimit( return await _striker.StrikeAndCheckLimit(
record.DownloadId, record.DownloadId,
record.Title, record.Title,
_queueCleanerConfig.ImportFailedMaxStrikes, _queueCleanerConfig.ImportFailedMaxStrikes,
@@ -105,17 +116,18 @@ public abstract class ArrClient
public virtual async Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient) public virtual async Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient)
{ {
Uri uri = new(arrInstance.Url, GetQueueDeleteUrlPath(record.Id, removeFromClient)); UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/{GetQueueDeleteUrlPath(record.Id).TrimStart('/')}";
using HttpRequestMessage request = new(HttpMethod.Delete, uri); uriBuilder.Query = GetQueueDeleteUrlQuery(removeFromClient);
SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
try try
{ {
response.EnsureSuccessStatusCode(); using HttpRequestMessage request = new(HttpMethod.Delete, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
_logger.LogInformation( _logger.LogInformation(
removeFromClient removeFromClient
? "queue item deleted | {url} | {title}" ? "queue item deleted | {url} | {title}"
@@ -126,7 +138,7 @@ public abstract class ArrClient
} }
catch catch
{ {
_logger.LogError("queue delete failed | {uri} | {title}", uri, record.Title); _logger.LogError("queue delete failed | {uri} | {title}", uriBuilder.Uri, record.Title);
throw; throw;
} }
} }
@@ -141,24 +153,32 @@ public abstract class ArrClient
return false; return false;
} }
if (record.DownloadId.Equals(record.Title, StringComparison.InvariantCultureIgnoreCase))
{
_logger.LogDebug("skip | item is not ready yet | {title}", record.Title);
return false;
}
return true; return true;
} }
protected abstract string GetQueueUrlPath(int page); protected abstract string GetQueueUrlPath();
protected abstract string GetQueueDeleteUrlPath(long recordId, bool removeFromClient); 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) protected virtual void SetApiKey(HttpRequestMessage request, string apiKey)
{ {
request.Headers.Add("x-api-key", 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) private bool HasIgnoredPatterns(QueueRecord record)
{ {
if (_queueCleanerConfig.ImportFailedIgnorePatterns?.Count is null or 0) if (_queueCleanerConfig.ImportFailedIgnorePatterns?.Count is null or 0)
@@ -1,6 +1,7 @@
using Common.Configuration; using Common.Configuration;
using Common.Configuration.Arr; using Common.Configuration.Arr;
using Domain.Models.Arr.Queue; using Domain.Models.Arr.Queue;
using Infrastructure.Verticals.Arr.Interfaces;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Infrastructure.Verticals.Arr; namespace Infrastructure.Verticals.Arr;
@@ -14,7 +15,7 @@ public sealed class ArrQueueIterator
_logger = logger; _logger = logger;
} }
public async Task Iterate(ArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action) public async Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action)
{ {
const ushort maxPage = 100; const ushort maxPage = 100;
ushort page = 1; ushort page = 1;
@@ -0,0 +1,19 @@
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);
Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
bool IsRecordValid(QueueRecord record);
}
@@ -0,0 +1,5 @@
namespace Infrastructure.Verticals.Arr.Interfaces;
public interface ILidarrClient : IArrClient
{
}
@@ -0,0 +1,5 @@
namespace Infrastructure.Verticals.Arr.Interfaces;
public interface IRadarrClient : IArrClient
{
}
@@ -0,0 +1,5 @@
namespace Infrastructure.Verticals.Arr.Interfaces;
public interface ISonarrClient : IArrClient
{
}
@@ -5,6 +5,8 @@ using Common.Configuration.QueueCleaner;
using Domain.Models.Arr; using Domain.Models.Arr;
using Domain.Models.Arr.Queue; using Domain.Models.Arr.Queue;
using Domain.Models.Lidarr; using Domain.Models.Lidarr;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.Arr.Interfaces;
using Infrastructure.Verticals.ItemStriker; using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -12,41 +14,55 @@ using Newtonsoft.Json;
namespace Infrastructure.Verticals.Arr; namespace Infrastructure.Verticals.Arr;
public sealed class LidarrClient : ArrClient public class LidarrClient : ArrClient, ILidarrClient
{ {
public LidarrClient( public LidarrClient(
ILogger<LidarrClient> logger, ILogger<LidarrClient> logger,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IOptions<LoggingConfig> loggingConfig, IOptions<LoggingConfig> loggingConfig,
IOptions<QueueCleanerConfig> queueCleanerConfig, IOptions<QueueCleanerConfig> queueCleanerConfig,
Striker striker IStriker striker,
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker) IDryRunInterceptor dryRunInterceptor
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker, dryRunInterceptor)
{ {
} }
protected override string GetQueueUrlPath(int page) protected override string GetQueueUrlPath()
{ {
return $"/api/v1/queue?page={page}&pageSize=200&includeUnknownArtistItems=true&includeArtist=true&includeAlbum=true"; return "/api/v1/queue";
} }
protected override string GetQueueDeleteUrlPath(long recordId, bool removeFromClient) protected override string GetQueueUrlQuery(int page)
{ {
string path = $"/api/v1/queue/{recordId}?blocklist=true&skipRedownload=true&changeCategory=false"; return $"page={page}&pageSize=200&includeUnknownArtistItems=true&includeArtist=true&includeAlbum=true";
}
path += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false"; protected override string GetQueueDeleteUrlPath(long recordId)
{
return $"/api/v1/queue/{recordId}";
}
return path; protected override string GetQueueDeleteUrlQuery(bool removeFromClient)
{
string query = "blocklist=true&skipRedownload=true&changeCategory=false";
query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return query;
} }
public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items) public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{ {
if (items?.Count is null or 0) return; if (items?.Count is null or 0)
{
return;
}
Uri uri = new(arrInstance.Url, "/api/v1/command"); UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/command";
foreach (var command in GetSearchCommands(items)) foreach (var command in GetSearchCommands(items))
{ {
using HttpRequestMessage request = new(HttpMethod.Post, uri); using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent( request.Content = new StringContent(
JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }), JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }),
Encoding.UTF8, Encoding.UTF8,
@@ -54,13 +70,13 @@ public sealed class LidarrClient : ArrClient
); );
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using var response = await _httpClient.SendAsync(request);
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command); string? logContext = await ComputeCommandLogContextAsync(arrInstance, command);
try try
{ {
response.EnsureSuccessStatusCode(); HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext)); _logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
} }
catch catch
@@ -129,8 +145,11 @@ public sealed class LidarrClient : ArrClient
private async Task<List<Album>?> GetAlbumsAsync(ArrInstance arrInstance, List<long> albumIds) private async Task<List<Album>?> GetAlbumsAsync(ArrInstance arrInstance, List<long> albumIds)
{ {
Uri uri = new(arrInstance.Url, $"api/v1/album?{string.Join('&', albumIds.Select(x => $"albumIds={x}"))}"); UriBuilder uriBuilder = new(arrInstance.Url);
using HttpRequestMessage request = new(HttpMethod.Get, uri); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/album";
uriBuilder.Query = string.Join('&', albumIds.Select(x => $"albumIds={x}"));
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using var response = await _httpClient.SendAsync(request); using var response = await _httpClient.SendAsync(request);
@@ -5,6 +5,8 @@ using Common.Configuration.QueueCleaner;
using Domain.Models.Arr; using Domain.Models.Arr;
using Domain.Models.Arr.Queue; using Domain.Models.Arr.Queue;
using Domain.Models.Radarr; using Domain.Models.Radarr;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.Arr.Interfaces;
using Infrastructure.Verticals.ItemStriker; using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -12,30 +14,40 @@ using Newtonsoft.Json;
namespace Infrastructure.Verticals.Arr; namespace Infrastructure.Verticals.Arr;
public sealed class RadarrClient : ArrClient public class RadarrClient : ArrClient, IRadarrClient
{ {
public RadarrClient( public RadarrClient(
ILogger<ArrClient> logger, ILogger<ArrClient> logger,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IOptions<LoggingConfig> loggingConfig, IOptions<LoggingConfig> loggingConfig,
IOptions<QueueCleanerConfig> queueCleanerConfig, IOptions<QueueCleanerConfig> queueCleanerConfig,
Striker striker IStriker striker,
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker) IDryRunInterceptor dryRunInterceptor
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker, dryRunInterceptor)
{ {
} }
protected override string GetQueueUrlPath(int page) protected override string GetQueueUrlPath()
{ {
return $"/api/v3/queue?page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true"; return "/api/v3/queue";
} }
protected override string GetQueueDeleteUrlPath(long recordId, bool removeFromClient) protected override string GetQueueUrlQuery(int page)
{ {
string path = $"/api/v3/queue/{recordId}?blocklist=true&skipRedownload=true&changeCategory=false"; return $"page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
}
path += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return path; protected override string GetQueueDeleteUrlPath(long recordId)
{
return $"/api/v3/queue/{recordId}";
}
protected override string GetQueueDeleteUrlQuery(bool removeFromClient)
{
string query = "blocklist=true&skipRedownload=true&changeCategory=false";
query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return query;
} }
public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items) public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
@@ -47,14 +59,16 @@ public sealed class RadarrClient : ArrClient
List<long> ids = items.Select(item => item.Id).ToList(); List<long> ids = items.Select(item => item.Id).ToList();
Uri uri = new(arrInstance.Url, "/api/v3/command"); UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
RadarrCommand command = new() RadarrCommand command = new()
{ {
Name = "MoviesSearch", Name = "MoviesSearch",
MovieIds = ids, MovieIds = ids,
}; };
using HttpRequestMessage request = new(HttpMethod.Post, uri); using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent( request.Content = new StringContent(
JsonConvert.SerializeObject(command), JsonConvert.SerializeObject(command),
Encoding.UTF8, Encoding.UTF8,
@@ -62,12 +76,12 @@ public sealed class RadarrClient : ArrClient
); );
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command); string? logContext = await ComputeCommandLogContextAsync(arrInstance, command);
try try
{ {
response.EnsureSuccessStatusCode(); HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext)); _logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
} }
@@ -132,8 +146,10 @@ public sealed class RadarrClient : ArrClient
private async Task<Movie?> GetMovie(ArrInstance arrInstance, long movieId) private async Task<Movie?> GetMovie(ArrInstance arrInstance, long movieId)
{ {
Uri uri = new(arrInstance.Url, $"api/v3/movie/{movieId}"); UriBuilder uriBuilder = new(arrInstance.Url);
using HttpRequestMessage request = new(HttpMethod.Get, uri); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/movie/{movieId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request); using HttpResponseMessage response = await _httpClient.SendAsync(request);
@@ -5,37 +5,50 @@ using Common.Configuration.QueueCleaner;
using Domain.Models.Arr; using Domain.Models.Arr;
using Domain.Models.Arr.Queue; using Domain.Models.Arr.Queue;
using Domain.Models.Sonarr; using Domain.Models.Sonarr;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.Arr.Interfaces;
using Infrastructure.Verticals.ItemStriker; using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Newtonsoft.Json; using Newtonsoft.Json;
using Series = Domain.Models.Sonarr.Series;
namespace Infrastructure.Verticals.Arr; namespace Infrastructure.Verticals.Arr;
public sealed class SonarrClient : ArrClient public class SonarrClient : ArrClient, ISonarrClient
{ {
public SonarrClient( public SonarrClient(
ILogger<SonarrClient> logger, ILogger<SonarrClient> logger,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IOptions<LoggingConfig> loggingConfig, IOptions<LoggingConfig> loggingConfig,
IOptions<QueueCleanerConfig> queueCleanerConfig, IOptions<QueueCleanerConfig> queueCleanerConfig,
Striker striker IStriker striker,
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker) IDryRunInterceptor dryRunInterceptor
) : base(logger, httpClientFactory, loggingConfig, queueCleanerConfig, striker, dryRunInterceptor)
{ {
} }
protected override string GetQueueUrlPath(int page) protected override string GetQueueUrlPath()
{ {
return $"/api/v3/queue?page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true"; return "/api/v3/queue";
} }
protected override string GetQueueDeleteUrlPath(long recordId, bool removeFromClient) protected override string GetQueueUrlQuery(int page)
{ {
string path = $"/api/v3/queue/{recordId}?blocklist=true&skipRedownload=true&changeCategory=false"; return $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
}
path += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false"; protected override string GetQueueDeleteUrlPath(long recordId)
{
return $"/api/v3/queue/{recordId}";
}
return path; protected override string GetQueueDeleteUrlQuery(bool removeFromClient)
{
string query = "blocklist=true&skipRedownload=true&changeCategory=false";
query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false";
return query;
} }
public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items) public override async Task RefreshItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
@@ -45,11 +58,12 @@ public sealed class SonarrClient : ArrClient
return; return;
} }
Uri uri = new(arrInstance.Url, "/api/v3/command"); UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
foreach (SonarrCommand command in GetSearchCommands(items.Cast<SonarrSearchItem>().ToHashSet())) foreach (SonarrCommand command in GetSearchCommands(items.Cast<SonarrSearchItem>().ToHashSet()))
{ {
using HttpRequestMessage request = new(HttpMethod.Post, uri); using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
request.Content = new StringContent( request.Content = new StringContent(
JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }), JsonConvert.SerializeObject(command, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }),
Encoding.UTF8, Encoding.UTF8,
@@ -57,13 +71,13 @@ public sealed class SonarrClient : ArrClient
); );
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request);
string? logContext = await ComputeCommandLogContextAsync(arrInstance, command, command.SearchType); string? logContext = await ComputeCommandLogContextAsync(arrInstance, command, command.SearchType);
try try
{ {
response.EnsureSuccessStatusCode(); HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
response?.Dispose();
_logger.LogInformation("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, true, logContext)); _logger.LogInformation("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, true, logContext));
} }
catch catch
@@ -195,8 +209,11 @@ public sealed class SonarrClient : ArrClient
private async Task<List<Episode>?> GetEpisodesAsync(ArrInstance arrInstance, List<long> episodeIds) private async Task<List<Episode>?> GetEpisodesAsync(ArrInstance arrInstance, List<long> episodeIds)
{ {
Uri uri = new(arrInstance.Url, $"api/v3/episode?{string.Join('&', episodeIds.Select(x => $"episodeIds={x}"))}"); UriBuilder uriBuilder = new(arrInstance.Url);
using HttpRequestMessage request = new(HttpMethod.Get, uri); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episode";
uriBuilder.Query = string.Join('&', episodeIds.Select(x => $"episodeIds={x}"));
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request); using HttpResponseMessage response = await _httpClient.SendAsync(request);
@@ -208,8 +225,10 @@ public sealed class SonarrClient : ArrClient
private async Task<Series?> GetSeriesAsync(ArrInstance arrInstance, long seriesId) private async Task<Series?> GetSeriesAsync(ArrInstance arrInstance, long seriesId)
{ {
Uri uri = new(arrInstance.Url, $"api/v3/series/{seriesId}"); UriBuilder uriBuilder = new(arrInstance.Url);
using HttpRequestMessage request = new(HttpMethod.Get, uri); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/series/{seriesId}";
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey); SetApiKey(request, arrInstance.ApiKey);
using HttpResponseMessage response = await _httpClient.SendAsync(request); using HttpResponseMessage response = await _httpClient.SendAsync(request);
@@ -49,9 +49,9 @@ public sealed class BlocklistProvider
try try
{ {
await LoadPatternsAndRegexesAsync(_sonarrConfig.Block.Type, _sonarrConfig.Block.Path, InstanceType.Sonarr); await LoadPatternsAndRegexesAsync(_sonarrConfig, InstanceType.Sonarr);
await LoadPatternsAndRegexesAsync(_radarrConfig.Block.Type, _radarrConfig.Block.Path, InstanceType.Radarr); await LoadPatternsAndRegexesAsync(_radarrConfig, InstanceType.Radarr);
await LoadPatternsAndRegexesAsync(_lidarrConfig.Block.Type, _lidarrConfig.Block.Path, InstanceType.Lidarr); await LoadPatternsAndRegexesAsync(_lidarrConfig, InstanceType.Lidarr);
_initialized = true; _initialized = true;
} }
@@ -83,14 +83,19 @@ public sealed class BlocklistProvider
return regexes ?? []; return regexes ?? [];
} }
private async Task LoadPatternsAndRegexesAsync(BlocklistType blocklistType, string? blocklistPath, InstanceType instanceType) private async Task LoadPatternsAndRegexesAsync(ArrConfig arrConfig, InstanceType instanceType)
{ {
if (string.IsNullOrEmpty(blocklistPath)) if (!arrConfig.Enabled)
{ {
return; return;
} }
string[] filePatterns = await ReadContentAsync(blocklistPath); if (string.IsNullOrEmpty(arrConfig.Block.Path))
{
return;
}
string[] filePatterns = await ReadContentAsync(arrConfig.Block.Path);
long startTime = Stopwatch.GetTimestamp(); long startTime = Stopwatch.GetTimestamp();
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 }; ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
@@ -121,13 +126,13 @@ public sealed class BlocklistProvider
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime); TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
_cache.Set(CacheKeys.BlocklistType(instanceType), blocklistType); _cache.Set(CacheKeys.BlocklistType(instanceType), arrConfig.Block.Type);
_cache.Set(CacheKeys.BlocklistPatterns(instanceType), patterns); _cache.Set(CacheKeys.BlocklistPatterns(instanceType), patterns);
_cache.Set(CacheKeys.BlocklistRegexes(instanceType), regexes); _cache.Set(CacheKeys.BlocklistRegexes(instanceType), regexes);
_logger.LogDebug("loaded {count} patterns", patterns.Count); _logger.LogDebug("loaded {count} patterns", patterns.Count);
_logger.LogDebug("loaded {count} regexes", regexes.Count); _logger.LogDebug("loaded {count} regexes", regexes.Count);
_logger.LogDebug("blocklist loaded in {elapsed} ms | {path}", elapsed.TotalMilliseconds, blocklistPath); _logger.LogDebug("blocklist loaded in {elapsed} ms | {path}", elapsed.TotalMilliseconds, arrConfig.Block.Path);
} }
private async Task<string[]> ReadContentAsync(string path) private async Task<string[]> ReadContentAsync(string path)
@@ -6,9 +6,13 @@ using Common.Configuration.DownloadClient;
using Domain.Enums; using Domain.Enums;
using Domain.Models.Arr; using Domain.Models.Arr;
using Domain.Models.Arr.Queue; using Domain.Models.Arr.Queue;
using Infrastructure.Providers;
using Infrastructure.Verticals.Arr; using Infrastructure.Verticals.Arr;
using Infrastructure.Verticals.Arr.Interfaces;
using Infrastructure.Verticals.Context;
using Infrastructure.Verticals.DownloadClient; using Infrastructure.Verticals.DownloadClient;
using Infrastructure.Verticals.Jobs; using Infrastructure.Verticals.Jobs;
using Infrastructure.Verticals.Notifications;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Serilog.Context; using Serilog.Context;
@@ -19,7 +23,8 @@ public sealed class ContentBlocker : GenericHandler
{ {
private readonly ContentBlockerConfig _config; private readonly ContentBlockerConfig _config;
private readonly BlocklistProvider _blocklistProvider; private readonly BlocklistProvider _blocklistProvider;
private readonly IgnoredDownloadsProvider<ContentBlockerConfig> _ignoredDownloadsProvider;
public ContentBlocker( public ContentBlocker(
ILogger<ContentBlocker> logger, ILogger<ContentBlocker> logger,
IOptions<ContentBlockerConfig> config, IOptions<ContentBlockerConfig> config,
@@ -32,23 +37,27 @@ public sealed class ContentBlocker : GenericHandler
LidarrClient lidarrClient, LidarrClient lidarrClient,
ArrQueueIterator arrArrQueueIterator, ArrQueueIterator arrArrQueueIterator,
BlocklistProvider blocklistProvider, BlocklistProvider blocklistProvider,
DownloadServiceFactory downloadServiceFactory DownloadServiceFactory downloadServiceFactory,
INotificationPublisher notifier,
IgnoredDownloadsProvider<ContentBlockerConfig> ignoredDownloadsProvider
) : base( ) : base(
logger, downloadClientConfig, logger, downloadClientConfig,
sonarrConfig, radarrConfig, lidarrConfig, sonarrConfig, radarrConfig, lidarrConfig,
sonarrClient, radarrClient, lidarrClient, sonarrClient, radarrClient, lidarrClient,
arrArrQueueIterator, downloadServiceFactory arrArrQueueIterator, downloadServiceFactory,
notifier
) )
{ {
_config = config.Value; _config = config.Value;
_blocklistProvider = blocklistProvider; _blocklistProvider = blocklistProvider;
_ignoredDownloadsProvider = ignoredDownloadsProvider;
} }
public override async Task ExecuteAsync() public override async Task ExecuteAsync()
{ {
if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None) if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None or Common.Enums.DownloadClient.Disabled)
{ {
_logger.LogWarning("download client is set to none"); _logger.LogWarning("download client is not set");
return; return;
} }
@@ -68,13 +77,19 @@ public sealed class ContentBlocker : GenericHandler
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType) protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
{ {
IReadOnlyList<string> ignoredDownloads = await _ignoredDownloadsProvider.GetIgnoredDownloads();
using var _ = LogContext.PushProperty("InstanceName", instanceType.ToString()); using var _ = LogContext.PushProperty("InstanceName", instanceType.ToString());
HashSet<SearchItem> itemsToBeRefreshed = []; HashSet<SearchItem> itemsToBeRefreshed = [];
ArrClient arrClient = GetClient(instanceType); IArrClient arrClient = GetClient(instanceType);
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType); BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType); ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(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 => await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
{ {
@@ -97,10 +112,19 @@ public sealed class ContentBlocker : GenericHandler
continue; 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); _logger.LogDebug("searching unwanted files for {title}", record.Title);
BlockFilesResult result = await _downloadService BlockFilesResult result = await _downloadService
.BlockUnwantedFilesAsync(record.DownloadId, blocklistType, patterns, regexes); .BlockUnwantedFilesAsync(record.DownloadId, blocklistType, patterns, regexes, ignoredDownloads);
if (!result.ShouldRemove) if (!result.ShouldRemove)
{ {
@@ -119,6 +143,7 @@ public sealed class ContentBlocker : GenericHandler
} }
await arrClient.DeleteQueueItemAsync(instance, record, removeFromClient); await arrClient.DeleteQueueItemAsync(instance, record, removeFromClient);
await _notifier.NotifyQueueItemDeleted(removeFromClient, DeleteReason.AllFilesBlocked);
} }
}); });
@@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging;
namespace Infrastructure.Verticals.ContentBlocker; namespace Infrastructure.Verticals.ContentBlocker;
public sealed class FilenameEvaluator public class FilenameEvaluator : IFilenameEvaluator
{ {
private readonly ILogger<FilenameEvaluator> _logger; private readonly ILogger<FilenameEvaluator> _logger;
@@ -31,7 +31,6 @@ public sealed class FilenameEvaluator
{ {
BlocklistType.Blacklist => !patterns.Any(pattern => MatchesPattern(filename, pattern)), BlocklistType.Blacklist => !patterns.Any(pattern => MatchesPattern(filename, pattern)),
BlocklistType.Whitelist => patterns.Any(pattern => MatchesPattern(filename, pattern)), BlocklistType.Whitelist => patterns.Any(pattern => MatchesPattern(filename, pattern)),
_ => true
}; };
} }
@@ -46,7 +45,6 @@ public sealed class FilenameEvaluator
{ {
BlocklistType.Blacklist => !regexes.Any(regex => regex.IsMatch(filename)), BlocklistType.Blacklist => !regexes.Any(regex => regex.IsMatch(filename)),
BlocklistType.Whitelist => regexes.Any(regex => regex.IsMatch(filename)), BlocklistType.Whitelist => regexes.Any(regex => regex.IsMatch(filename)),
_ => true
}; };
} }
@@ -76,6 +74,6 @@ public sealed class FilenameEvaluator
); );
} }
return filename == pattern; return filename.Equals(pattern, StringComparison.InvariantCultureIgnoreCase);
} }
} }
@@ -0,0 +1,10 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Common.Configuration.ContentBlocker;
namespace Infrastructure.Verticals.ContentBlocker;
public interface IFilenameEvaluator
{
bool IsValid(string filename, BlocklistType type, ConcurrentBag<string> patterns, ConcurrentBag<Regex> regexes);
}
@@ -0,0 +1,24 @@
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");
}
}
@@ -0,0 +1,111 @@
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 = [];
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;
}
if (_config.Categories?.Count is null or 0)
{
_logger.LogWarning("no categories configured");
return;
}
IReadOnlyList<string> ignoredDownloads = await _ignoredDownloadsProvider.GetIgnoredDownloads();
await _downloadService.LoginAsync();
List<object>? downloads = await _downloadService.GetAllDownloadsToBeCleaned(_config.Categories);
if (downloads?.Count is null or 0)
{
_logger.LogDebug("no downloads found in the download client");
return;
}
// 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);
await _downloadService.CleanDownloads(downloads, _config.Categories, _excludedHashes, ignoredDownloads);
}
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();
}
}
@@ -16,9 +16,24 @@ public sealed class DelugeClient
private readonly DelugeConfig _config; private readonly DelugeConfig _config;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private static readonly IReadOnlyList<string> Fields =
[
"hash",
"state",
"name",
"eta",
"private",
"total_done",
"label",
"seeding_time",
"ratio",
"trackers"
];
public DelugeClient(IOptions<DelugeConfig> config, IHttpClientFactory httpClientFactory) public DelugeClient(IOptions<DelugeConfig> config, IHttpClientFactory httpClientFactory)
{ {
_config = config.Value; _config = config.Value;
_config.Validate();
_httpClient = httpClientFactory.CreateClient(nameof(DelugeService)); _httpClient = httpClientFactory.CreateClient(nameof(DelugeService));
} }
@@ -62,6 +77,39 @@ public sealed class DelugeClient
await ListTorrentsExtended(new Dictionary<string, string> { { "hash", hash } }); await ListTorrentsExtended(new Dictionary<string, string> { { "hash", hash } });
return torrents.FirstOrDefault(); return torrents.FirstOrDefault();
} }
public async Task<TorrentStatus?> GetTorrentStatus(string hash)
{
try
{
return await SendRequest<TorrentStatus?>(
"web.get_torrent_status",
hash,
Fields
);
}
catch (DelugeClientException e)
{
// Deluge returns an error when the torrent is not found
if (e.Message == "AttributeError: 'NoneType' object has no attribute 'call'")
{
return null;
}
throw;
}
}
public async Task<List<TorrentStatus>?> GetStatusForAllTorrents()
{
Dictionary<string, TorrentStatus>? downloads = await SendRequest<Dictionary<string, TorrentStatus>?>(
"core.get_torrents_status",
"",
Fields
);
return downloads?.Values.ToList();
}
public async Task<DelugeContents?> GetTorrentFiles(string hash) public async Task<DelugeContents?> GetTorrentFiles(string hash)
{ {
@@ -78,17 +126,21 @@ public sealed class DelugeClient
await SendRequest<DelugeResponse<object>>("core.set_torrent_options", hash, filePriorities); await SendRequest<DelugeResponse<object>>("core.set_torrent_options", hash, filePriorities);
} }
public async Task<DelugeResponse<object>> DeleteTorrent(string hash) public async Task DeleteTorrents(List<string> hashes)
{ {
return await SendRequest<DelugeResponse<object>>("core.remove_torrents", new List<string> { hash }, true); await SendRequest<DelugeResponse<object>>("core.remove_torrents", hashes, true);
} }
private async Task<String> PostJson(String json) private async Task<String> PostJson(String json)
{ {
StringContent content = new StringContent(json); StringContent content = new StringContent(json);
content.Headers.ContentType = new MediaTypeWithQualityHeaderValue("application/json"); content.Headers.ContentType = new MediaTypeWithQualityHeaderValue("application/json");
var responseMessage = await _httpClient.PostAsync(new Uri(_config.Url, "/json"), content); UriBuilder uriBuilder = new(_config.Url);
uriBuilder.Path = string.IsNullOrEmpty(_config.UrlBase)
? $"{uriBuilder.Path.TrimEnd('/')}/json"
: $"{uriBuilder.Path.TrimEnd('/')}/{_config.UrlBase.TrimStart('/').TrimEnd('/')}/json";
var responseMessage = await _httpClient.PostAsync(uriBuilder.Uri, content);
responseMessage.EnsureSuccessStatusCode(); responseMessage.EnsureSuccessStatusCode();
var responseJson = await responseMessage.Content.ReadAsStringAsync(); var responseJson = await responseMessage.Content.ReadAsStringAsync();
@@ -1,31 +1,44 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Common.Attributes;
using Common.Configuration.ContentBlocker; using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.DownloadClient; using Common.Configuration.DownloadClient;
using Common.Configuration.QueueCleaner; using Common.Configuration.QueueCleaner;
using Domain.Enums;
using Domain.Models.Deluge.Response; using Domain.Models.Deluge.Response;
using Infrastructure.Extensions;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.Context;
using Infrastructure.Verticals.ItemStriker; using Infrastructure.Verticals.ItemStriker;
using Infrastructure.Verticals.Notifications;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Infrastructure.Verticals.DownloadClient.Deluge; namespace Infrastructure.Verticals.DownloadClient.Deluge;
public sealed class DelugeService : DownloadServiceBase public class DelugeService : DownloadService, IDelugeService
{ {
private readonly DelugeClient _client; private readonly DelugeClient _client;
public DelugeService( public DelugeService(
ILogger<DelugeService> logger, ILogger<DelugeService> logger,
IOptions<DelugeConfig> config, IOptions<DelugeConfig> config,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IOptions<QueueCleanerConfig> queueCleanerConfig, IOptions<QueueCleanerConfig> queueCleanerConfig,
IOptions<ContentBlockerConfig> contentBlockerConfig, IOptions<ContentBlockerConfig> contentBlockerConfig,
IOptions<DownloadCleanerConfig> downloadCleanerConfig,
IMemoryCache cache, IMemoryCache cache,
FilenameEvaluator filenameEvaluator, IFilenameEvaluator filenameEvaluator,
Striker striker IStriker striker,
) : base(logger, queueCleanerConfig, contentBlockerConfig, cache, filenameEvaluator, striker) INotificationPublisher notifier,
IDryRunInterceptor dryRunInterceptor
) : base(
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
filenameEvaluator, striker, notifier, dryRunInterceptor
)
{ {
config.Value.Validate(); config.Value.Validate();
_client = new (config, httpClientFactory); _client = new (config, httpClientFactory);
@@ -37,20 +50,26 @@ public sealed class DelugeService : DownloadServiceBase
} }
/// <inheritdoc/> /// <inheritdoc/>
public override async Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash) public override async Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
{ {
hash = hash.ToLowerInvariant(); hash = hash.ToLowerInvariant();
DelugeContents? contents = null; DelugeContents? contents = null;
StalledResult result = new(); StalledResult result = new();
TorrentStatus? status = await GetTorrentStatus(hash); TorrentStatus? download = await _client.GetTorrentStatus(hash);
if (status?.Hash is null) if (download?.Hash is null)
{ {
_logger.LogDebug("failed to find torrent {hash} in the download client", hash); _logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return result; return result;
} }
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
return result;
}
try try
{ {
@@ -71,37 +90,54 @@ public sealed class DelugeService : DownloadServiceBase
} }
}); });
result.ShouldRemove = shouldRemove || IsItemStuckAndShouldRemove(status); if (shouldRemove)
result.IsPrivate = status.Private; {
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
result.ShouldRemove = shouldRemove || await IsItemStuckAndShouldRemove(download);
result.IsPrivate = download.Private;
if (!shouldRemove && result.ShouldRemove)
{
result.DeleteReason = DeleteReason.Stalled;
}
return result; return result;
} }
/// <inheritdoc/> /// <inheritdoc/>
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync( public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash,
string hash,
BlocklistType blocklistType, BlocklistType blocklistType,
ConcurrentBag<string> patterns, ConcurrentBag<string> patterns,
ConcurrentBag<Regex> regexes ConcurrentBag<Regex> regexes, IReadOnlyList<string> ignoredDownloads)
)
{ {
hash = hash.ToLowerInvariant(); hash = hash.ToLowerInvariant();
TorrentStatus? status = await GetTorrentStatus(hash); TorrentStatus? download = await _client.GetTorrentStatus(hash);
BlockFilesResult result = new(); BlockFilesResult result = new();
if (status?.Hash is null) if (download?.Hash is null)
{ {
_logger.LogDebug("failed to find torrent {hash} in the download client", hash); _logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return result; return result;
} }
result.IsPrivate = status.Private;
if (_contentBlockerConfig.IgnorePrivate && status.Private) var ceva = await _client.GetTorrentExtended(hash);
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 // ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", status.Name); _logger.LogDebug("skip files check | download is private | {name}", download.Name);
return result; return result;
} }
@@ -167,20 +203,99 @@ public sealed class DelugeService : DownloadServiceBase
return result; return result;
} }
await _client.ChangeFilesPriority(hash, sortedPriorities); await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, hash, sortedPriorities);
return result; return result;
} }
public override async Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories)
{
return (await _client.GetStatusForAllTorrents())
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => x.State?.Equals("seeding", StringComparison.InvariantCultureIgnoreCase) is true)
.Where(x => categories.Any(cat => cat.Name.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
.Cast<object>()
.ToList();
}
/// <inheritdoc/>
public override async Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
IReadOnlyList<string> ignoredDownloads)
{
foreach (TorrentStatus download in downloads)
{
if (string.IsNullOrEmpty(download.Hash))
{
continue;
}
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
continue;
}
Category? category = categoriesToClean
.FirstOrDefault(x => x.Name.Equals(download.Label, StringComparison.InvariantCultureIgnoreCase));
if (category is null)
{
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 (!_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);
}
}
/// <inheritdoc/> /// <inheritdoc/>
public override async Task Delete(string hash) [DryRunSafeguard]
public override async Task DeleteDownload(string hash)
{ {
hash = hash.ToLowerInvariant(); hash = hash.ToLowerInvariant();
await _client.DeleteTorrent(hash); await _client.DeleteTorrents([hash]);
} }
private bool IsItemStuckAndShouldRemove(TorrentStatus status) [DryRunSafeguard]
protected virtual async Task ChangeFilesPriority(string hash, List<int> sortedPriorities)
{
await _client.ChangeFilesPriority(hash, sortedPriorities);
}
private async Task<bool> IsItemStuckAndShouldRemove(TorrentStatus status)
{ {
if (_queueCleanerConfig.StalledMaxStrikes is 0) if (_queueCleanerConfig.StalledMaxStrikes is 0)
{ {
@@ -206,16 +321,7 @@ public sealed class DelugeService : DownloadServiceBase
ResetStrikesOnProgress(status.Hash!, status.TotalDone); ResetStrikesOnProgress(status.Hash!, status.TotalDone);
return StrikeAndCheckLimit(status.Hash!, status.Name!); return await StrikeAndCheckLimit(status.Hash!, status.Name!);
}
private async Task<TorrentStatus?> GetTorrentStatus(string hash)
{
return await _client.SendRequest<TorrentStatus?>(
"web.get_torrent_status",
hash,
new[] { "hash", "state", "name", "eta", "private", "total_done" }
);
} }
private static void ProcessFiles(Dictionary<string, DelugeFileOrDirectory>? contents, Action<string, DelugeFileOrDirectory> processFile) private static void ProcessFiles(Dictionary<string, DelugeFileOrDirectory>? contents, Action<string, DelugeFileOrDirectory> processFile)
@@ -0,0 +1,5 @@
namespace Infrastructure.Verticals.DownloadClient.Deluge;
public interface IDelugeService : IDownloadService
{
}
@@ -0,0 +1,179 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.QueueCleaner;
using Common.Helpers;
using Domain.Enums;
using Domain.Models.Cache;
using Infrastructure.Helpers;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.Context;
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;
public abstract class DownloadService : IDownloadService
{
protected readonly ILogger<DownloadService> _logger;
protected readonly QueueCleanerConfig _queueCleanerConfig;
protected readonly ContentBlockerConfig _contentBlockerConfig;
protected readonly DownloadCleanerConfig _downloadCleanerConfig;
protected readonly IMemoryCache _cache;
protected readonly IFilenameEvaluator _filenameEvaluator;
protected readonly IStriker _striker;
protected readonly MemoryCacheEntryOptions _cacheOptions;
protected readonly INotificationPublisher _notifier;
protected readonly IDryRunInterceptor _dryRunInterceptor;
protected DownloadService(
ILogger<DownloadService> logger,
IOptions<QueueCleanerConfig> queueCleanerConfig,
IOptions<ContentBlockerConfig> contentBlockerConfig,
IOptions<DownloadCleanerConfig> downloadCleanerConfig,
IMemoryCache cache,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
INotificationPublisher notifier,
IDryRunInterceptor dryRunInterceptor
)
{
_logger = logger;
_queueCleanerConfig = queueCleanerConfig.Value;
_contentBlockerConfig = contentBlockerConfig.Value;
_downloadCleanerConfig = downloadCleanerConfig.Value;
_cache = cache;
_filenameEvaluator = filenameEvaluator;
_striker = striker;
_notifier = notifier;
_dryRunInterceptor = dryRunInterceptor;
_cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
}
public abstract void Dispose();
public abstract Task LoginAsync();
public abstract Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads);
/// <inheritdoc/>
public abstract Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash,
BlocklistType blocklistType,
ConcurrentBag<string> patterns,
ConcurrentBag<Regex> regexes, IReadOnlyList<string> ignoredDownloads);
/// <inheritdoc/>
public abstract Task DeleteDownload(string hash);
/// <inheritdoc/>
public abstract Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories);
/// <inheritdoc/>
public abstract Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
IReadOnlyList<string> ignoredDownloads);
protected void ResetStrikesOnProgress(string hash, long downloaded)
{
if (!_queueCleanerConfig.StalledResetStrikesOnProgress)
{
return;
}
if (_cache.TryGetValue(CacheKeys.Item(hash), out CacheItem? cachedItem) && cachedItem is not null && downloaded > cachedItem.Downloaded)
{
// cache item found
_cache.Remove(CacheKeys.Strike(StrikeType.Stalled, hash));
_logger.LogDebug("resetting strikes for {hash} due to progress", hash);
}
_cache.Set(CacheKeys.Item(hash), new CacheItem { Downloaded = downloaded }, _cacheOptions);
}
/// <summary>
/// Strikes an item and checks if the limit has been reached.
/// </summary>
/// <param name="hash">The torrent hash.</param>
/// <param name="itemName">The name or title of the item.</param>
/// <returns>True if the limit has been reached; otherwise, false.</returns>
protected async Task<bool> StrikeAndCheckLimit(string hash, string itemName)
{
return await _striker.StrikeAndCheckLimit(hash, itemName, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled);
}
protected SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, Category category)
{
// check ratio
if (DownloadReachedRatio(ratio, seedingTime, category))
{
return new()
{
ShouldClean = true,
Reason = CleanReason.MaxRatioReached
};
}
// check max seed time
if (DownloadReachedMaxSeedTime(seedingTime, category))
{
return new()
{
ShouldClean = true,
Reason = CleanReason.MaxSeedTimeReached
};
}
return new();
}
private bool DownloadReachedRatio(double ratio, TimeSpan seedingTime, Category category)
{
if (category.MaxRatio < 0)
{
return false;
}
string downloadName = ContextProvider.Get<string>("downloadName");
TimeSpan minSeedingTime = TimeSpan.FromHours(category.MinSeedTime);
if (category.MinSeedTime > 0 && seedingTime < minSeedingTime)
{
_logger.LogDebug("skip | download has not reached MIN_SEED_TIME | {name}", downloadName);
return false;
}
if (ratio < category.MaxRatio)
{
_logger.LogDebug("skip | download has not reached MAX_RATIO | {name}", downloadName);
return false;
}
// max ration is 0 or reached
return true;
}
private bool DownloadReachedMaxSeedTime(TimeSpan seedingTime, Category category)
{
if (category.MaxSeedTime < 0)
{
return false;
}
string downloadName = ContextProvider.Get<string>("downloadName");
TimeSpan maxSeedingTime = TimeSpan.FromHours(category.MaxSeedTime);
if (category.MaxSeedTime > 0 && seedingTime < maxSeedingTime)
{
_logger.LogDebug("skip | download has not reached MAX_SEED_TIME | {name}", downloadName);
return false;
}
// max seed time is 0 or reached
return true;
}
}
@@ -1,90 +0,0 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Common.Configuration.ContentBlocker;
using Common.Configuration.QueueCleaner;
using Common.Helpers;
using Domain.Enums;
using Domain.Models.Cache;
using Infrastructure.Helpers;
using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.ItemStriker;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Infrastructure.Verticals.DownloadClient;
public abstract class DownloadServiceBase : IDownloadService
{
protected readonly ILogger<DownloadServiceBase> _logger;
protected readonly QueueCleanerConfig _queueCleanerConfig;
protected readonly ContentBlockerConfig _contentBlockerConfig;
protected readonly IMemoryCache _cache;
protected readonly FilenameEvaluator _filenameEvaluator;
protected readonly Striker _striker;
protected readonly MemoryCacheEntryOptions _cacheOptions;
protected DownloadServiceBase(
ILogger<DownloadServiceBase> logger,
IOptions<QueueCleanerConfig> queueCleanerConfig,
IOptions<ContentBlockerConfig> contentBlockerConfig,
IMemoryCache cache,
FilenameEvaluator filenameEvaluator,
Striker striker
)
{
_logger = logger;
_queueCleanerConfig = queueCleanerConfig.Value;
_contentBlockerConfig = contentBlockerConfig.Value;
_cache = cache;
_filenameEvaluator = filenameEvaluator;
_striker = striker;
_cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
}
public abstract void Dispose();
public abstract Task LoginAsync();
public abstract Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash);
/// <inheritdoc/>
public abstract Task<BlockFilesResult> BlockUnwantedFilesAsync(
string hash,
BlocklistType blocklistType,
ConcurrentBag<string> patterns,
ConcurrentBag<Regex> regexes
);
/// <inheritdoc/>
public abstract Task Delete(string hash);
protected void ResetStrikesOnProgress(string hash, long downloaded)
{
if (!_queueCleanerConfig.StalledResetStrikesOnProgress)
{
return;
}
if (_cache.TryGetValue(CacheKeys.Item(hash), out CacheItem? cachedItem) && cachedItem is not null && downloaded > cachedItem.Downloaded)
{
// cache item found
_cache.Remove(CacheKeys.Strike(StrikeType.Stalled, hash));
_logger.LogDebug("resetting strikes for {hash} due to progress", hash);
}
_cache.Set(CacheKeys.Item(hash), new CacheItem { Downloaded = downloaded }, _cacheOptions);
}
/// <summary>
/// Strikes an item and checks if the limit has been reached.
/// </summary>
/// <param name="hash">The torrent hash.</param>
/// <param name="itemName">The name or title of the item.</param>
/// <returns>True if the limit has been reached; otherwise, false.</returns>
protected bool StrikeAndCheckLimit(string hash, string itemName)
{
return _striker.StrikeAndCheckLimit(hash, itemName, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled);
}
}
@@ -25,6 +25,7 @@ public sealed class DownloadServiceFactory
Common.Enums.DownloadClient.Deluge => _serviceProvider.GetRequiredService<DelugeService>(), Common.Enums.DownloadClient.Deluge => _serviceProvider.GetRequiredService<DelugeService>(),
Common.Enums.DownloadClient.Transmission => _serviceProvider.GetRequiredService<TransmissionService>(), Common.Enums.DownloadClient.Transmission => _serviceProvider.GetRequiredService<TransmissionService>(),
Common.Enums.DownloadClient.None => _serviceProvider.GetRequiredService<DummyDownloadService>(), Common.Enums.DownloadClient.None => _serviceProvider.GetRequiredService<DummyDownloadService>(),
Common.Enums.DownloadClient.Disabled => _serviceProvider.GetRequiredService<DummyDownloadService>(),
_ => throw new ArgumentOutOfRangeException() _ => throw new ArgumentOutOfRangeException()
}; };
} }
@@ -1,18 +1,21 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Common.Configuration.ContentBlocker; using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.QueueCleaner; using Common.Configuration.QueueCleaner;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.ItemStriker; using Infrastructure.Verticals.ItemStriker;
using Infrastructure.Verticals.Notifications;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Infrastructure.Verticals.DownloadClient; namespace Infrastructure.Verticals.DownloadClient;
public sealed class DummyDownloadService : DownloadServiceBase public class DummyDownloadService : DownloadService
{ {
public DummyDownloadService(ILogger<DownloadServiceBase> logger, IOptions<QueueCleanerConfig> queueCleanerConfig, IOptions<ContentBlockerConfig> contentBlockerConfig, IMemoryCache cache, FilenameEvaluator filenameEvaluator, Striker striker) : base(logger, queueCleanerConfig, contentBlockerConfig, cache, filenameEvaluator, striker) public DummyDownloadService(ILogger<DownloadService> logger, IOptions<QueueCleanerConfig> queueCleanerConfig, IOptions<ContentBlockerConfig> contentBlockerConfig, IOptions<DownloadCleanerConfig> downloadCleanerConfig, IMemoryCache cache, IFilenameEvaluator filenameEvaluator, IStriker striker, INotificationPublisher notifier, IDryRunInterceptor dryRunInterceptor) : base(logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache, filenameEvaluator, striker, notifier, dryRunInterceptor)
{ {
} }
@@ -25,17 +28,29 @@ public sealed class DummyDownloadService : DownloadServiceBase
return Task.CompletedTask; return Task.CompletedTask;
} }
public override Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash) public override Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
} }
public override Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, BlocklistType blocklistType, ConcurrentBag<string> patterns, ConcurrentBag<Regex> regexes) public override Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, BlocklistType blocklistType, ConcurrentBag<string> patterns,
ConcurrentBag<Regex> regexes, IReadOnlyList<string> ignoredDownloads)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
} }
public override Task Delete(string hash) public override Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories)
{
throw new NotImplementedException();
}
public override Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
IReadOnlyList<string> ignoredDownloads)
{
throw new NotImplementedException();
}
public override Task DeleteDownload(string hash)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
} }
@@ -1,6 +1,8 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Common.Configuration.ContentBlocker; using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Infrastructure.Interceptors;
namespace Infrastructure.Verticals.DownloadClient; namespace Infrastructure.Verticals.DownloadClient;
@@ -12,7 +14,8 @@ public interface IDownloadService : IDisposable
/// Checks whether the download should be removed from the *arr queue. /// Checks whether the download should be removed from the *arr queue.
/// </summary> /// </summary>
/// <param name="hash">The download hash.</param> /// <param name="hash">The download hash.</param>
public Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash); /// <param name="ignoredDownloads">Downloads to ignore from processing.</param>
public Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads);
/// <summary> /// <summary>
/// Blocks unwanted files from being fully downloaded. /// Blocks unwanted files from being fully downloaded.
@@ -21,16 +24,34 @@ public interface IDownloadService : IDisposable
/// <param name="blocklistType">The <see cref="BlocklistType"/>.</param> /// <param name="blocklistType">The <see cref="BlocklistType"/>.</param>
/// <param name="patterns">The patterns to test the files against.</param> /// <param name="patterns">The patterns to test the files against.</param>
/// <param name="regexes">The regexes to test the files against.</param> /// <param name="regexes">The regexes to test the files against.</param>
/// <param name="ignoredDownloads">Downloads to ignore from processing.</param>
/// <returns>True if all files have been blocked; otherwise false.</returns> /// <returns>True if all files have been blocked; otherwise false.</returns>
public Task<BlockFilesResult> BlockUnwantedFilesAsync( public Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash,
string hash,
BlocklistType blocklistType, BlocklistType blocklistType,
ConcurrentBag<string> patterns, ConcurrentBag<string> patterns,
ConcurrentBag<Regex> regexes ConcurrentBag<Regex> regexes,
IReadOnlyList<string> ignoredDownloads
); );
/// <summary>
/// Fetches all downloads.
/// </summary>
/// <param name="categories">The categories by which to filter the downloads.</param>
/// <returns>A list of downloads for the provided categories.</returns>
Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories);
/// <summary>
/// Cleans the downloads.
/// </summary>
/// <param name="downloads"></param>
/// <param name="categoriesToClean">The categories that should be cleaned.</param>
/// <param name="excludedHashes">The hashes that should not be cleaned.</param>
/// <param name="ignoredDownloads">Downloads to ignore from processing.</param>
public abstract Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
IReadOnlyList<string> ignoredDownloads);
/// <summary> /// <summary>
/// Deletes a download item. /// Deletes a download item.
/// </summary> /// </summary>
public Task Delete(string hash); public Task DeleteDownload(string hash);
} }
@@ -0,0 +1,5 @@
namespace Infrastructure.Verticals.DownloadClient.QBittorrent;
public interface IQBitService : IDownloadService, IDisposable
{
}
@@ -1,19 +1,27 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Common.Attributes;
using Common.Configuration.ContentBlocker; using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.DownloadClient; using Common.Configuration.DownloadClient;
using Common.Configuration.QueueCleaner; using Common.Configuration.QueueCleaner;
using Common.Helpers; using Common.Helpers;
using Domain.Enums;
using Infrastructure.Extensions;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.Context;
using Infrastructure.Verticals.ItemStriker; using Infrastructure.Verticals.ItemStriker;
using Infrastructure.Verticals.Notifications;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using QBittorrent.Client; using QBittorrent.Client;
using Category = Common.Configuration.DownloadCleaner.Category;
namespace Infrastructure.Verticals.DownloadClient.QBittorrent; namespace Infrastructure.Verticals.DownloadClient.QBittorrent;
public sealed class QBitService : DownloadServiceBase public class QBitService : DownloadService, IQBitService
{ {
private readonly QBitConfig _config; private readonly QBitConfig _config;
private readonly QBittorrentClient _client; private readonly QBittorrentClient _client;
@@ -24,14 +32,24 @@ public sealed class QBitService : DownloadServiceBase
IOptions<QBitConfig> config, IOptions<QBitConfig> config,
IOptions<QueueCleanerConfig> queueCleanerConfig, IOptions<QueueCleanerConfig> queueCleanerConfig,
IOptions<ContentBlockerConfig> contentBlockerConfig, IOptions<ContentBlockerConfig> contentBlockerConfig,
IOptions<DownloadCleanerConfig> downloadCleanerConfig,
IMemoryCache cache, IMemoryCache cache,
FilenameEvaluator filenameEvaluator, IFilenameEvaluator filenameEvaluator,
Striker striker IStriker striker,
) : base(logger, queueCleanerConfig, contentBlockerConfig, cache, filenameEvaluator, striker) INotificationPublisher notifier,
IDryRunInterceptor dryRunInterceptor
) : base(
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
filenameEvaluator, striker, notifier, dryRunInterceptor
)
{ {
_config = config.Value; _config = config.Value;
_config.Validate(); _config.Validate();
_client = new(httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), _config.Url); UriBuilder uriBuilder = new(_config.Url);
uriBuilder.Path = string.IsNullOrEmpty(_config.UrlBase)
? uriBuilder.Path
: $"{uriBuilder.Path.TrimEnd('/')}/{_config.UrlBase.TrimStart('/')}";
_client = new(httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), uriBuilder.Uri);
} }
public override async Task LoginAsync() public override async Task LoginAsync()
@@ -45,18 +63,27 @@ public sealed class QBitService : DownloadServiceBase
} }
/// <inheritdoc/> /// <inheritdoc/>
public override async Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash) public override async Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
{ {
StalledResult result = new(); StalledResult result = new();
TorrentInfo? torrent = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] })) TorrentInfo? download = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] }))
.FirstOrDefault(); .FirstOrDefault();
if (torrent is null) if (download is null)
{ {
_logger.LogDebug("failed to find torrent {hash} in the download client", hash); _logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return result; return result;
} }
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(hash);
if (ignoredDownloads.Count > 0 &&
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)) is true))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
return result;
}
TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash); TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash);
if (torrentProperties is null) if (torrentProperties is null)
@@ -70,9 +97,10 @@ public sealed class QBitService : DownloadServiceBase
&& boolValue; && boolValue;
// if all files were blocked by qBittorrent // if all files were blocked by qBittorrent
if (torrent is { CompletionOn: not null, Downloaded: null or 0 }) if (download is { CompletionOn: not null, Downloaded: null or 0 })
{ {
result.ShouldRemove = true; result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
return result; return result;
} }
@@ -82,32 +110,47 @@ public sealed class QBitService : DownloadServiceBase
if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip)) if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip))
{ {
result.ShouldRemove = true; result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
return result; return result;
} }
result.ShouldRemove = IsItemStuckAndShouldRemove(torrent, result.IsPrivate); result.ShouldRemove = await IsItemStuckAndShouldRemove(download, result.IsPrivate);
if (result.ShouldRemove)
{
result.DeleteReason = DeleteReason.Stalled;
}
return result; return result;
} }
/// <inheritdoc/> /// <inheritdoc/>
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync( public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash,
string hash,
BlocklistType blocklistType, BlocklistType blocklistType,
ConcurrentBag<string> patterns, ConcurrentBag<string> patterns,
ConcurrentBag<Regex> regexes ConcurrentBag<Regex> regexes,
IReadOnlyList<string> ignoredDownloads
) )
{ {
TorrentInfo? torrent = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] })) TorrentInfo? download = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] }))
.FirstOrDefault(); .FirstOrDefault();
BlockFilesResult result = new(); BlockFilesResult result = new();
if (torrent is null) if (download is null)
{ {
_logger.LogDebug("failed to find torrent {hash} in the download client", hash); _logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return result; return result;
} }
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(hash);
if (ignoredDownloads.Count > 0 &&
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)) is true))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
return result;
}
TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash); TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash);
if (torrentProperties is null) if (torrentProperties is null)
@@ -125,7 +168,7 @@ public sealed class QBitService : DownloadServiceBase
if (_contentBlockerConfig.IgnorePrivate && isPrivate) if (_contentBlockerConfig.IgnorePrivate && isPrivate)
{ {
// ignore private trackers // ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", torrent.Name); _logger.LogDebug("skip files check | download is private | {name}", download.Name);
return result; return result;
} }
@@ -180,24 +223,121 @@ public sealed class QBitService : DownloadServiceBase
foreach (int fileIndex in unwantedFiles) foreach (int fileIndex in unwantedFiles)
{ {
await _client.SetFilePriorityAsync(hash, fileIndex, TorrentContentPriority.Skip); await _dryRunInterceptor.InterceptAsync(SkipFile, hash, fileIndex);
} }
return result; return result;
} }
/// <inheritdoc/>
public override async Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories) =>
(await _client.GetTorrentListAsync(new()
{
Filter = TorrentListFilter.Seeding
}))
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.Cast<object>()
.ToList();
/// <inheritdoc/> /// <inheritdoc/>
public override async Task Delete(string hash) public override async Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
IReadOnlyList<string> ignoredDownloads)
{
foreach (TorrentInfo download in downloads)
{
if (string.IsNullOrEmpty(download.Hash))
{
continue;
}
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(download.Hash);
if (ignoredDownloads.Count > 0 &&
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)) is true))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
continue;
}
Category? category = categoriesToClean
.FirstOrDefault(x => download.Category.Equals(x.Name, StringComparison.InvariantCultureIgnoreCase));
if (category is null)
{
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 (!_downloadCleanerConfig.DeletePrivate)
{
TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(download.Hash);
if (torrentProperties is null)
{
_logger.LogDebug("failed to find torrent properties in the download client | {name}", download.Name);
return;
}
bool isPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) &&
bool.TryParse(dictValue?.ToString(), out bool boolValue)
&& boolValue;
if (isPrivate)
{
_logger.LogDebug("skip | download is private | {name}", download.Name);
continue;
}
}
ContextProvider.Set("downloadName", download.Name);
ContextProvider.Set("hash", download.Hash);
SeedingCheckResult result = ShouldCleanDownload(download.Ratio, download.SeedingTime ?? TimeSpan.Zero, 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, download.SeedingTime ?? TimeSpan.Zero, category.Name, result.Reason);
}
}
/// <inheritdoc/>
[DryRunSafeguard]
public override async Task DeleteDownload(string hash)
{ {
await _client.DeleteAsync(hash, deleteDownloadedData: true); await _client.DeleteAsync(hash, deleteDownloadedData: true);
} }
[DryRunSafeguard]
protected virtual async Task SkipFile(string hash, int fileIndex)
{
await _client.SetFilePriorityAsync(hash, fileIndex, TorrentContentPriority.Skip);
}
public override void Dispose() public override void Dispose()
{ {
_client.Dispose(); _client.Dispose();
} }
private bool IsItemStuckAndShouldRemove(TorrentInfo torrent, bool isPrivate) private async Task<bool> IsItemStuckAndShouldRemove(TorrentInfo torrent, bool isPrivate)
{ {
if (_queueCleanerConfig.StalledMaxStrikes is 0) if (_queueCleanerConfig.StalledMaxStrikes is 0)
{ {
@@ -220,6 +360,13 @@ public sealed class QBitService : DownloadServiceBase
ResetStrikesOnProgress(torrent.Hash, torrent.Downloaded ?? 0); ResetStrikesOnProgress(torrent.Hash, torrent.Downloaded ?? 0);
return StrikeAndCheckLimit(torrent.Hash, torrent.Name); return await StrikeAndCheckLimit(torrent.Hash, torrent.Name);
}
private async Task<IReadOnlyList<TorrentTracker>> GetTrackersAsync(string hash)
{
return (await _client.GetTorrentTrackersAsync(hash))
.Where(x => !x.Url.ToString().Contains("**"))
.ToList();
} }
} }
@@ -0,0 +1,9 @@
using Domain.Enums;
namespace Infrastructure.Verticals.DownloadClient;
public sealed record SeedingCheckResult
{
public bool ShouldClean { get; set; }
public CleanReason Reason { get; set; }
}
@@ -1,4 +1,6 @@
namespace Infrastructure.Verticals.DownloadClient; using Domain.Enums;
namespace Infrastructure.Verticals.DownloadClient;
public sealed record StalledResult public sealed record StalledResult
{ {
@@ -7,6 +9,8 @@ public sealed record StalledResult
/// </summary> /// </summary>
public bool ShouldRemove { get; set; } public bool ShouldRemove { get; set; }
public DeleteReason DeleteReason { get; set; }
/// <summary> /// <summary>
/// True if the download is private; otherwise false. /// True if the download is private; otherwise false.
/// </summary> /// </summary>
@@ -0,0 +1,5 @@
namespace Infrastructure.Verticals.DownloadClient.Transmission;
public interface ITransmissionService : IDownloadService
{
}
@@ -1,11 +1,18 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Common.Attributes;
using Common.Configuration.ContentBlocker; using Common.Configuration.ContentBlocker;
using Common.Configuration.DownloadCleaner;
using Common.Configuration.DownloadClient; using Common.Configuration.DownloadClient;
using Common.Configuration.QueueCleaner; using Common.Configuration.QueueCleaner;
using Common.Helpers; using Common.Helpers;
using Domain.Enums;
using Infrastructure.Extensions;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.ContentBlocker;
using Infrastructure.Verticals.Context;
using Infrastructure.Verticals.ItemStriker; using Infrastructure.Verticals.ItemStriker;
using Infrastructure.Verticals.Notifications;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -15,28 +22,55 @@ using Transmission.API.RPC.Entity;
namespace Infrastructure.Verticals.DownloadClient.Transmission; namespace Infrastructure.Verticals.DownloadClient.Transmission;
public sealed class TransmissionService : DownloadServiceBase public class TransmissionService : DownloadService, ITransmissionService
{ {
private readonly TransmissionConfig _config; private readonly TransmissionConfig _config;
private readonly Client _client; private readonly Client _client;
private TorrentInfo[]? _torrentsCache; private TorrentInfo[]? _torrentsCache;
private static readonly string[] Fields =
[
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS
];
public TransmissionService( public TransmissionService(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
ILogger<TransmissionService> logger, ILogger<TransmissionService> logger,
IOptions<TransmissionConfig> config, IOptions<TransmissionConfig> config,
IOptions<QueueCleanerConfig> queueCleanerConfig, IOptions<QueueCleanerConfig> queueCleanerConfig,
IOptions<ContentBlockerConfig> contentBlockerConfig, IOptions<ContentBlockerConfig> contentBlockerConfig,
IOptions<DownloadCleanerConfig> downloadCleanerConfig,
IMemoryCache cache, IMemoryCache cache,
FilenameEvaluator filenameEvaluator, IFilenameEvaluator filenameEvaluator,
Striker striker IStriker striker,
) : base(logger, queueCleanerConfig, contentBlockerConfig, cache, filenameEvaluator, striker) INotificationPublisher notifier,
IDryRunInterceptor dryRunInterceptor
) : base(
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
filenameEvaluator, striker, notifier, dryRunInterceptor
)
{ {
_config = config.Value; _config = config.Value;
_config.Validate(); _config.Validate();
UriBuilder uriBuilder = new(_config.Url);
uriBuilder.Path = string.IsNullOrEmpty(_config.UrlBase)
? $"{uriBuilder.Path.TrimEnd('/')}/rpc"
: $"{uriBuilder.Path.TrimEnd('/')}/{_config.UrlBase.TrimStart('/').TrimEnd('/')}/rpc";
_client = new( _client = new(
httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), httpClientFactory.CreateClient(Constants.HttpClientWithRetryName),
new Uri(_config.Url, "/transmission/rpc").ToString(), uriBuilder.Uri.ToString(),
login: _config.Username, login: _config.Username,
password: _config.Password password: _config.Password
); );
@@ -48,21 +82,27 @@ public sealed class TransmissionService : DownloadServiceBase
} }
/// <inheritdoc/> /// <inheritdoc/>
public override async Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash) public override async Task<StalledResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
{ {
StalledResult result = new(); StalledResult result = new();
TorrentInfo? torrent = await GetTorrentAsync(hash); TorrentInfo? download = await GetTorrentAsync(hash);
if (torrent is null) if (download is null)
{ {
_logger.LogDebug("failed to find torrent {hash} in the download client", hash); _logger.LogDebug("failed to find torrent {hash} in the download client", hash);
return result; return result;
} }
bool shouldRemove = torrent.FileStats?.Length > 0; if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
result.IsPrivate = torrent.IsPrivate ?? false; {
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
return result;
}
bool shouldRemove = download.FileStats?.Length > 0;
result.IsPrivate = download.IsPrivate ?? false;
foreach (TransmissionTorrentFileStats? stats in torrent.FileStats ?? []) foreach (TransmissionTorrentFileStats? stats in download.FileStats ?? [])
{ {
if (!stats.Wanted.HasValue) if (!stats.Wanted.HasValue)
{ {
@@ -76,36 +116,50 @@ public sealed class TransmissionService : DownloadServiceBase
shouldRemove = false; shouldRemove = false;
} }
} }
if (shouldRemove)
{
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
// remove if all files are unwanted or download is stuck // remove if all files are unwanted or download is stuck
result.ShouldRemove = shouldRemove || IsItemStuckAndShouldRemove(torrent); result.ShouldRemove = shouldRemove || await IsItemStuckAndShouldRemove(download);
if (!shouldRemove && result.ShouldRemove)
{
result.DeleteReason = DeleteReason.Stalled;
}
return result; return result;
} }
/// <inheritdoc/> /// <inheritdoc/>
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync( public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash,
string hash,
BlocklistType blocklistType, BlocklistType blocklistType,
ConcurrentBag<string> patterns, ConcurrentBag<string> patterns,
ConcurrentBag<Regex> regexes ConcurrentBag<Regex> regexes, IReadOnlyList<string> ignoredDownloads)
)
{ {
TorrentInfo? torrent = await GetTorrentAsync(hash); TorrentInfo? download = await GetTorrentAsync(hash);
BlockFilesResult result = new(); BlockFilesResult result = new();
if (torrent?.FileStats is null || torrent.Files is null) if (download?.FileStats is null || download.Files is null)
{ {
return result; return result;
} }
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
{
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
return result;
}
bool isPrivate = torrent.IsPrivate ?? false; bool isPrivate = download.IsPrivate ?? false;
result.IsPrivate = isPrivate; result.IsPrivate = isPrivate;
if (_contentBlockerConfig.IgnorePrivate && isPrivate) if (_contentBlockerConfig.IgnorePrivate && isPrivate)
{ {
// ignore private trackers // ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", torrent.Name); _logger.LogDebug("skip files check | download is private | {name}", download.Name);
return result; return result;
} }
@@ -113,27 +167,27 @@ public sealed class TransmissionService : DownloadServiceBase
long totalFiles = 0; long totalFiles = 0;
long totalUnwantedFiles = 0; long totalUnwantedFiles = 0;
for (int i = 0; i < torrent.Files.Length; i++) for (int i = 0; i < download.Files.Length; i++)
{ {
if (torrent.FileStats?[i].Wanted == null) if (download.FileStats?[i].Wanted == null)
{ {
continue; continue;
} }
totalFiles++; totalFiles++;
if (!torrent.FileStats[i].Wanted.Value) if (!download.FileStats[i].Wanted.Value)
{ {
totalUnwantedFiles++; totalUnwantedFiles++;
continue; continue;
} }
if (_filenameEvaluator.IsValid(torrent.Files[i].Name, blocklistType, patterns, regexes)) if (_filenameEvaluator.IsValid(download.Files[i].Name, blocklistType, patterns, regexes))
{ {
continue; continue;
} }
_logger.LogInformation("unwanted file found | {file}", torrent.Files[i].Name); _logger.LogInformation("unwanted file found | {file}", download.Files[i].Name);
unwantedFiles.Add(i); unwantedFiles.Add(i);
totalUnwantedFiles++; totalUnwantedFiles++;
} }
@@ -152,17 +206,107 @@ public sealed class TransmissionService : DownloadServiceBase
} }
_logger.LogDebug("changing priorities | torrent {hash}", hash); _logger.LogDebug("changing priorities | torrent {hash}", hash);
await _client.TorrentSetAsync(new TorrentSettings await _dryRunInterceptor.InterceptAsync(SetUnwantedFiles, download.Id, unwantedFiles.ToArray());
{
Ids = [ torrent.Id ],
FilesUnwanted = unwantedFiles.ToArray(),
});
return result; return result;
} }
public override async Task Delete(string hash) /// <inheritdoc/>
public override async Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories)
{
return (await _client.TorrentGetAsync(Fields))
?.Torrents
?.Where(x => !string.IsNullOrEmpty(x.HashString))
.Where(x => x.Status is 5 or 6)
.Where(x => categories
.Any(cat =>
{
if (x.DownloadDir is null)
{
return false;
}
return Path.GetFileName(Path.TrimEndingDirectorySeparator(x.DownloadDir))
.Equals(cat.Name, StringComparison.InvariantCultureIgnoreCase);
})
)
.Cast<object>()
.ToList();
}
/// <inheritdoc/>
public override async Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
IReadOnlyList<string> ignoredDownloads)
{
foreach (TorrentInfo download in downloads)
{
if (string.IsNullOrEmpty(download.HashString))
{
continue;
}
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
{
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
continue;
}
Category? category = categoriesToClean
.FirstOrDefault(x =>
{
if (download.DownloadDir is null)
{
return false;
}
return Path.GetFileName(Path.TrimEndingDirectorySeparator(download.DownloadDir))
.Equals(x.Name, StringComparison.InvariantCultureIgnoreCase);
});
if (category is null)
{
continue;
}
if (excludedHashes.Any(x => x.Equals(download.HashString, StringComparison.InvariantCultureIgnoreCase)))
{
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
continue;
}
if (!_downloadCleanerConfig.DeletePrivate && download.IsPrivate is true)
{
_logger.LogDebug("skip | download is private | {name}", download.Name);
continue;
}
ContextProvider.Set("downloadName", download.Name);
ContextProvider.Set("hash", download.HashString);
TimeSpan seedingTime = TimeSpan.FromSeconds(download.SecondsSeeding ?? 0);
SeedingCheckResult result = ShouldCleanDownload(download.uploadRatio ?? 0, seedingTime, category);
if (!result.ShouldClean)
{
continue;
}
await _dryRunInterceptor.InterceptAsync(RemoveDownloadAsync, download.Id);
_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.uploadRatio ?? 0, seedingTime, category.Name, result.Reason);
}
}
public override async Task DeleteDownload(string hash)
{ {
TorrentInfo? torrent = await GetTorrentAsync(hash); TorrentInfo? torrent = await GetTorrentAsync(hash);
@@ -178,7 +322,23 @@ public sealed class TransmissionService : DownloadServiceBase
{ {
} }
private bool IsItemStuckAndShouldRemove(TorrentInfo torrent) [DryRunSafeguard]
protected virtual async Task RemoveDownloadAsync(long downloadId)
{
await _client.TorrentRemoveAsync([downloadId], true);
}
[DryRunSafeguard]
protected virtual async Task SetUnwantedFiles(long downloadId, long[] unwantedFiles)
{
await _client.TorrentSetAsync(new TorrentSettings
{
Ids = [downloadId],
FilesUnwanted = unwantedFiles,
});
}
private async Task<bool> IsItemStuckAndShouldRemove(TorrentInfo torrent)
{ {
if (_queueCleanerConfig.StalledMaxStrikes is 0) if (_queueCleanerConfig.StalledMaxStrikes is 0)
{ {
@@ -205,7 +365,7 @@ public sealed class TransmissionService : DownloadServiceBase
ResetStrikesOnProgress(torrent.HashString!, torrent.DownloadedEver ?? 0); ResetStrikesOnProgress(torrent.HashString!, torrent.DownloadedEver ?? 0);
return StrikeAndCheckLimit(torrent.HashString!, torrent.Name!); return await StrikeAndCheckLimit(torrent.HashString!, torrent.Name!);
} }
private async Task<TorrentInfo?> GetTorrentAsync(string hash) private async Task<TorrentInfo?> GetTorrentAsync(string hash)
@@ -215,20 +375,8 @@ public sealed class TransmissionService : DownloadServiceBase
if (_torrentsCache is null || torrent is null) if (_torrentsCache is null || torrent is null)
{ {
string[] fields = [
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER
];
// refresh cache // refresh cache
_torrentsCache = (await _client.TorrentGetAsync(fields)) _torrentsCache = (await _client.TorrentGetAsync(Fields))
?.Torrents; ?.Torrents;
} }
@@ -0,0 +1,8 @@
using Domain.Enums;
namespace Infrastructure.Verticals.ItemStriker;
public interface IStriker
{
Task<bool> StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType);
}
@@ -1,26 +1,33 @@
using Common.Helpers; using Common.Helpers;
using Domain.Enums; using Domain.Enums;
using Infrastructure.Helpers; using Infrastructure.Helpers;
using Infrastructure.Interceptors;
using Infrastructure.Verticals.Context;
using Infrastructure.Verticals.Notifications;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Infrastructure.Verticals.ItemStriker; namespace Infrastructure.Verticals.ItemStriker;
public class Striker public sealed class Striker : IStriker
{ {
private readonly ILogger<Striker> _logger; private readonly ILogger<Striker> _logger;
private readonly IMemoryCache _cache; private readonly IMemoryCache _cache;
private readonly MemoryCacheEntryOptions _cacheOptions; private readonly MemoryCacheEntryOptions _cacheOptions;
private readonly INotificationPublisher _notifier;
private readonly IDryRunInterceptor _dryRunInterceptor;
public Striker(ILogger<Striker> logger, IMemoryCache cache) public Striker(ILogger<Striker> logger, IMemoryCache cache, INotificationPublisher notifier, IDryRunInterceptor dryRunInterceptor)
{ {
_logger = logger; _logger = logger;
_cache = cache; _cache = cache;
_notifier = notifier;
_dryRunInterceptor = dryRunInterceptor;
_cacheOptions = new MemoryCacheEntryOptions() _cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer); .SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
} }
public bool StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType) public async Task<bool> StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType)
{ {
if (maxStrikes is 0) if (maxStrikes is 0)
{ {
@@ -29,7 +36,7 @@ public class Striker
string key = CacheKeys.Strike(strikeType, hash); string key = CacheKeys.Strike(strikeType, hash);
if (!_cache.TryGetValue(key, out int? strikeCount)) if (!_cache.TryGetValue(key, out int strikeCount))
{ {
strikeCount = 1; strikeCount = 1;
} }
@@ -39,6 +46,9 @@ public class Striker
} }
_logger.LogInformation("item on strike number {strike} | reason {reason} | {name}", strikeCount, strikeType.ToString(), itemName); _logger.LogInformation("item on strike number {strike} | reason {reason} | {name}", strikeCount, strikeType.ToString(), itemName);
await _notifier.NotifyStrike(strikeType, strikeCount);
_cache.Set(key, strikeCount, _cacheOptions); _cache.Set(key, strikeCount, _cacheOptions);
if (strikeCount < maxStrikes) if (strikeCount < maxStrikes)
@@ -4,24 +4,27 @@ using Domain.Enums;
using Domain.Models.Arr; using Domain.Models.Arr;
using Domain.Models.Arr.Queue; using Domain.Models.Arr.Queue;
using Infrastructure.Verticals.Arr; using Infrastructure.Verticals.Arr;
using Infrastructure.Verticals.Arr.Interfaces;
using Infrastructure.Verticals.DownloadClient; using Infrastructure.Verticals.DownloadClient;
using Infrastructure.Verticals.Notifications;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Infrastructure.Verticals.Jobs; namespace Infrastructure.Verticals.Jobs;
public abstract class GenericHandler : IDisposable public abstract class GenericHandler : IHandler, IDisposable
{ {
protected readonly ILogger<GenericHandler> _logger; protected readonly ILogger<GenericHandler> _logger;
protected readonly DownloadClientConfig _downloadClientConfig; protected readonly DownloadClientConfig _downloadClientConfig;
protected readonly SonarrConfig _sonarrConfig; protected readonly SonarrConfig _sonarrConfig;
protected readonly RadarrConfig _radarrConfig; protected readonly RadarrConfig _radarrConfig;
protected readonly LidarrConfig _lidarrConfig; protected readonly LidarrConfig _lidarrConfig;
protected readonly SonarrClient _sonarrClient; protected readonly ISonarrClient _sonarrClient;
protected readonly RadarrClient _radarrClient; protected readonly IRadarrClient _radarrClient;
protected readonly LidarrClient _lidarrClient; protected readonly ILidarrClient _lidarrClient;
protected readonly ArrQueueIterator _arrArrQueueIterator; protected readonly ArrQueueIterator _arrArrQueueIterator;
protected readonly IDownloadService _downloadService; protected readonly IDownloadService _downloadService;
protected readonly INotificationPublisher _notifier;
protected GenericHandler( protected GenericHandler(
ILogger<GenericHandler> logger, ILogger<GenericHandler> logger,
@@ -29,11 +32,12 @@ public abstract class GenericHandler : IDisposable
IOptions<SonarrConfig> sonarrConfig, IOptions<SonarrConfig> sonarrConfig,
IOptions<RadarrConfig> radarrConfig, IOptions<RadarrConfig> radarrConfig,
IOptions<LidarrConfig> lidarrConfig, IOptions<LidarrConfig> lidarrConfig,
SonarrClient sonarrClient, ISonarrClient sonarrClient,
RadarrClient radarrClient, IRadarrClient radarrClient,
LidarrClient lidarrClient, ILidarrClient lidarrClient,
ArrQueueIterator arrArrQueueIterator, ArrQueueIterator arrArrQueueIterator,
DownloadServiceFactory downloadServiceFactory DownloadServiceFactory downloadServiceFactory,
INotificationPublisher notifier
) )
{ {
_logger = logger; _logger = logger;
@@ -46,6 +50,7 @@ public abstract class GenericHandler : IDisposable
_lidarrClient = lidarrClient; _lidarrClient = lidarrClient;
_arrArrQueueIterator = arrArrQueueIterator; _arrArrQueueIterator = arrArrQueueIterator;
_downloadService = downloadServiceFactory.CreateDownloadClient(); _downloadService = downloadServiceFactory.CreateDownloadClient();
_notifier = notifier;
} }
public virtual async Task ExecuteAsync() public virtual async Task ExecuteAsync()
@@ -64,7 +69,7 @@ public abstract class GenericHandler : IDisposable
protected abstract Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType); protected abstract Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType);
private async Task ProcessArrConfigAsync(ArrConfig config, InstanceType instanceType) protected async Task ProcessArrConfigAsync(ArrConfig config, InstanceType instanceType, bool throwOnFailure = false)
{ {
if (!config.Enabled) if (!config.Enabled)
{ {
@@ -80,11 +85,16 @@ public abstract class GenericHandler : IDisposable
catch (Exception exception) catch (Exception exception)
{ {
_logger.LogError(exception, "failed to clean {type} instance | {url}", instanceType, arrInstance.Url); _logger.LogError(exception, "failed to clean {type} instance | {url}", instanceType, arrInstance.Url);
if (throwOnFailure)
{
throw;
}
} }
} }
} }
protected ArrClient GetClient(InstanceType type) => protected IArrClient GetClient(InstanceType type) =>
type switch type switch
{ {
InstanceType.Sonarr => _sonarrClient, InstanceType.Sonarr => _sonarrClient,
@@ -0,0 +1,6 @@
namespace Infrastructure.Verticals.Jobs;
public interface IHandler
{
Task ExecuteAsync();
}
@@ -4,10 +4,12 @@ namespace Infrastructure.Verticals.Jobs;
public class JobChainingListener : IJobListener public class JobChainingListener : IJobListener
{ {
private readonly string _firstJobName;
private readonly string _nextJobName; private readonly string _nextJobName;
public JobChainingListener(string nextJobName) public JobChainingListener(string firstJobName, string nextJobName)
{ {
_firstJobName = firstJobName;
_nextJobName = nextJobName; _nextJobName = nextJobName;
} }
@@ -19,7 +21,7 @@ public class JobChainingListener : IJobListener
public async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken) public async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken)
{ {
if (string.IsNullOrEmpty(_nextJobName) || context.JobDetail.Key.Name == _nextJobName) if (string.IsNullOrEmpty(_nextJobName) || context.JobDetail.Key.Name == _nextJobName || context.JobDetail.Key.Name != _firstJobName)
{ {
return; return;
} }
@@ -0,0 +1,48 @@
using Infrastructure.Verticals.Notifications.Models;
using MassTransit;
using Microsoft.Extensions.Logging;
namespace Infrastructure.Verticals.Notifications.Consumers;
public sealed class NotificationConsumer<T> : IConsumer<T> where T : Notification
{
private readonly ILogger<NotificationConsumer<T>> _logger;
private readonly NotificationService _notificationService;
public NotificationConsumer(ILogger<NotificationConsumer<T>> logger, NotificationService notificationService)
{
_logger = logger;
_notificationService = notificationService;
}
public async Task Consume(ConsumeContext<T> context)
{
try
{
switch (context.Message)
{
case FailedImportStrikeNotification failedMessage:
await _notificationService.Notify(failedMessage);
break;
case StalledStrikeNotification stalledMessage:
await _notificationService.Notify(stalledMessage);
break;
case QueueItemDeletedNotification queueItemDeleteMessage:
await _notificationService.Notify(queueItemDeleteMessage);
break;
case DownloadCleanedNotification downloadCleanedNotification:
await _notificationService.Notify(downloadCleanedNotification);
break;
default:
throw new NotImplementedException();
}
// prevent spamming
await Task.Delay(1000);
}
catch (Exception exception)
{
_logger.LogError(exception, "error while processing notifications");
}
}
}
@@ -0,0 +1,12 @@
namespace Infrastructure.Verticals.Notifications;
public interface INotificationFactory
{
List<INotificationProvider> OnFailedImportStrikeEnabled();
List<INotificationProvider> OnStalledStrikeEnabled();
List<INotificationProvider> OnQueueItemDeletedEnabled();
List<INotificationProvider> OnDownloadCleanedEnabled();
}
@@ -0,0 +1,19 @@
using Common.Configuration.Notification;
using Infrastructure.Verticals.Notifications.Models;
namespace Infrastructure.Verticals.Notifications;
public interface INotificationProvider
{
NotificationConfig Config { get; }
string Name { get; }
Task OnFailedImportStrike(FailedImportStrikeNotification notification);
Task OnStalledStrike(StalledStrikeNotification notification);
Task OnQueueItemDeleted(QueueItemDeletedNotification notification);
Task OnDownloadCleaned(DownloadCleanedNotification notification);
}
@@ -0,0 +1,12 @@
using Domain.Enums;
namespace Infrastructure.Verticals.Notifications;
public interface INotificationPublisher
{
Task NotifyStrike(StrikeType strikeType, int strikeCount);
Task NotifyQueueItemDeleted(bool removeFromClient, DeleteReason reason);
Task NotifyDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason);
}
@@ -0,0 +1,14 @@
using Domain.Enums;
namespace Infrastructure.Verticals.Notifications.Models;
public record ArrNotification : Notification
{
public required InstanceType InstanceType { get; init; }
public required Uri InstanceUrl { get; init; }
public required string Hash { get; init; }
public Uri? Image { get; init; }
}
@@ -0,0 +1,5 @@
namespace Infrastructure.Verticals.Notifications.Models;
public sealed record DownloadCleanedNotification : Notification
{
}
@@ -0,0 +1,5 @@
namespace Infrastructure.Verticals.Notifications.Models;
public sealed record FailedImportStrikeNotification : ArrNotification
{
}
@@ -0,0 +1,12 @@
namespace Infrastructure.Verticals.Notifications.Models;
public abstract record Notification
{
public required string Title { get; init; }
public required string Description { get; init; }
public List<NotificationField>? Fields { get; init; }
public NotificationLevel Level { get; init; }
}
@@ -0,0 +1,8 @@
namespace Infrastructure.Verticals.Notifications.Models;
public sealed record NotificationField
{
public required string Title { get; init; }
public required string Text { get; init; }
}

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