mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-01-03 19:38:20 -05:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
693f80fe6a | ||
|
|
8cfc73213a | ||
|
|
6fbae768a4 | ||
|
|
8e9d0127e0 | ||
|
|
b92d70769a |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: Flaminel
|
||||
49
.github/workflows/docs.yml
vendored
Normal file
49
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Deploy Docusaurus to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: yarn
|
||||
cache-dependency-path: docs/yarn.lock
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: docs
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build Docusaurus
|
||||
working-directory: docs
|
||||
run: yarn build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/build
|
||||
retention-days: 1
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
399
README.md
399
README.md
@@ -1,409 +1,30 @@
|
||||
_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
|
||||
|
||||
[](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/).
|
||||
|
||||
> [!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 and block downloads that have a low download speed or high estimated completion time.
|
||||
> - Remove downloads blocked by qBittorrent or by cleanuperr's **content blocker**.
|
||||
> - Remove downloads blocked by qBittorrent or by Cleanuperr's **content blocker**.
|
||||
> - Trigger a search for downloads removed from the *arrs.
|
||||
> - Clean up downloads that have been seeding for a certain amount of time.
|
||||
> - Remove downloads that have been seeding for a certain amount of time.
|
||||
> - Remove downloads that have no hardlinks (have been upgraded by the *arrs).
|
||||
> - Notify on strike or download removal.
|
||||
> - Ignore certain torrent hashes, categories, tags or trackers from processing.
|
||||
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuperr.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
> [!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
|
||||
# Docs
|
||||
|
||||
## 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
|
||||
|
||||
#### 1. **Content blocker** will:
|
||||
- Run every 5 minutes (or configured cron).
|
||||
- Process all items in the *arr queue.
|
||||
- Find the corresponding item from the download client for each queue item.
|
||||
- Mark the files that were found in the queue as **unwanted/skipped** if:
|
||||
- They **are listed in the blacklist**, or
|
||||
- They **are not included in the whitelist**.
|
||||
- If **all files** of a download **are unwanted**:
|
||||
- It will be removed from the *arr's queue and blocked.
|
||||
- It will be deleted from the download client.
|
||||
- A new search will be triggered for the *arr item.
|
||||
#### 2. **Queue cleaner** will:
|
||||
- Run every 5 minutes (or configured cron, or right after `content blocker`).
|
||||
- Process all items in the *arr queue.
|
||||
- Check each queue item if it is **stalled (download speed is 0)**, **stuck in metadata downloading**, **failed to be imported** or **slow**.
|
||||
- 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:
|
||||
- **Marked as completed, but 0 bytes have been downloaded** (due to files being blocked by qBittorrent or the **content blocker**).
|
||||
- All associated files are marked as **unwanted/skipped/do not download**.
|
||||
- If the item **DOES NOT** match the above criteria, it will be skipped.
|
||||
- If the item **DOES** match the criteria or has received the **maximum number of strikes**:
|
||||
- It will be removed from the *arr's queue and blocked.
|
||||
- It will be deleted from the download client.
|
||||
- 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 examples
|
||||
|
||||
## Using qBittorrent's built-in feature (works only with qBittorrent)
|
||||
|
||||
1. Go to qBittorrent -> Options -> Downloads -> make sure `Excluded file names` is checked -> Paste an exclusion list that you have copied.
|
||||
- [blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist), or
|
||||
- [permissive blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist_permissive), or
|
||||
- create your own
|
||||
2. qBittorrent will block files from being downloaded. In the case of malicious content, **nothing is downloaded and the torrent is marked as complete**.
|
||||
3. Start **cleanuperr** with `QUEUECLEANER__ENABLED` set to `true`.
|
||||
4. The **queue cleaner** will perform a cleanup process as described in the [How it works](#how-it-works) section.
|
||||
|
||||
## Using cleanuperr's blocklist (works with all supported download clients)
|
||||
|
||||
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](variables.md#Arr-settings) section.
|
||||
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 **queue cleaner** job, as explained in the [How it works](#how-it-works) section.
|
||||
|
||||
## Using cleanuperr just for failed *arr imports (works for Usenet users as well)
|
||||
|
||||
1. Set `QUEUECLEANER__ENABLED` to `true`.
|
||||
2. Set `QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES` to a desired value.
|
||||
3. Optionally set failed import message patterns to ignore using `QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__<NUMBER>`.
|
||||
4. Set `DOWNLOAD_CLIENT` to `none`(works only for usenet) or `disabled` (works for both usenet and torrent).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> When `DOWNLOAD_CLIENT=disabled`, no other action involving a download client would work (e.g. content blocking, removing stalled downloads, excluding private trackers).
|
||||
>
|
||||
> When the download client is set to `disabled`, the queue cleaner will be able to remove items that are failed to be imported even if there is no download client configured. This means that all downloads, including private ones, will be completely removed.
|
||||
>
|
||||
> Setting `DOWNLOAD_CLIENT=disabled` means you don't care about seeding, ratio, H&R and potentially losing your private tracker account.
|
||||
|
||||
## Usage
|
||||
|
||||
### <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"
|
||||
services:
|
||||
cleanuperr:
|
||||
image: ghcr.io/flmorg/cleanuperr:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./cleanuperr/logs:/var/logs
|
||||
- ./cleanuperr/ignored.txt:/ignored.txt
|
||||
environment:
|
||||
# general settings
|
||||
- TZ=America/New_York
|
||||
- DRY_RUN=false
|
||||
- HTTP_MAX_RETRIES=0
|
||||
- HTTP_TIMEOUT=100
|
||||
|
||||
# logging
|
||||
- LOGGING__LOGLEVEL=Information
|
||||
- LOGGING__FILE__ENABLED=false
|
||||
- LOGGING__FILE__PATH=/var/logs/
|
||||
- LOGGING__ENHANCED=true
|
||||
|
||||
# job triggers
|
||||
- TRIGGERS__QUEUECLEANER=0 0/5 * * * ?
|
||||
- TRIGGERS__CONTENTBLOCKER=0 0/5 * * * ?
|
||||
- TRIGGERS__DOWNLOADCLEANER=0 0 * * * ?
|
||||
|
||||
# queue cleaner
|
||||
- QUEUECLEANER__ENABLED=true
|
||||
- QUEUECLEANER__IGNORED_DOWNLOADS_PATH=/ignored.txt
|
||||
- QUEUECLEANER__RUNSEQUENTIALLY=true
|
||||
|
||||
# failed imports
|
||||
- QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES=5
|
||||
- QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE=false
|
||||
- QUEUECLEANER__IMPORT_FAILED_DELETE_PRIVATE=false
|
||||
- QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__0=title mismatch
|
||||
- QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1=manual import required
|
||||
|
||||
# stalled downloads
|
||||
- QUEUECLEANER__STALLED_MAX_STRIKES=5
|
||||
- QUEUECLEANER__STALLED_RESET_STRIKES_ON_PROGRESS=false
|
||||
- QUEUECLEANER__STALLED_IGNORE_PRIVATE=false
|
||||
- QUEUECLEANER__STALLED_DELETE_PRIVATE=false
|
||||
|
||||
# slow downloads
|
||||
- QUEUECLEANER__SLOW_MAX_STRIKES=5
|
||||
- QUEUECLEANER__SLOW_RESET_STRIKES_ON_PROGRESS=true
|
||||
- QUEUECLEANER__SLOW_IGNORE_PRIVATE=false
|
||||
- QUEUECLEANER__SLOW_DELETE_PRIVATE=false
|
||||
- QUEUECLEANER__SLOW_MIN_SPEED=1MB
|
||||
- QUEUECLEANER__SLOW_MAX_TIME=20
|
||||
- QUEUECLEANER__SLOW_IGNORE_ABOVE_SIZE=60GB
|
||||
|
||||
# content blocker
|
||||
- CONTENTBLOCKER__ENABLED=true
|
||||
- CONTENTBLOCKER__IGNORED_DOWNLOADS_PATH=/ignored.txt
|
||||
- CONTENTBLOCKER__IGNORE_PRIVATE=false
|
||||
- CONTENTBLOCKER__DELETE_PRIVATE=false
|
||||
|
||||
# download cleaner
|
||||
- DOWNLOADCLEANER__ENABLED=true
|
||||
- DOWNLOADCLEANER__IGNORED_DOWNLOADS_PATH=/ignored.txt
|
||||
- DOWNLOADCLEANER__DELETE_PRIVATE=false
|
||||
|
||||
# categories to seed until max ratio or min seed time has been reached
|
||||
- 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
|
||||
# OR
|
||||
# - DOWNLOAD_CLIENT=disabled
|
||||
# OR
|
||||
# - DOWNLOAD_CLIENT=qBittorrent
|
||||
# - QBITTORRENT__URL=http://localhost:8080
|
||||
# - QBITTORRENT__URL_BASE=myCustomPath
|
||||
# - QBITTORRENT__USERNAME=user
|
||||
# - QBITTORRENT__PASSWORD=pass
|
||||
# OR
|
||||
# - DOWNLOAD_CLIENT=deluge
|
||||
# - DELUGE__URL_BASE=myCustomPath
|
||||
# - DELUGE__URL=http://localhost:8112
|
||||
# - DELUGE__PASSWORD=testing
|
||||
# OR
|
||||
# - DOWNLOAD_CLIENT=transmission
|
||||
# - TRANSMISSION__URL=http://localhost:9091
|
||||
# - TRANSMISSION__URL_BASE=myCustomPath
|
||||
# - TRANSMISSION__USERNAME=test
|
||||
# - TRANSMISSION__PASSWORD=testing
|
||||
|
||||
- SONARR__ENABLED=true
|
||||
- SONARR__SEARCHTYPE=Episode
|
||||
- SONARR__BLOCK__TYPE=blacklist
|
||||
- SONARR__BLOCK__PATH=https://example.com/path/to/file.txt
|
||||
- SONARR__INSTANCES__0__URL=http://localhost:8989
|
||||
- SONARR__INSTANCES__0__APIKEY=secret1
|
||||
- SONARR__INSTANCES__1__URL=http://localhost:8990
|
||||
- SONARR__INSTANCES__1__APIKEY=secret2
|
||||
|
||||
- RADARR__ENABLED=true
|
||||
- RADARR__BLOCK__TYPE=blacklist
|
||||
- RADARR__BLOCK__PATH=https://example.com/path/to/file.txt
|
||||
- RADARR__INSTANCES__0__URL=http://localhost:7878
|
||||
- RADARR__INSTANCES__0__APIKEY=secret3
|
||||
- RADARR__INSTANCES__1__URL=http://localhost:7879
|
||||
- RADARR__INSTANCES__1__APIKEY=secret4
|
||||
|
||||
- LIDARR__ENABLED=true
|
||||
- LIDARR__BLOCK__TYPE=blacklist
|
||||
- LIDARR__BLOCK__PATH=https://example.com/path/to/file.txt
|
||||
- LIDARR__INSTANCES__0__URL=http://radarr:8686
|
||||
- LIDARR__INSTANCES__0__APIKEY=secret5
|
||||
- LIDARR__INSTANCES__1__URL=http://radarr:8687
|
||||
- LIDARR__INSTANCES__1__APIKEY=secret6
|
||||
|
||||
- NOTIFIARR__ON_IMPORT_FAILED_STRIKE=true
|
||||
- NOTIFIARR__ON_STALLED_STRIKE=true
|
||||
- NOTIFIARR__ON_SLOW_STRIKE=true
|
||||
- NOTIFIARR__ON_QUEUE_ITEM_DELETED=true
|
||||
- NOTIFIARR__ON_DOWNLOAD_CLEANED=true
|
||||
- NOTIFIARR__API_KEY=notifiarr_secret
|
||||
- NOTIFIARR__CHANNEL_ID=discord_channel_id
|
||||
|
||||
- APPRISE__ON_IMPORT_FAILED_STRIKE=true
|
||||
- APPRISE__ON_STALLED_STRIKE=true
|
||||
- APPRISE__ON_SLOW_STRIKE=true
|
||||
- APPRISE__ON_QUEUE_ITEM_DELETED=true
|
||||
- APPRISE__ON_DOWNLOAD_CLEANED=true
|
||||
- APPRISE__URL=http://apprise:8000
|
||||
- APPRISE__KEY=myConfigKey
|
||||
```
|
||||
|
||||
### <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>
|
||||
|
||||
1. Download the zip file from [releases](https://github.com/flmorg/cleanuperr/releases).
|
||||
2. Extract the zip file into `C:\example\directory`.
|
||||
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`.
|
||||
|
||||
> [!TIP]
|
||||
> ### Run as a Windows Service
|
||||
> 1. Download latest nssm build from `https://nssm.cc/builds`.
|
||||
> 2. Unzip `nssm.exe` in `C:\example\directory`.
|
||||
> 3. Open a terminal with Administrator rights and execute these commands:
|
||||
> ```
|
||||
> nssm.exe install Cleanuperr "C:\example\directory\cleanuperr.exe"
|
||||
> nssm.exe set Cleanuperr AppDirectory "C:\example\directory\"
|
||||
> nssm.exe set Cleanuperr AppStdout "C:\example\directory\cleanuperr.log"
|
||||
> nssm.exe set Cleanuperr AppStderr "C:\example\directory\cleanuperr.crash.log"
|
||||
> nssm.exe set Cleanuperr AppRotateFiles 1
|
||||
> nssm.exe set Cleanuperr AppRotateOnline 1
|
||||
> nssm.exe set Cleanuperr AppRotateBytes 10485760
|
||||
> nssm.exe set Cleanuperr AppRotateFiles 10
|
||||
> nssm.exe set Cleanuperr Start SERVICE_AUTO_START
|
||||
> nssm.exe start Cleanuperr
|
||||
> ```
|
||||
|
||||
### <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>
|
||||
|
||||
1. Download the zip file from [releases](https://github.com/flmorg/cleanuperr/releases).
|
||||
2. Extract the zip file into `/example/directory`.
|
||||
3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](#environment-variables).
|
||||
4. Open a terminal and execute these commands:
|
||||
```
|
||||
cd /example/directory
|
||||
chmod +x cleanuperr
|
||||
./cleanuperr
|
||||
```
|
||||
|
||||
### <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>
|
||||
|
||||
1. Download the zip file from [releases](https://github.com/flmorg/cleanuperr/releases).
|
||||
2. Extract the zip file into `/example/directory`.
|
||||
3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](#environment-variables).
|
||||
4. Open a terminal and execute these commands:
|
||||
```
|
||||
cd /example/directory
|
||||
chmod +x cleanuperr
|
||||
./cleanuperr
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Some people have experienced problems when trying to execute cleanuperr on MacOS because the system actively blocked the file for not being signed.
|
||||
> As per [this comment](https://stackoverflow.com/a/77907937), you may need to also execute this command:
|
||||
> ```
|
||||
> codesign --sign - --force --preserve-metadata=entitlements,requirements,flags,runtime /example/directory/cleanuperr
|
||||
> ```
|
||||
|
||||
### <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>
|
||||
|
||||
1. Installation:
|
||||
```
|
||||
# install dependencies
|
||||
pkg install -y git icu libinotify libunwind wget
|
||||
|
||||
# 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
|
||||
|
||||
# download NuGet dependencies
|
||||
mkdir -p /tmp/nuget
|
||||
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
|
||||
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
|
||||
|
||||
# add NuGet source
|
||||
dotnet nuget add source /tmp/nuget --name tmp
|
||||
|
||||
# add GitHub NuGet source
|
||||
# a PAT (Personal Access Token) can be generated here https://github.com/settings/tokens
|
||||
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
|
||||
|
||||
# build and publish the app
|
||||
dotnet publish code/Executable/Executable.csproj -c Release --self-contained -o artifacts /p:PublishSingleFile=true
|
||||
|
||||
# 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
|
||||
```
|
||||
Docs can be found [here](https://flmorg.github.io/cleanuperr/).
|
||||
|
||||
# Credits
|
||||
Special thanks for inspiration go to:
|
||||
|
||||
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Common.Configuration.DownloadCleaner;
|
||||
|
||||
public sealed record Category : IConfig
|
||||
public sealed record CleanCategory : IConfig
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
|
||||
@@ -8,8 +8,8 @@ public sealed record DownloadCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
|
||||
public const string SectionName = "DownloadCleaner";
|
||||
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public List<Category>? Categories { get; init; }
|
||||
|
||||
public List<CleanCategory>? Categories { get; init; }
|
||||
|
||||
[ConfigurationKeyName("DELETE_PRIVATE")]
|
||||
public bool DeletePrivate { get; init; }
|
||||
@@ -17,6 +17,15 @@ public sealed record DownloadCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
|
||||
[ConfigurationKeyName("IGNORED_DOWNLOADS_PATH")]
|
||||
public string? IgnoredDownloadsPath { get; init; }
|
||||
|
||||
[ConfigurationKeyName("UNLINKED_TARGET_CATEGORY")]
|
||||
public string UnlinkedTargetCategory { get; init; } = "cleanuperr-unlinked";
|
||||
|
||||
[ConfigurationKeyName("UNLINKED_IGNORED_ROOT_DIR")]
|
||||
public string UnlinkedIgnoredRootDir { get; init; } = string.Empty;
|
||||
|
||||
[ConfigurationKeyName("UNLINKED_CATEGORIES")]
|
||||
public List<string>? UnlinkedCategories { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
@@ -31,9 +40,34 @@ public sealed record DownloadCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
|
||||
|
||||
if (Categories?.GroupBy(x => x.Name).Any(x => x.Count() > 1) is true)
|
||||
{
|
||||
throw new ValidationException("duplicated categories found");
|
||||
throw new ValidationException("duplicated clean categories found");
|
||||
}
|
||||
|
||||
Categories?.ForEach(x => x.Validate());
|
||||
|
||||
if (string.IsNullOrEmpty(UnlinkedTargetCategory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (UnlinkedCategories?.Count is null or 0)
|
||||
{
|
||||
throw new ValidationException("no unlinked categories configured");
|
||||
}
|
||||
|
||||
if (UnlinkedCategories.Contains(UnlinkedTargetCategory))
|
||||
{
|
||||
throw new ValidationException($"{SectionName.ToUpperInvariant()}__UNLINKED_TARGET_CATEGORY should not be present in {SectionName.ToUpperInvariant()}__UNLINKED_CATEGORIES");
|
||||
}
|
||||
|
||||
if (UnlinkedCategories.Any(string.IsNullOrEmpty))
|
||||
{
|
||||
throw new ValidationException("empty unlinked category filter found");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(UnlinkedIgnoredRootDir) && !Directory.Exists(UnlinkedIgnoredRootDir))
|
||||
{
|
||||
throw new ValidationException($"{UnlinkedIgnoredRootDir} root directory does not exist");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,17 @@ public abstract record NotificationConfig
|
||||
|
||||
[ConfigurationKeyName("ON_DOWNLOAD_CLEANED")]
|
||||
public bool OnDownloadCleaned { get; init; }
|
||||
|
||||
[ConfigurationKeyName("ON_CATEGORY_CHANGED")]
|
||||
public bool OnCategoryChanged { get; init; }
|
||||
|
||||
public bool IsEnabled => OnImportFailedStrike || OnStalledStrike || OnSlowStrike || OnQueueItemDeleted || OnDownloadCleaned;
|
||||
public bool IsEnabled =>
|
||||
OnImportFailedStrike ||
|
||||
OnStalledStrike ||
|
||||
OnSlowStrike ||
|
||||
OnQueueItemDeleted ||
|
||||
OnDownloadCleaned ||
|
||||
OnCategoryChanged;
|
||||
|
||||
public abstract bool IsValid();
|
||||
}
|
||||
@@ -25,7 +25,7 @@ public sealed record QueueCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
|
||||
public bool ImportFailedDeletePrivate { get; init; }
|
||||
|
||||
[ConfigurationKeyName("IMPORT_FAILED_IGNORE_PATTERNS")]
|
||||
public List<string>? ImportFailedIgnorePatterns { get; init; }
|
||||
public IReadOnlyList<string>? ImportFailedIgnorePatterns { get; init; }
|
||||
|
||||
[ConfigurationKeyName("STALLED_MAX_STRIKES")]
|
||||
public ushort StalledMaxStrikes { get; init; }
|
||||
@@ -39,6 +39,9 @@ public sealed record QueueCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
|
||||
[ConfigurationKeyName("STALLED_DELETE_PRIVATE")]
|
||||
public bool StalledDeletePrivate { get; init; }
|
||||
|
||||
[ConfigurationKeyName("DOWNLOADING_METADATA_MAX_STRIKES")]
|
||||
public ushort DownloadingMetadataMaxStrikes { get; init; }
|
||||
|
||||
[ConfigurationKeyName("SLOW_MAX_STRIKES")]
|
||||
public ushort SlowMaxStrikes { get; init; }
|
||||
|
||||
@@ -63,22 +66,27 @@ public sealed record QueueCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
|
||||
public string SlowIgnoreAboveSize { get; init; } = string.Empty;
|
||||
|
||||
public ByteSize? SlowIgnoreAboveSizeByteSize => string.IsNullOrEmpty(SlowIgnoreAboveSize) ? null : ByteSize.Parse(SlowIgnoreAboveSize);
|
||||
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (ImportFailedMaxStrikes is > 0 and < 3)
|
||||
{
|
||||
throw new ValidationException($"the minimum value for {SectionName}__IMPORT_FAILED_MAX_STRIKES must be 3");
|
||||
throw new ValidationException($"the minimum value for {SectionName.ToUpperInvariant()}__IMPORT_FAILED_MAX_STRIKES must be 3");
|
||||
}
|
||||
|
||||
if (StalledMaxStrikes is > 0 and < 3)
|
||||
{
|
||||
throw new ValidationException($"the minimum value for {SectionName}__STALLED_MAX_STRIKES must be 3");
|
||||
throw new ValidationException($"the minimum value for {SectionName.ToUpperInvariant()}__STALLED_MAX_STRIKES must be 3");
|
||||
}
|
||||
|
||||
if (DownloadingMetadataMaxStrikes is > 0 and < 3)
|
||||
{
|
||||
throw new ValidationException($"the minimum value for {SectionName.ToUpperInvariant()}__DOWNLOADING_METADATA_MAX_STRIKES must be 3");
|
||||
}
|
||||
|
||||
if (SlowMaxStrikes is > 0 and < 3)
|
||||
{
|
||||
throw new ValidationException($"the minimum value for {SectionName}__SLOW_MAX_STRIKES must be 3");
|
||||
throw new ValidationException($"the minimum value for {SectionName.ToUpperInvariant()}__SLOW_MAX_STRIKES must be 3");
|
||||
}
|
||||
|
||||
if (SlowMaxStrikes > 0)
|
||||
@@ -87,24 +95,24 @@ public sealed record QueueCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
|
||||
|
||||
if (isSlowSpeedSet && ByteSize.TryParse(SlowMinSpeed, out _) is false)
|
||||
{
|
||||
throw new ValidationException($"invalid value for {SectionName}__SLOW_MIN_SPEED");
|
||||
throw new ValidationException($"invalid value for {SectionName.ToUpperInvariant()}__SLOW_MIN_SPEED");
|
||||
}
|
||||
|
||||
if (SlowMaxTime < 0)
|
||||
{
|
||||
throw new ValidationException($"invalid value for {SectionName}__SLOW_MAX_TIME");
|
||||
throw new ValidationException($"invalid value for {SectionName.ToUpperInvariant()}__SLOW_MAX_TIME");
|
||||
}
|
||||
|
||||
if (!isSlowSpeedSet && SlowMaxTime is 0)
|
||||
{
|
||||
throw new ValidationException($"either {SectionName}__SLOW_MIN_SPEED or {SectionName}__SLOW_MAX_STRIKES must be set");
|
||||
throw new ValidationException($"either {SectionName.ToUpperInvariant()}__SLOW_MIN_SPEED or {SectionName.ToUpperInvariant()}__SLOW_MAX_STRIKES must be set");
|
||||
}
|
||||
|
||||
bool isSlowIgnoreAboveSizeSet = !string.IsNullOrEmpty(SlowIgnoreAboveSize);
|
||||
|
||||
if (isSlowIgnoreAboveSizeSet && ByteSize.TryParse(SlowIgnoreAboveSize, out _) is false)
|
||||
{
|
||||
throw new ValidationException($"invalid value for {SectionName}__SLOW_IGNORE_ABOVE_SIZE");
|
||||
throw new ValidationException($"invalid value for {SectionName.ToUpperInvariant()}__SLOW_IGNORE_ABOVE_SIZE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
code/Common/Exceptions/FatalException.cs
Normal file
12
code/Common/Exceptions/FatalException.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Common.Exceptions;
|
||||
|
||||
public class FatalException : Exception
|
||||
{
|
||||
public FatalException()
|
||||
{
|
||||
}
|
||||
|
||||
public FatalException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Domain.Enums;
|
||||
namespace Domain.Enums;
|
||||
|
||||
public enum DeleteReason
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed record DownloadStatus
|
||||
[JsonProperty("total_done")]
|
||||
public long TotalDone { get; init; }
|
||||
|
||||
public string? Label { get; init; }
|
||||
public string? Label { get; set; }
|
||||
|
||||
[JsonProperty("seeding_time")]
|
||||
public long SeedingTime { get; init; }
|
||||
@@ -31,6 +31,9 @@ public sealed record DownloadStatus
|
||||
public float Ratio { get; init; }
|
||||
|
||||
public required IReadOnlyList<Tracker> Trackers { get; init; }
|
||||
|
||||
[JsonProperty("download_location")]
|
||||
public required string DownloadLocation { get; init; }
|
||||
}
|
||||
|
||||
public sealed record Tracker
|
||||
|
||||
@@ -17,7 +17,9 @@ public static class MainDI
|
||||
.AddLogging(builder => builder.ClearProviders().AddConsole())
|
||||
.AddHttpClients(configuration)
|
||||
.AddConfiguration(configuration)
|
||||
.AddMemoryCache()
|
||||
.AddMemoryCache(options => {
|
||||
options.ExpirationScanFrequency = TimeSpan.FromMinutes(1);
|
||||
})
|
||||
.AddServices()
|
||||
.AddQuartzServices(configuration)
|
||||
.AddNotifications(configuration)
|
||||
@@ -28,6 +30,7 @@ public static class MainDI
|
||||
config.AddConsumer<NotificationConsumer<SlowStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
|
||||
|
||||
config.UsingInMemory((context, cfg) =>
|
||||
{
|
||||
@@ -38,6 +41,7 @@ public static class MainDI
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);
|
||||
e.ConcurrentMessageLimit = 1;
|
||||
e.PrefetchCount = 1;
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ using Infrastructure.Verticals.DownloadClient;
|
||||
using Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
using Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||
using Infrastructure.Verticals.DownloadClient.Transmission;
|
||||
using Infrastructure.Verticals.Files;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
|
||||
@@ -27,14 +28,17 @@ public static class ServicesDI
|
||||
.AddTransient<ContentBlocker>()
|
||||
.AddTransient<DownloadCleaner>()
|
||||
.AddTransient<IFilenameEvaluator, FilenameEvaluator>()
|
||||
.AddTransient<IHardLinkFileService, HardLinkFileService>()
|
||||
.AddTransient<UnixHardLinkFileService>()
|
||||
.AddTransient<WindowsHardLinkFileService>()
|
||||
.AddTransient<DummyDownloadService>()
|
||||
.AddTransient<QBitService>()
|
||||
.AddTransient<DelugeService>()
|
||||
.AddTransient<TransmissionService>()
|
||||
.AddTransient<ArrQueueIterator>()
|
||||
.AddTransient<DownloadServiceFactory>()
|
||||
.AddTransient<IStriker, Striker>()
|
||||
.AddSingleton<BlocklistProvider>()
|
||||
.AddSingleton<IStriker, Striker>()
|
||||
.AddSingleton<IgnoredDownloadsProvider<QueueCleanerConfig>>()
|
||||
.AddSingleton<IgnoredDownloadsProvider<ContentBlockerConfig>>()
|
||||
.AddSingleton<IgnoredDownloadsProvider<DownloadCleanerConfig>>();
|
||||
|
||||
@@ -25,16 +25,17 @@
|
||||
"Enabled": true,
|
||||
"RunSequentially": true,
|
||||
"IGNORED_DOWNLOADS_PATH": "../test/data/cleanuperr/ignored_downloads",
|
||||
"IMPORT_FAILED_MAX_STRIKES": 5,
|
||||
"IMPORT_FAILED_MAX_STRIKES": 3,
|
||||
"IMPORT_FAILED_IGNORE_PRIVATE": true,
|
||||
"IMPORT_FAILED_DELETE_PRIVATE": false,
|
||||
"IMPORT_FAILED_IGNORE_PATTERNS": [
|
||||
"file is a sample"
|
||||
],
|
||||
"STALLED_MAX_STRIKES": 5,
|
||||
"STALLED_MAX_STRIKES": 3,
|
||||
"STALLED_RESET_STRIKES_ON_PROGRESS": true,
|
||||
"STALLED_IGNORE_PRIVATE": true,
|
||||
"STALLED_DELETE_PRIVATE": false,
|
||||
"DOWNLOADING_METADATA_MAX_STRIKES": 3,
|
||||
"SLOW_MAX_STRIKES": 5,
|
||||
"SLOW_RESET_STRIKES_ON_PROGRESS": true,
|
||||
"SLOW_IGNORE_PRIVATE": false,
|
||||
@@ -51,9 +52,15 @@
|
||||
"Name": "tv-sonarr",
|
||||
"MAX_RATIO": -1,
|
||||
"MIN_SEED_TIME": 0,
|
||||
"MAX_SEED_TIME": -1
|
||||
"MAX_SEED_TIME": 240
|
||||
}
|
||||
],
|
||||
"UNLINKED_TARGET_CATEGORY": "cleanuperr-unlinked",
|
||||
"UNLINKED_IGNORED_ROOT_DIR": "",
|
||||
"UNLINKED_CATEGORIES": [
|
||||
"tv-sonarr",
|
||||
"radarr"
|
||||
],
|
||||
"IGNORED_DOWNLOADS_PATH": "../test/data/cleanuperr/ignored_downloads"
|
||||
},
|
||||
"DOWNLOAD_CLIENT": "qbittorrent",
|
||||
@@ -120,6 +127,7 @@
|
||||
"ON_SLOW_STRIKE": true,
|
||||
"ON_QUEUE_ITEM_DELETED": true,
|
||||
"ON_DOWNLOAD_CLEANED": true,
|
||||
"ON_CATEGORY_CHANGED": true,
|
||||
"API_KEY": "",
|
||||
"CHANNEL_ID": ""
|
||||
},
|
||||
@@ -129,6 +137,7 @@
|
||||
"ON_SLOW_STRIKE": true,
|
||||
"ON_QUEUE_ITEM_DELETED": true,
|
||||
"ON_DOWNLOAD_CLEANED": true,
|
||||
"ON_CATEGORY_CHANGED": true,
|
||||
"URL": "http://localhost:8000",
|
||||
"KEY": ""
|
||||
}
|
||||
|
||||
@@ -31,12 +31,16 @@
|
||||
"STALLED_MAX_STRIKES": 0,
|
||||
"STALLED_RESET_STRIKES_ON_PROGRESS": false,
|
||||
"STALLED_IGNORE_PRIVATE": false,
|
||||
"STALLED_DELETE_PRIVATE": false
|
||||
"STALLED_DELETE_PRIVATE": false,
|
||||
"DOWNLOADING_METADATA_MAX_STRIKES": 0
|
||||
},
|
||||
"DownloadCleaner": {
|
||||
"Enabled": false,
|
||||
"DELETE_PRIVATE": false,
|
||||
"CATEGORIES": [],
|
||||
"UNLINKED_TARGET_CATEGORY": "cleanuperr-unlinked",
|
||||
"UNLINKED_IGNORED_ROOT_DIR": "",
|
||||
"UNLINKED_CATEGORIES": [],
|
||||
"IGNORED_DOWNLOADS_PATH": ""
|
||||
},
|
||||
"DOWNLOAD_CLIENT": "none",
|
||||
@@ -103,6 +107,7 @@
|
||||
"ON_SLOW_STRIKE": false,
|
||||
"ON_QUEUE_ITEM_DELETED": false,
|
||||
"ON_DOWNLOAD_CLEANED": false,
|
||||
"ON_CATEGORY_CHANGED": false,
|
||||
"API_KEY": "",
|
||||
"CHANNEL_ID": ""
|
||||
},
|
||||
@@ -112,6 +117,7 @@
|
||||
"ON_SLOW_STRIKE": false,
|
||||
"ON_QUEUE_ITEM_DELETED": false,
|
||||
"ON_DOWNLOAD_CLEANED": false,
|
||||
"ON_CATEGORY_CHANGED": false,
|
||||
"URL": "",
|
||||
"KEY": ""
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Infrastructure.Verticals.Files;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -56,6 +57,7 @@ public class DownloadServiceFixture : IDisposable
|
||||
var filenameEvaluator = Substitute.For<IFilenameEvaluator>();
|
||||
var notifier = Substitute.For<INotificationPublisher>();
|
||||
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
var hardlinkFileService = Substitute.For<IHardLinkFileService>();
|
||||
|
||||
return new TestDownloadService(
|
||||
Logger,
|
||||
@@ -66,7 +68,8 @@ public class DownloadServiceFixture : IDisposable
|
||||
filenameEvaluator,
|
||||
Striker,
|
||||
notifier,
|
||||
dryRunInterceptor
|
||||
dryRunInterceptor,
|
||||
hardlinkFileService
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ public class DownloadServiceTests : IClassFixture<DownloadServiceFixture>
|
||||
public void WhenRatioAndMinSeedTimeReached_ShouldReturnTrue()
|
||||
{
|
||||
// Arrange
|
||||
Category category = new()
|
||||
CleanCategory category = new()
|
||||
{
|
||||
Name = "test",
|
||||
MaxRatio = 1.0,
|
||||
@@ -137,7 +137,7 @@ public class DownloadServiceTests : IClassFixture<DownloadServiceFixture>
|
||||
public void WhenRatioReachedAndMinSeedTimeNotReached_ShouldReturnFalse()
|
||||
{
|
||||
// Arrange
|
||||
Category category = new()
|
||||
CleanCategory category = new()
|
||||
{
|
||||
Name = "test",
|
||||
MaxRatio = 1.0,
|
||||
@@ -163,7 +163,7 @@ public class DownloadServiceTests : IClassFixture<DownloadServiceFixture>
|
||||
public void WhenMaxSeedTimeReached_ShouldReturnTrue()
|
||||
{
|
||||
// Arrange
|
||||
Category category = new()
|
||||
CleanCategory category = new()
|
||||
{
|
||||
Name = "test",
|
||||
MaxRatio = -1,
|
||||
@@ -189,7 +189,7 @@ public class DownloadServiceTests : IClassFixture<DownloadServiceFixture>
|
||||
public void WhenNeitherConditionMet_ShouldReturnFalse()
|
||||
{
|
||||
// Arrange
|
||||
Category category = new()
|
||||
CleanCategory category = new()
|
||||
{
|
||||
Name = "test",
|
||||
MaxRatio = 2.0,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
@@ -7,6 +7,7 @@ using Domain.Enums;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Infrastructure.Verticals.Files;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -26,10 +27,11 @@ public class TestDownloadService : DownloadService
|
||||
IFilenameEvaluator filenameEvaluator,
|
||||
IStriker striker,
|
||||
INotificationPublisher notifier,
|
||||
IDryRunInterceptor dryRunInterceptor
|
||||
IDryRunInterceptor dryRunInterceptor,
|
||||
IHardLinkFileService hardLinkFileService
|
||||
) : base(
|
||||
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
|
||||
filenameEvaluator, striker, notifier, dryRunInterceptor
|
||||
filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -40,11 +42,13 @@ public class TestDownloadService : DownloadService
|
||||
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;
|
||||
|
||||
public override Task CreateCategoryAsync(string name) => Task.CompletedTask;
|
||||
public override Task<List<object>?> GetSeedingDownloads() => Task.FromResult<List<object>?>(null);
|
||||
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) => null;
|
||||
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories) => null;
|
||||
public override Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads) => Task.CompletedTask;
|
||||
public override Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads) => Task.CompletedTask;
|
||||
// Expose protected methods for testing
|
||||
public new void ResetStalledStrikesOnProgress(string hash, long downloaded) => base.ResetStalledStrikesOnProgress(hash, downloaded);
|
||||
public new SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, Category category) => base.ShouldCleanDownload(ratio, seedingTime, category);
|
||||
public new SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, CleanCategory category) => base.ShouldCleanDownload(ratio, seedingTime, category);
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
<PackageReference Include="MassTransit" Version="8.3.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
|
||||
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
|
||||
<PackageReference Include="Quartz" Version="3.13.1" />
|
||||
<PackageReference Include="Scrutor" Version="6.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -43,7 +43,7 @@ public sealed class BlocklistProvider
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
_logger.LogDebug("blocklists already loaded");
|
||||
_logger.LogTrace("blocklists already loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
private readonly IgnoredDownloadsProvider<DownloadCleanerConfig> _ignoredDownloadsProvider;
|
||||
private readonly HashSet<string> _excludedHashes = [];
|
||||
|
||||
private static bool _hardLinkCategoryCreated;
|
||||
|
||||
public DownloadCleaner(
|
||||
ILogger<DownloadCleaner> logger,
|
||||
IOptions<DownloadCleanerConfig> config,
|
||||
@@ -65,13 +67,20 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
IReadOnlyList<string> ignoredDownloads = await _ignoredDownloadsProvider.GetIgnoredDownloads();
|
||||
|
||||
await _downloadService.LoginAsync();
|
||||
|
||||
List<object>? downloads = await _downloadService.GetAllDownloadsToBeCleaned(_config.Categories);
|
||||
|
||||
if (downloads?.Count is null or 0)
|
||||
List<object>? downloads = await _downloadService.GetSeedingDownloads();
|
||||
List<object>? downloadsToChangeCategory = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(_config.UnlinkedTargetCategory) && _config.UnlinkedCategories?.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("no downloads found in the download client");
|
||||
return;
|
||||
if (!_hardLinkCategoryCreated)
|
||||
{
|
||||
_logger.LogDebug("creating category {cat}", _config.UnlinkedTargetCategory);
|
||||
|
||||
await _downloadService.CreateCategoryAsync(_config.UnlinkedTargetCategory);
|
||||
_hardLinkCategoryCreated = true;
|
||||
}
|
||||
|
||||
downloadsToChangeCategory = _downloadService.FilterDownloadsToChangeCategoryAsync(downloads, _config.UnlinkedCategories);
|
||||
}
|
||||
|
||||
// wait for the downloads to appear in the arr queue
|
||||
@@ -81,7 +90,16 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
await ProcessArrConfigAsync(_radarrConfig, InstanceType.Radarr, true);
|
||||
await ProcessArrConfigAsync(_lidarrConfig, InstanceType.Lidarr, true);
|
||||
|
||||
await _downloadService.CleanDownloads(downloads, _config.Categories, _excludedHashes, ignoredDownloads);
|
||||
_logger.LogTrace("looking for downloads to change category");
|
||||
await _downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory, _excludedHashes, ignoredDownloads);
|
||||
|
||||
List<object>? downloadsToClean = _downloadService.FilterDownloadsToBeCleanedAsync(downloads, _config.Categories);
|
||||
|
||||
// release unused objects
|
||||
downloads = null;
|
||||
|
||||
_logger.LogTrace("looking for downloads to clean");
|
||||
await _downloadService.CleanDownloadsAsync(downloadsToClean, _config.Categories, _excludedHashes, ignoredDownloads);
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Exceptions;
|
||||
using Domain.Models.Deluge.Exceptions;
|
||||
using Domain.Models.Deluge.Request;
|
||||
using Domain.Models.Deluge.Response;
|
||||
@@ -29,7 +30,8 @@ public sealed class DelugeClient
|
||||
"ratio",
|
||||
"trackers",
|
||||
"download_payload_rate",
|
||||
"total_size"
|
||||
"total_size",
|
||||
"download_location"
|
||||
];
|
||||
|
||||
public DelugeClient(IOptions<DelugeConfig> config, IHttpClientFactory httpClientFactory)
|
||||
@@ -44,11 +46,42 @@ public sealed class DelugeClient
|
||||
return await SendRequest<bool>("auth.login", _config.Password);
|
||||
}
|
||||
|
||||
public async Task<bool> IsConnected()
|
||||
{
|
||||
return await SendRequest<bool>("web.connected");
|
||||
}
|
||||
|
||||
public async Task<bool> Connect()
|
||||
{
|
||||
string? firstHost = await GetHost();
|
||||
|
||||
if (string.IsNullOrEmpty(firstHost))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = await SendRequest<List<string>?>("web.connect", firstHost);
|
||||
|
||||
return result?.Count > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> Logout()
|
||||
{
|
||||
return await SendRequest<bool>("auth.delete_session");
|
||||
}
|
||||
|
||||
public async Task<string?> GetHost()
|
||||
{
|
||||
var hosts = await SendRequest<List<List<string>?>?>("web.get_hosts");
|
||||
|
||||
if (hosts?.Count > 1)
|
||||
{
|
||||
throw new FatalException("multiple Deluge hosts found - please connect to only one host");
|
||||
}
|
||||
|
||||
return hosts?.FirstOrDefault()?.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<List<DelugeTorrent>> ListTorrents(Dictionary<string, string>? filters = null)
|
||||
{
|
||||
filters ??= new Dictionary<string, string>();
|
||||
@@ -149,7 +182,7 @@ public sealed class DelugeClient
|
||||
return responseJson;
|
||||
}
|
||||
|
||||
private DelugeRequest CreateRequest(string method, params object[] parameters)
|
||||
private static DelugeRequest CreateRequest(string method, params object[] parameters)
|
||||
{
|
||||
if (String.IsNullOrWhiteSpace(method))
|
||||
{
|
||||
@@ -195,4 +228,19 @@ public sealed class DelugeClient
|
||||
|
||||
return webResponse.Result;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetLabels()
|
||||
{
|
||||
return await SendRequest<IReadOnlyList<string>>("label.get_labels");
|
||||
}
|
||||
|
||||
public async Task CreateLabel(string label)
|
||||
{
|
||||
await SendRequest<DelugeResponse<object>>("label.add", label);
|
||||
}
|
||||
|
||||
public async Task SetTorrentLabel(string hash, string newLabel)
|
||||
{
|
||||
await SendRequest<DelugeResponse<object>>("label.set_torrent", hash, newLabel);
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,14 @@ using Common.Configuration.DownloadCleaner;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Common.CustomDataTypes;
|
||||
using Common.Exceptions;
|
||||
using Domain.Enums;
|
||||
using Domain.Models.Deluge.Response;
|
||||
using Infrastructure.Extensions;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.Context;
|
||||
using Infrastructure.Verticals.Files;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -36,10 +38,11 @@ public class DelugeService : DownloadService, IDelugeService
|
||||
IFilenameEvaluator filenameEvaluator,
|
||||
IStriker striker,
|
||||
INotificationPublisher notifier,
|
||||
IDryRunInterceptor dryRunInterceptor
|
||||
IDryRunInterceptor dryRunInterceptor,
|
||||
IHardLinkFileService hardLinkFileService
|
||||
) : base(
|
||||
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
|
||||
filenameEvaluator, striker, notifier, dryRunInterceptor
|
||||
filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService
|
||||
)
|
||||
{
|
||||
config.Value.Validate();
|
||||
@@ -49,6 +52,11 @@ public class DelugeService : DownloadService, IDelugeService
|
||||
public override async Task LoginAsync()
|
||||
{
|
||||
await _client.LoginAsync();
|
||||
|
||||
if (!await _client.IsConnected() && !await _client.Connect())
|
||||
{
|
||||
throw new FatalException("Deluge WebUI is not connected to the daemon");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -208,26 +216,51 @@ public class DelugeService : DownloadService, IDelugeService
|
||||
return result;
|
||||
}
|
||||
|
||||
public override async Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories)
|
||||
public override async Task<List<object>?> GetSeedingDownloads()
|
||||
{
|
||||
return (await _client.GetStatusForAllTorrents())
|
||||
?.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => x.State?.Equals("seeding", StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
.Where(x => categories.Any(cat => cat.Name.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) =>
|
||||
downloads
|
||||
?.Cast<DownloadStatus>()
|
||||
.Where(x => categories.Any(cat => cat.Name.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
|
||||
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories) =>
|
||||
downloads
|
||||
?.Cast<DownloadStatus>()
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => categories.Any(cat => cat.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
|
||||
public override async Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes,
|
||||
IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (DownloadStatus download in downloads)
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.Hash))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
@@ -235,19 +268,13 @@ public class DelugeService : DownloadService, IDelugeService
|
||||
continue;
|
||||
}
|
||||
|
||||
Category? category = categoriesToClean
|
||||
CleanCategory? 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)
|
||||
{
|
||||
@@ -279,7 +306,107 @@ public class DelugeService : DownloadService, IDelugeService
|
||||
await _notifier.NotifyDownloadCleaned(download.Ratio, seedingTime, category.Name, result.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public override async Task CreateCategoryAsync(string name)
|
||||
{
|
||||
IReadOnlyList<string> existingLabels = await _client.GetLabels();
|
||||
|
||||
if (existingLabels.Contains(name, StringComparer.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(CreateLabel, name);
|
||||
}
|
||||
|
||||
public override async Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir))
|
||||
{
|
||||
_hardLinkFileService.PopulateFileCounts(_downloadCleanerConfig.UnlinkedIgnoredRootDir);
|
||||
}
|
||||
|
||||
foreach (DownloadStatus download in downloads.Cast<DownloadStatus>())
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.Hash) || string.IsNullOrEmpty(download.Name) || string.IsNullOrEmpty(download.Label))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
|
||||
DelugeContents? contents = null;
|
||||
try
|
||||
{
|
||||
contents = await _client.GetTorrentFiles(download.Hash);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find torrent files for {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
bool hasHardlinks = false;
|
||||
|
||||
ProcessFiles(contents?.Contents, (_, file) =>
|
||||
{
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.DownloadLocation, file.Path).Split(['\\', '/']));
|
||||
|
||||
if (file.Priority <= 0)
|
||||
{
|
||||
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir));
|
||||
|
||||
if (hardlinkCount < 0)
|
||||
{
|
||||
_logger.LogDebug("skip | could not get file properties | {file}", filePath);
|
||||
hasHardlinks = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hardlinkCount > 0)
|
||||
{
|
||||
hasHardlinks = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasHardlinks)
|
||||
{
|
||||
_logger.LogDebug("skip | download has hardlinks | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeLabel, download.Hash, _downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
_logger.LogInformation("category changed for {name}", download.Name);
|
||||
|
||||
await _notifier.NotifyCategoryChanged(download.Label, _downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
download.Label = _downloadCleanerConfig.UnlinkedTargetCategory;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[DryRunSafeguard]
|
||||
public override async Task DeleteDownload(string hash)
|
||||
@@ -288,6 +415,12 @@ public class DelugeService : DownloadService, IDelugeService
|
||||
|
||||
await _client.DeleteTorrents([hash]);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected async Task CreateLabel(string name)
|
||||
{
|
||||
await _client.CreateLabel(name);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task ChangeFilesPriority(string hash, List<int> sortedPriorities)
|
||||
@@ -295,6 +428,12 @@ public class DelugeService : DownloadService, IDelugeService
|
||||
await _client.ChangeFilesPriority(hash, sortedPriorities);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task ChangeLabel(string hash, string newLabel)
|
||||
{
|
||||
await _client.SetTorrentLabel(hash, newLabel);
|
||||
}
|
||||
|
||||
private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(DownloadStatus status)
|
||||
{
|
||||
(bool ShouldRemove, DeleteReason Reason) result = await CheckIfSlow(status);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadCleaner;
|
||||
@@ -11,6 +11,7 @@ using Infrastructure.Helpers;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.Context;
|
||||
using Infrastructure.Verticals.Files;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -31,6 +32,7 @@ public abstract class DownloadService : IDownloadService
|
||||
protected readonly MemoryCacheEntryOptions _cacheOptions;
|
||||
protected readonly INotificationPublisher _notifier;
|
||||
protected readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
protected readonly IHardLinkFileService _hardLinkFileService;
|
||||
|
||||
protected DownloadService(
|
||||
ILogger<DownloadService> logger,
|
||||
@@ -41,7 +43,8 @@ public abstract class DownloadService : IDownloadService
|
||||
IFilenameEvaluator filenameEvaluator,
|
||||
IStriker striker,
|
||||
INotificationPublisher notifier,
|
||||
IDryRunInterceptor dryRunInterceptor
|
||||
IDryRunInterceptor dryRunInterceptor,
|
||||
IHardLinkFileService hardLinkFileService
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -53,6 +56,7 @@ public abstract class DownloadService : IDownloadService
|
||||
_striker = striker;
|
||||
_notifier = notifier;
|
||||
_dryRunInterceptor = dryRunInterceptor;
|
||||
_hardLinkFileService = hardLinkFileService;
|
||||
_cacheOptions = new MemoryCacheEntryOptions()
|
||||
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
|
||||
}
|
||||
@@ -73,12 +77,23 @@ public abstract class DownloadService : IDownloadService
|
||||
public abstract Task DeleteDownload(string hash);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories);
|
||||
public abstract Task<List<object>?> GetSeedingDownloads();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
|
||||
IReadOnlyList<string> ignoredDownloads);
|
||||
public abstract List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task CreateCategoryAsync(string name);
|
||||
|
||||
protected void ResetStalledStrikesOnProgress(string hash, long downloaded)
|
||||
{
|
||||
if (!_queueCleanerConfig.StalledResetStrikesOnProgress)
|
||||
@@ -179,7 +194,7 @@ public abstract class DownloadService : IDownloadService
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
protected SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, Category category)
|
||||
protected SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, CleanCategory category)
|
||||
{
|
||||
// check ratio
|
||||
if (DownloadReachedRatio(ratio, seedingTime, category))
|
||||
@@ -203,8 +218,28 @@ public abstract class DownloadService : IDownloadService
|
||||
|
||||
return new();
|
||||
}
|
||||
|
||||
protected string? GetRootWithFirstDirectory(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool DownloadReachedRatio(double ratio, TimeSpan seedingTime, Category category)
|
||||
string? root = Path.GetPathRoot(path);
|
||||
|
||||
if (root is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string relativePath = path[root.Length..].TrimStart(Path.DirectorySeparatorChar);
|
||||
string[] parts = relativePath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
return parts.Length > 0 ? Path.Combine(root, parts[0]) : root;
|
||||
}
|
||||
|
||||
private bool DownloadReachedRatio(double ratio, TimeSpan seedingTime, CleanCategory category)
|
||||
{
|
||||
if (category.MaxRatio < 0)
|
||||
{
|
||||
@@ -230,7 +265,7 @@ public abstract class DownloadService : IDownloadService
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool DownloadReachedMaxSeedTime(TimeSpan seedingTime, Category category)
|
||||
private bool DownloadReachedMaxSeedTime(TimeSpan seedingTime, CleanCategory category)
|
||||
{
|
||||
if (category.MaxSeedTime < 0)
|
||||
{
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System.Collections.Concurrent;
|
||||
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.Files;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -15,7 +16,21 @@ namespace Infrastructure.Verticals.DownloadClient;
|
||||
|
||||
public class DummyDownloadService : DownloadService
|
||||
{
|
||||
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)
|
||||
public DummyDownloadService(
|
||||
ILogger<DownloadService> logger,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
IOptions<ContentBlockerConfig> contentBlockerConfig,
|
||||
IOptions<DownloadCleanerConfig> downloadCleanerConfig,
|
||||
IMemoryCache cache,
|
||||
IFilenameEvaluator filenameEvaluator,
|
||||
IStriker striker,
|
||||
INotificationPublisher notifier,
|
||||
IDryRunInterceptor dryRunInterceptor,
|
||||
IHardLinkFileService hardLinkFileService
|
||||
) : base(
|
||||
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig,
|
||||
cache, filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -39,13 +54,32 @@ public class DummyDownloadService : DownloadService
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories)
|
||||
public override Task<List<object>?> GetSeedingDownloads()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
|
||||
IReadOnlyList<string> ignoredDownloads)
|
||||
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override Task CreateCategoryAsync(string name)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
@@ -34,24 +34,52 @@ public interface IDownloadService : IDisposable
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all downloads.
|
||||
/// Fetches all seeding downloads.
|
||||
/// </summary>
|
||||
/// <returns>A list of downloads that are seeding.</returns>
|
||||
Task<List<object>?> GetSeedingDownloads();
|
||||
|
||||
/// <summary>
|
||||
/// Filters downloads that should be cleaned.
|
||||
/// </summary>
|
||||
/// <param name="downloads">The downloads to filter.</param>
|
||||
/// <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);
|
||||
List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories);
|
||||
|
||||
/// <summary>
|
||||
/// Filters downloads that should have their category changed.
|
||||
/// </summary>
|
||||
/// <param name="downloads">The downloads to filter.</param>
|
||||
/// <param name="categories">The categories by which to filter the downloads.</param>
|
||||
/// <returns>A list of downloads for the provided categories.</returns>
|
||||
List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories);
|
||||
|
||||
/// <summary>
|
||||
/// Cleans the downloads.
|
||||
/// </summary>
|
||||
/// <param name="downloads"></param>
|
||||
/// <param name="downloads">The downloads to clean.</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);
|
||||
/// <param name="ignoredDownloads">The downloads to ignore from processing.</param>
|
||||
Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
|
||||
|
||||
/// <summary>
|
||||
/// Changes the category for downloads that have no hardlinks.
|
||||
/// </summary>
|
||||
/// <param name="downloads">The downloads to change.</param>
|
||||
/// <param name="excludedHashes">The hashes that should not be cleaned.</param>
|
||||
/// <param name="ignoredDownloads">The downloads to ignore from processing.</param>
|
||||
Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a download item.
|
||||
/// </summary>
|
||||
public Task DeleteDownload(string hash);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a category.
|
||||
/// </summary>
|
||||
/// <param name="name">The category name.</param>
|
||||
public Task CreateCategoryAsync(string name);
|
||||
}
|
||||
@@ -12,13 +12,13 @@ using Infrastructure.Extensions;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.Context;
|
||||
using Infrastructure.Verticals.Files;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QBittorrent.Client;
|
||||
using Category = Common.Configuration.DownloadCleaner.Category;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||
|
||||
@@ -38,10 +38,11 @@ public class QBitService : DownloadService, IQBitService
|
||||
IFilenameEvaluator filenameEvaluator,
|
||||
IStriker striker,
|
||||
INotificationPublisher notifier,
|
||||
IDryRunInterceptor dryRunInterceptor
|
||||
IDryRunInterceptor dryRunInterceptor,
|
||||
IHardLinkFileService hardLinkFileService
|
||||
) : base(
|
||||
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
|
||||
filenameEvaluator, striker, notifier, dryRunInterceptor
|
||||
filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService
|
||||
)
|
||||
{
|
||||
_config = config.Value;
|
||||
@@ -226,20 +227,42 @@ public class QBitService : DownloadService, IQBitService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories) =>
|
||||
public override async Task<List<object>?> GetSeedingDownloads() =>
|
||||
(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/>
|
||||
public override async Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
|
||||
IReadOnlyList<string> ignoredDownloads)
|
||||
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories) =>
|
||||
downloads
|
||||
?.Cast<TorrentInfo>()
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories) =>
|
||||
downloads
|
||||
?.Cast<TorrentInfo>()
|
||||
.Where(x => !string.IsNullOrEmpty(x.Hash))
|
||||
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean,
|
||||
HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (TorrentInfo download in downloads)
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.Hash))
|
||||
@@ -247,16 +270,22 @@ public class QBitService : DownloadService, IQBitService
|
||||
continue;
|
||||
}
|
||||
|
||||
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(download.Hash);
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)) is true))
|
||||
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads))))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
Category? category = categoriesToClean
|
||||
CleanCategory? category = categoriesToClean
|
||||
.FirstOrDefault(x => download.Category.Equals(x.Name, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (category is null)
|
||||
@@ -264,12 +293,6 @@ public class QBitService : DownloadService, IQBitService
|
||||
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);
|
||||
@@ -315,12 +338,125 @@ public class QBitService : DownloadService, IQBitService
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task CreateCategoryAsync(string name)
|
||||
{
|
||||
IReadOnlyDictionary<string, Category>? existingCategories = await _client.GetCategoriesAsync();
|
||||
|
||||
if (existingCategories.Any(x => x.Value.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(CreateCategory, name);
|
||||
}
|
||||
|
||||
public override async Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir))
|
||||
{
|
||||
_hardLinkFileService.PopulateFileCounts(_downloadCleanerConfig.UnlinkedIgnoredRootDir);
|
||||
}
|
||||
|
||||
foreach (TorrentInfo download in downloads)
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.Hash))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
_logger.LogDebug("skip | download is used by an arr | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentTracker> trackers = await GetTrackersAsync(download.Hash);
|
||||
|
||||
if (ignoredDownloads.Count > 0 &&
|
||||
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads))))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(download.Hash);
|
||||
|
||||
if (files is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find files for {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.Hash);
|
||||
bool hasHardlinks = false;
|
||||
|
||||
foreach (TorrentContent file in files)
|
||||
{
|
||||
if (!file.Index.HasValue)
|
||||
{
|
||||
_logger.LogDebug("skip | file index is null for {name}", download.Name);
|
||||
hasHardlinks = true;
|
||||
break;
|
||||
}
|
||||
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.SavePath, file.Name).Split(['\\', '/']));
|
||||
|
||||
if (file.Priority is TorrentContentPriority.Skip)
|
||||
{
|
||||
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir));
|
||||
|
||||
if (hardlinkCount < 0)
|
||||
{
|
||||
_logger.LogDebug("skip | could not get file properties | {file}", filePath);
|
||||
hasHardlinks = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (hardlinkCount > 0)
|
||||
{
|
||||
hasHardlinks = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasHardlinks)
|
||||
{
|
||||
_logger.LogDebug("skip | download has hardlinks | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeCategory, download.Hash, _downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
_logger.LogInformation("category changed for {name}", download.Name);
|
||||
|
||||
await _notifier.NotifyCategoryChanged(download.Category, _downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
download.Category = _downloadCleanerConfig.UnlinkedTargetCategory;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[DryRunSafeguard]
|
||||
public override async Task DeleteDownload(string hash)
|
||||
{
|
||||
await _client.DeleteAsync(hash, deleteDownloadedData: true);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected async Task CreateCategory(string name)
|
||||
{
|
||||
await _client.AddCategoryAsync(name);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task SkipFile(string hash, int fileIndex)
|
||||
@@ -328,6 +464,12 @@ public class QBitService : DownloadService, IQBitService
|
||||
await _client.SetFilePriorityAsync(hash, fileIndex, TorrentContentPriority.Skip);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task ChangeCategory(string hash, string newCategory)
|
||||
{
|
||||
await _client.SetTorrentCategoryAsync([hash], newCategory);
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
@@ -392,7 +534,7 @@ public class QBitService : DownloadService, IQBitService
|
||||
|
||||
private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(TorrentInfo torrent, bool isPrivate)
|
||||
{
|
||||
if (_queueCleanerConfig.StalledMaxStrikes is 0)
|
||||
if (_queueCleanerConfig.StalledMaxStrikes is 0 && _queueCleanerConfig.DownloadingMetadataMaxStrikes is 0)
|
||||
{
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
@@ -403,26 +545,28 @@ public class QBitService : DownloadService, IQBitService
|
||||
// ignore other states
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (_queueCleanerConfig.StalledIgnorePrivate && isPrivate)
|
||||
|
||||
if (_queueCleanerConfig.StalledMaxStrikes > 0 && torrent.State is TorrentState.StalledDownload)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name);
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
if (torrent.State is TorrentState.StalledDownload)
|
||||
{
|
||||
_logger.LogTrace("stalled download | {name}", torrent.Name);
|
||||
if (_queueCleanerConfig.StalledIgnorePrivate && isPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
ResetStalledStrikesOnProgress(torrent.Hash, torrent.Downloaded ?? 0);
|
||||
|
||||
ResetStalledStrikesOnProgress(torrent.Hash, torrent.Downloaded ?? 0);
|
||||
|
||||
return (await _striker.StrikeAndCheckLimit(torrent.Hash, torrent.Name, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled), DeleteReason.Stalled);
|
||||
return (await _striker.StrikeAndCheckLimit(torrent.Hash, torrent.Name, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled), DeleteReason.Stalled);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogTrace("downloading metadata | {name}", torrent.Name);
|
||||
|
||||
return (await _striker.StrikeAndCheckLimit(torrent.Hash, torrent.Name, _queueCleanerConfig.StalledMaxStrikes, StrikeType.DownloadingMetadata), DeleteReason.DownloadingMetadata);
|
||||
if (_queueCleanerConfig.DownloadingMetadataMaxStrikes > 0 && torrent.State is not TorrentState.StalledDownload)
|
||||
{
|
||||
return (await _striker.StrikeAndCheckLimit(torrent.Hash, torrent.Name, _queueCleanerConfig.DownloadingMetadataMaxStrikes, StrikeType.DownloadingMetadata), DeleteReason.DownloadingMetadata);
|
||||
}
|
||||
|
||||
return (false, DeleteReason.None);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<TorrentTracker>> GetTrackersAsync(string hash)
|
||||
|
||||
@@ -12,6 +12,7 @@ using Infrastructure.Extensions;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.Context;
|
||||
using Infrastructure.Verticals.Files;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -44,7 +45,7 @@ public class TransmissionService : DownloadService, ITransmissionService
|
||||
TorrentFields.UPLOAD_RATIO,
|
||||
TorrentFields.TRACKERS,
|
||||
TorrentFields.RATE_DOWNLOAD,
|
||||
TorrentFields.TOTAL_SIZE
|
||||
TorrentFields.TOTAL_SIZE,
|
||||
];
|
||||
|
||||
public TransmissionService(
|
||||
@@ -58,10 +59,11 @@ public class TransmissionService : DownloadService, ITransmissionService
|
||||
IFilenameEvaluator filenameEvaluator,
|
||||
IStriker striker,
|
||||
INotificationPublisher notifier,
|
||||
IDryRunInterceptor dryRunInterceptor
|
||||
IDryRunInterceptor dryRunInterceptor,
|
||||
IHardLinkFileService hardLinkFileService
|
||||
) : base(
|
||||
logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache,
|
||||
filenameEvaluator, striker, notifier, dryRunInterceptor
|
||||
filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService
|
||||
)
|
||||
{
|
||||
_config = config.Value;
|
||||
@@ -211,40 +213,59 @@ public class TransmissionService : DownloadService, ITransmissionService
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public override async Task<List<object>?> GetSeedingDownloads() =>
|
||||
(await _client.TorrentGetAsync(Fields))
|
||||
?.Torrents
|
||||
?.Where(x => !string.IsNullOrEmpty(x.HashString))
|
||||
.Where(x => x.Status is 5 or 6)
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<List<object>?> GetAllDownloadsToBeCleaned(List<Category> categories)
|
||||
public override List<object>? FilterDownloadsToBeCleanedAsync(List<object>? downloads, List<CleanCategory> categories)
|
||||
{
|
||||
return (await _client.TorrentGetAsync(Fields))
|
||||
?.Torrents
|
||||
?.Where(x => !string.IsNullOrEmpty(x.HashString))
|
||||
.Where(x => x.Status is 5 or 6)
|
||||
return downloads
|
||||
?
|
||||
.Cast<TorrentInfo>()
|
||||
.Where(x => categories
|
||||
.Any(cat =>
|
||||
{
|
||||
if (x.DownloadDir is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Path.GetFileName(Path.TrimEndingDirectorySeparator(x.DownloadDir))
|
||||
.Equals(cat.Name, StringComparison.InvariantCultureIgnoreCase);
|
||||
})
|
||||
.Any(cat => cat.Name.Equals(x.GetCategory(), StringComparison.InvariantCultureIgnoreCase))
|
||||
)
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task CleanDownloads(List<object> downloads, List<Category> categoriesToClean, HashSet<string> excludedHashes,
|
||||
IReadOnlyList<string> ignoredDownloads)
|
||||
public override List<object>? FilterDownloadsToChangeCategoryAsync(List<object>? downloads, List<string> categories)
|
||||
{
|
||||
return downloads
|
||||
?.Cast<TorrentInfo>()
|
||||
.Where(x => !string.IsNullOrEmpty(x.HashString))
|
||||
.Where(x => categories.Any(cat => cat.Equals(x.GetCategory(), StringComparison.InvariantCultureIgnoreCase)))
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task CleanDownloadsAsync(List<object>? downloads, List<CleanCategory> categoriesToClean,
|
||||
HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (TorrentInfo download in downloads)
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.HashString))
|
||||
{
|
||||
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 (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
@@ -252,7 +273,7 @@ public class TransmissionService : DownloadService, ITransmissionService
|
||||
continue;
|
||||
}
|
||||
|
||||
Category? category = categoriesToClean
|
||||
CleanCategory? category = categoriesToClean
|
||||
.FirstOrDefault(x =>
|
||||
{
|
||||
if (download.DownloadDir is null)
|
||||
@@ -269,12 +290,6 @@ public class TransmissionService : DownloadService, ITransmissionService
|
||||
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);
|
||||
@@ -306,6 +321,106 @@ public class TransmissionService : DownloadService, ITransmissionService
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task CreateCategoryAsync(string name)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override async Task ChangeCategoryForNoHardLinksAsync(List<object>? downloads, HashSet<string> excludedHashes, IReadOnlyList<string> ignoredDownloads)
|
||||
{
|
||||
if (downloads?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir))
|
||||
{
|
||||
_hardLinkFileService.PopulateFileCounts(_downloadCleanerConfig.UnlinkedIgnoredRootDir);
|
||||
}
|
||||
|
||||
foreach (TorrentInfo download in downloads.Cast<TorrentInfo>())
|
||||
{
|
||||
if (string.IsNullOrEmpty(download.HashString) || string.IsNullOrEmpty(download.Name) || download.DownloadDir == 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 (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ContextProvider.Set("downloadName", download.Name);
|
||||
ContextProvider.Set("hash", download.HashString);
|
||||
|
||||
bool hasHardlinks = false;
|
||||
|
||||
if (download.Files is null || download.FileStats is null)
|
||||
{
|
||||
_logger.LogDebug("skip | download has no files | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int i = 0; i < download.Files.Length; i++)
|
||||
{
|
||||
TransmissionTorrentFiles file = download.Files[i];
|
||||
TransmissionTorrentFileStats stats = download.FileStats[i];
|
||||
|
||||
if (stats.Wanted is null or false || string.IsNullOrEmpty(file.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.DownloadDir, file.Name).Split(['\\', '/']));
|
||||
|
||||
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir));
|
||||
|
||||
if (hardlinkCount < 0)
|
||||
{
|
||||
_logger.LogDebug("skip | could not get file properties | {file}", filePath);
|
||||
hasHardlinks = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (hardlinkCount > 0)
|
||||
{
|
||||
hasHardlinks = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasHardlinks)
|
||||
{
|
||||
_logger.LogDebug("skip | download has hardlinks | {name}", download.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
string currentCategory = download.GetCategory();
|
||||
string newLocation = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.DownloadDir, _downloadCleanerConfig.UnlinkedTargetCategory).Split(['\\', '/']));
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(ChangeDownloadLocation, download.Id, newLocation);
|
||||
|
||||
_logger.LogInformation("category changed for {name}", download.Name);
|
||||
|
||||
await _notifier.NotifyCategoryChanged(currentCategory, _downloadCleanerConfig.UnlinkedTargetCategory);
|
||||
|
||||
download.DownloadDir = newLocation;
|
||||
}
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
protected virtual async Task ChangeDownloadLocation(long downloadId, string newLocation)
|
||||
{
|
||||
await _client.TorrentSetLocationAsync([downloadId], newLocation, true);
|
||||
}
|
||||
|
||||
public override async Task DeleteDownload(string hash)
|
||||
{
|
||||
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
||||
|
||||
51
code/Infrastructure/Verticals/Files/HardLinkFileService.cs
Normal file
51
code/Infrastructure/Verticals/Files/HardLinkFileService.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Infrastructure.Verticals.Files;
|
||||
|
||||
public class HardLinkFileService : IHardLinkFileService
|
||||
{
|
||||
private readonly ILogger<HardLinkFileService> _logger;
|
||||
private readonly UnixHardLinkFileService _unixHardLinkFileService;
|
||||
private readonly WindowsHardLinkFileService _windowsHardLinkFileService;
|
||||
|
||||
public HardLinkFileService(
|
||||
ILogger<HardLinkFileService> logger,
|
||||
UnixHardLinkFileService unixHardLinkFileService,
|
||||
WindowsHardLinkFileService windowsHardLinkFileService
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_unixHardLinkFileService = unixHardLinkFileService;
|
||||
_windowsHardLinkFileService = windowsHardLinkFileService;
|
||||
}
|
||||
|
||||
public void PopulateFileCounts(string directoryPath)
|
||||
{
|
||||
_logger.LogTrace("populating file counts from {dir}", directoryPath);
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
_windowsHardLinkFileService.PopulateFileCounts(directoryPath);
|
||||
return;
|
||||
}
|
||||
|
||||
_unixHardLinkFileService.PopulateFileCounts(directoryPath);
|
||||
}
|
||||
|
||||
public long GetHardLinkCount(string filePath, bool ignoreRootDir)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
_logger.LogDebug("file {file} does not exist", filePath);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return _windowsHardLinkFileService.GetHardLinkCount(filePath, ignoreRootDir);
|
||||
}
|
||||
|
||||
return _unixHardLinkFileService.GetHardLinkCount(filePath, ignoreRootDir);
|
||||
}
|
||||
}
|
||||
19
code/Infrastructure/Verticals/Files/IHardLinkFileService.cs
Normal file
19
code/Infrastructure/Verticals/Files/IHardLinkFileService.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace Infrastructure.Verticals.Files;
|
||||
|
||||
public interface IHardLinkFileService
|
||||
{
|
||||
/// <summary>
|
||||
/// Populates the inode counts for Unix and the file index counts for Windows.
|
||||
/// Needs to be called before <see cref="GetHardLinkCount"/> to populate the inode counts.
|
||||
/// </summary>
|
||||
/// <param name="directoryPath">The root directory where to search for hardlinks.</param>
|
||||
void PopulateFileCounts(string directoryPath);
|
||||
|
||||
/// <summary>
|
||||
/// Get the hardlink count of a file.
|
||||
/// </summary>
|
||||
/// <param name="filePath">File path.</param>
|
||||
/// <param name="ignoreRootDir">Whether to ignore hardlinks found in the same root dir.</param>
|
||||
/// <returns>-1 on error, 0 if there are no hardlinks and 1 otherwise.</returns>
|
||||
long GetHardLinkCount(string filePath, bool ignoreRootDir);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Mono.Unix.Native;
|
||||
|
||||
namespace Infrastructure.Verticals.Files;
|
||||
|
||||
public class UnixHardLinkFileService : IHardLinkFileService, IDisposable
|
||||
{
|
||||
private readonly ILogger<UnixHardLinkFileService> _logger;
|
||||
private readonly ConcurrentDictionary<ulong, int> _inodeCounts = new();
|
||||
|
||||
public UnixHardLinkFileService(ILogger<UnixHardLinkFileService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public long GetHardLinkCount(string filePath, bool ignoreRootDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Syscall.stat(filePath, out Stat stat) != 0)
|
||||
{
|
||||
_logger.LogDebug("failed to stat file {file}", filePath);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!ignoreRootDir)
|
||||
{
|
||||
_logger.LogDebug("stat file | hardlinks: {nlink} | {file}", stat.st_nlink, filePath);
|
||||
return (long)stat.st_nlink == 1 ? 0 : 1;
|
||||
}
|
||||
|
||||
// get the number of hardlinks in the same root directory
|
||||
int linksInIgnoredDir = _inodeCounts.TryGetValue(stat.st_ino, out int count)
|
||||
? count
|
||||
: 1; // default to 1 if not found
|
||||
|
||||
_logger.LogDebug("stat file | hardlinks: {nlink} | ignored: {ignored} | {file}", stat.st_nlink, linksInIgnoredDir, filePath);
|
||||
return (long)stat.st_nlink - linksInIgnoredDir;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "failed to stat file {file}", filePath);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void PopulateFileCounts(string directoryPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
// traverse all files in the ignored path and subdirectories
|
||||
foreach (string file in Directory.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
AddInodeToCount(file);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "failed to populate inode counts from {dir}", directoryPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddInodeToCount(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Syscall.stat(path, out Stat stat) == 0)
|
||||
{
|
||||
_inodeCounts.AddOrUpdate(stat.st_ino, 1, (_, count) => count + 1);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "could not stat {path} during inode counting", path);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_inodeCounts.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace Infrastructure.Verticals.Files;
|
||||
|
||||
public class WindowsHardLinkFileService : IHardLinkFileService, IDisposable
|
||||
{
|
||||
private readonly ILogger<WindowsHardLinkFileService> _logger;
|
||||
private readonly ConcurrentDictionary<ulong, int> _fileIndexCounts = new();
|
||||
|
||||
public WindowsHardLinkFileService(ILogger<WindowsHardLinkFileService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public long GetHardLinkCount(string filePath, bool ignoreRootDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
using SafeFileHandle fileStream = File.OpenHandle(filePath);
|
||||
|
||||
if (!GetFileInformationByHandle(fileStream, out var file))
|
||||
{
|
||||
_logger.LogDebug("failed to get file handle {file}", filePath);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!ignoreRootDir)
|
||||
{
|
||||
_logger.LogDebug("stat file | hardlinks: {nlink} | {file}", file.NumberOfLinks, filePath);
|
||||
return file.NumberOfLinks == 1 ? 0 : 1;
|
||||
}
|
||||
|
||||
// Get unique file ID (combination of high and low indices)
|
||||
ulong fileIndex = ((ulong)file.FileIndexHigh << 32) | file.FileIndexLow;
|
||||
|
||||
// get the number of hardlinks in the same root directory
|
||||
int linksInIgnoredDir = _fileIndexCounts.TryGetValue(fileIndex, out int count)
|
||||
? count
|
||||
: 1; // default to 1 if not found
|
||||
|
||||
_logger.LogDebug("stat file | hardlinks: {links} | ignored: {ignored} | {file}", file.NumberOfLinks, linksInIgnoredDir, filePath);
|
||||
return file.NumberOfLinks - linksInIgnoredDir;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "failed to stat file {file}", filePath);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void PopulateFileCounts(string directoryPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
// traverse all files in the ignored path and subdirectories
|
||||
foreach (string file in Directory.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
AddFileIndexToCount(file);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to populate file index counts from {dir}", directoryPath);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddFileIndexToCount(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using SafeFileHandle fileStream = File.OpenHandle(path);
|
||||
if (GetFileInformationByHandle(fileStream, out var file))
|
||||
{
|
||||
ulong fileIndex = ((ulong)file.FileIndexHigh << 32) | file.FileIndexLow;
|
||||
_fileIndexCounts.AddOrUpdate(fileIndex, 1, (_, count) => count + 1);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Couldn't stat {path} during file index counting", path);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool GetFileInformationByHandle(
|
||||
SafeFileHandle hFile,
|
||||
out BY_HANDLE_FILE_INFORMATION lpFileInformation
|
||||
);
|
||||
|
||||
private struct BY_HANDLE_FILE_INFORMATION
|
||||
{
|
||||
public uint FileAttributes;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
|
||||
public uint VolumeSerialNumber;
|
||||
public uint FileSizeHigh;
|
||||
public uint FileSizeLow;
|
||||
public uint NumberOfLinks;
|
||||
public uint FileIndexHigh;
|
||||
public uint FileIndexLow;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_fileIndexCounts.Clear();
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,12 @@ public sealed class Striker : IStriker
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly MemoryCacheEntryOptions _cacheOptions;
|
||||
private readonly INotificationPublisher _notifier;
|
||||
private readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
|
||||
public Striker(ILogger<Striker> logger, IMemoryCache cache, INotificationPublisher notifier, IDryRunInterceptor dryRunInterceptor)
|
||||
public Striker(ILogger<Striker> logger, IMemoryCache cache, INotificationPublisher notifier)
|
||||
{
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_notifier = notifier;
|
||||
_dryRunInterceptor = dryRunInterceptor;
|
||||
_cacheOptions = new MemoryCacheEntryOptions()
|
||||
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,11 @@ public sealed class AppriseProvider : NotificationProvider
|
||||
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), _config);
|
||||
}
|
||||
|
||||
public override async Task OnCategoryChanged(CategoryChangedNotification notification)
|
||||
{
|
||||
await _proxy.SendNotification(BuildPayload(notification, NotificationType.Warning), _config);
|
||||
}
|
||||
|
||||
private static ApprisePayload BuildPayload(ArrNotification notification, NotificationType notificationType)
|
||||
{
|
||||
StringBuilder body = new();
|
||||
|
||||
@@ -36,6 +36,9 @@ public sealed class NotificationConsumer<T> : IConsumer<T> where T : Notificatio
|
||||
case DownloadCleanedNotification downloadCleanedNotification:
|
||||
await _notificationService.Notify(downloadCleanedNotification);
|
||||
break;
|
||||
case CategoryChangedNotification categoryChangedNotification:
|
||||
await _notificationService.Notify(categoryChangedNotification);
|
||||
break;
|
||||
default:
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
@@ -11,4 +11,6 @@ public interface INotificationFactory
|
||||
List<INotificationProvider> OnQueueItemDeletedEnabled();
|
||||
|
||||
List<INotificationProvider> OnDownloadCleanedEnabled();
|
||||
|
||||
List<INotificationProvider> OnCategoryChangedEnabled();
|
||||
}
|
||||
@@ -18,4 +18,6 @@ public interface INotificationProvider
|
||||
Task OnQueueItemDeleted(QueueItemDeletedNotification notification);
|
||||
|
||||
Task OnDownloadCleaned(DownloadCleanedNotification notification);
|
||||
|
||||
Task OnCategoryChanged(CategoryChangedNotification notification);
|
||||
}
|
||||
@@ -9,4 +9,6 @@ public interface INotificationPublisher
|
||||
Task NotifyQueueItemDeleted(bool removeFromClient, DeleteReason reason);
|
||||
|
||||
Task NotifyDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason);
|
||||
|
||||
Task NotifyCategoryChanged(string oldCategory, string newCategory);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Infrastructure.Verticals.Notifications.Models;
|
||||
|
||||
public sealed record CategoryChangedNotification : Notification
|
||||
{
|
||||
}
|
||||
@@ -46,6 +46,11 @@ public class NotifiarrProvider : NotificationProvider
|
||||
{
|
||||
await _proxy.SendNotification(BuildPayload(notification), _config);
|
||||
}
|
||||
|
||||
public override async Task OnCategoryChanged(CategoryChangedNotification notification)
|
||||
{
|
||||
await _proxy.SendNotification(BuildPayload(notification), _config);
|
||||
}
|
||||
|
||||
private NotifiarrPayload BuildPayload(ArrNotification notification, string color)
|
||||
{
|
||||
@@ -110,4 +115,32 @@ public class NotifiarrProvider : NotificationProvider
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private NotifiarrPayload BuildPayload(CategoryChangedNotification notification)
|
||||
{
|
||||
NotifiarrPayload payload = new()
|
||||
{
|
||||
Discord = new()
|
||||
{
|
||||
Color = WarningColor,
|
||||
Text = new()
|
||||
{
|
||||
Title = notification.Title,
|
||||
Icon = Logo,
|
||||
Description = notification.Description,
|
||||
Fields = notification.Fields?.Adapt<List<Field>>() ?? []
|
||||
},
|
||||
Ids = new Ids
|
||||
{
|
||||
Channel = _config.ChannelId
|
||||
},
|
||||
Images = new()
|
||||
{
|
||||
Thumbnail = new Uri(Logo)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text;
|
||||
using Common.Helpers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
|
||||
@@ -7,12 +8,14 @@ namespace Infrastructure.Verticals.Notifications.Notifiarr;
|
||||
|
||||
public sealed class NotifiarrProxy : INotifiarrProxy
|
||||
{
|
||||
private readonly ILogger<NotifiarrProxy> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
private const string Url = "https://notifiarr.com/api/v1/notification/passthrough/";
|
||||
|
||||
public NotifiarrProxy(IHttpClientFactory httpClientFactory)
|
||||
public NotifiarrProxy(ILogger<NotifiarrProxy> logger, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
|
||||
}
|
||||
|
||||
@@ -25,6 +28,8 @@ public sealed class NotifiarrProxy : INotifiarrProxy
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver()
|
||||
});
|
||||
|
||||
_logger.LogTrace("sending notification to Notifiarr: {content}", content);
|
||||
|
||||
using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, $"{Url}{config.ApiKey}");
|
||||
request.Method = HttpMethod.Post;
|
||||
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
|
||||
|
||||
@@ -39,4 +39,9 @@ public class NotificationFactory : INotificationFactory
|
||||
ActiveProviders()
|
||||
.Where(n => n.Config.OnDownloadCleaned)
|
||||
.ToList();
|
||||
|
||||
public List<INotificationProvider> OnCategoryChangedEnabled() =>
|
||||
ActiveProviders()
|
||||
.Where(n => n.Config.OnCategoryChanged)
|
||||
.ToList();
|
||||
}
|
||||
@@ -24,4 +24,6 @@ public abstract class NotificationProvider : INotificationProvider
|
||||
public abstract Task OnQueueItemDeleted(QueueItemDeletedNotification notification);
|
||||
|
||||
public abstract Task OnDownloadCleaned(DownloadCleanedNotification notification);
|
||||
|
||||
public abstract Task OnCategoryChanged(CategoryChangedNotification notification);
|
||||
}
|
||||
@@ -49,14 +49,14 @@ public class NotificationPublisher : INotificationPublisher
|
||||
{
|
||||
case StrikeType.Stalled:
|
||||
case StrikeType.DownloadingMetadata:
|
||||
await _dryRunInterceptor.InterceptAsync(Notify<StalledStrikeNotification>, notification.Adapt<StalledStrikeNotification>());
|
||||
await NotifyInternal(notification.Adapt<StalledStrikeNotification>());
|
||||
break;
|
||||
case StrikeType.ImportFailed:
|
||||
await _dryRunInterceptor.InterceptAsync(Notify<FailedImportStrikeNotification>, notification.Adapt<FailedImportStrikeNotification>());
|
||||
await NotifyInternal(notification.Adapt<FailedImportStrikeNotification>());
|
||||
break;
|
||||
case StrikeType.SlowSpeed:
|
||||
case StrikeType.SlowTime:
|
||||
await _dryRunInterceptor.InterceptAsync(Notify<SlowStrikeNotification>, notification.Adapt<SlowStrikeNotification>());
|
||||
await NotifyInternal(notification.Adapt<SlowStrikeNotification>());
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ public class NotificationPublisher : INotificationPublisher
|
||||
Fields = [new() { Title = "Removed from download client?", Text = removeFromClient ? "Yes" : "No" }]
|
||||
};
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(Notify<QueueItemDeletedNotification>, notification);
|
||||
await NotifyInternal(notification);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -115,13 +115,36 @@ public class NotificationPublisher : INotificationPublisher
|
||||
Level = NotificationLevel.Important
|
||||
};
|
||||
|
||||
await _dryRunInterceptor.InterceptAsync(Notify<DownloadCleanedNotification>, notification);
|
||||
await NotifyInternal(notification);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "failed to notify download cleaned");
|
||||
}
|
||||
}
|
||||
|
||||
public virtual async Task NotifyCategoryChanged(string oldCategory, string newCategory)
|
||||
{
|
||||
CategoryChangedNotification notification = new()
|
||||
{
|
||||
Title = "Category changed",
|
||||
Description = ContextProvider.Get<string>("downloadName"),
|
||||
Fields =
|
||||
[
|
||||
new() { Title = "Hash", Text = ContextProvider.Get<string>("hash").ToLowerInvariant() },
|
||||
new() { Title = "Old category", Text = oldCategory },
|
||||
new() { Title = "New category", Text = newCategory }
|
||||
],
|
||||
Level = NotificationLevel.Important
|
||||
};
|
||||
|
||||
await NotifyInternal(notification);
|
||||
}
|
||||
|
||||
private Task NotifyInternal<T>(T message) where T: notnull
|
||||
{
|
||||
return _dryRunInterceptor.InterceptAsync(Notify<T>, message);
|
||||
}
|
||||
|
||||
[DryRunSafeguard]
|
||||
private Task Notify<T>(T message) where T: notnull
|
||||
|
||||
@@ -88,4 +88,19 @@ public class NotificationService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Notify(CategoryChangedNotification notification)
|
||||
{
|
||||
foreach (INotificationProvider provider in _notificationFactory.OnCategoryChangedEnabled())
|
||||
{
|
||||
try
|
||||
{
|
||||
await provider.OnCategoryChanged(notification);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "failed to send notification | provider {provider}", provider.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,11 +124,11 @@ public sealed class QueueCleaner : GenericHandler
|
||||
if (downloadCheckResult.IsPrivate)
|
||||
{
|
||||
bool isStalledWithoutPruneFlag =
|
||||
downloadCheckResult.DeleteReason is DeleteReason.Stalled &&
|
||||
downloadCheckResult.DeleteReason is DeleteReason.Stalled &&
|
||||
!_config.StalledDeletePrivate;
|
||||
|
||||
bool isSlowWithoutPruneFlag =
|
||||
downloadCheckResult.DeleteReason is DeleteReason.SlowSpeed or DeleteReason.SlowTime &&
|
||||
downloadCheckResult.DeleteReason is DeleteReason.SlowSpeed or DeleteReason.SlowTime &&
|
||||
!_config.SlowDeletePrivate;
|
||||
|
||||
bool shouldKeepDueToDeleteRules = downloadCheckResult.ShouldRemove && (isStalledWithoutPruneFlag || isSlowWithoutPruneFlag);
|
||||
|
||||
@@ -194,15 +194,16 @@ services:
|
||||
- QUEUECLEANER__IGNORED_DOWNLOADS_PATH=/ignored
|
||||
- QUEUECLEANER__RUNSEQUENTIALLY=true
|
||||
|
||||
- QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES=5
|
||||
- QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES=3
|
||||
- QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE=true
|
||||
- QUEUECLEANER__IMPORT_FAILED_DELETE_PRIVATE=false
|
||||
- QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__0=file is a sample
|
||||
|
||||
- QUEUECLEANER__STALLED_MAX_STRIKES=5
|
||||
- QUEUECLEANER__STALLED_MAX_STRIKES=3
|
||||
- QUEUECLEANER__STALLED_RESET_STRIKES_ON_PROGRESS=true
|
||||
- QUEUECLEANER__STALLED_IGNORE_PRIVATE=true
|
||||
- QUEUECLEANER__STALLED_DELETE_PRIVATE=false
|
||||
- QUEUECLEANER__DOWNLOADING_METADATA_MAX_STRIKES=3
|
||||
|
||||
- QUEUECLEANER__SLOW_MAX_STRIKES=5
|
||||
- QUEUECLEANER__SLOW_RESET_STRIKES_ON_PROGRESS=true
|
||||
@@ -223,11 +224,15 @@ services:
|
||||
- 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=0.01
|
||||
- DOWNLOADCLEANER__CATEGORIES__1__NAME=radarr
|
||||
- DOWNLOADCLEANER__CATEGORIES__0__MAX_SEED_TIME=99999
|
||||
- DOWNLOADCLEANER__CATEGORIES__1__NAME=nohardlink
|
||||
- DOWNLOADCLEANER__CATEGORIES__1__MAX_RATIO=-1
|
||||
- DOWNLOADCLEANER__CATEGORIES__1__MIN_SEED_TIME=0
|
||||
- DOWNLOADCLEANER__CATEGORIES__1__MAX_SEED_TIME=0.01
|
||||
- DOWNLOADCLEANER__CATEGORIES__1__MAX_SEED_TIME=99999
|
||||
- DOWNLOADCLEANER__UNLINKED_TARGET_CATEGORY=cleanuperr-unlinked
|
||||
- DOWNLOADCLEANER__UNLINKED_IGNORED_ROOT_DIR=/downloads
|
||||
- DOWNLOADCLEANER__UNLINKED_CATEGORIES__0=tv-sonarr
|
||||
- DOWNLOADCLEANER__UNLINKED_CATEGORIES__1=radarr
|
||||
|
||||
- DOWNLOAD_CLIENT=qbittorrent
|
||||
- QBITTORRENT__URL=http://qbittorrent:8080
|
||||
@@ -267,6 +272,7 @@ services:
|
||||
# - NOTIFIARR__ON_SLOW_STRIKE=true
|
||||
# - NOTIFIARR__ON_QUEUE_ITEM_DELETED=true
|
||||
# - NOTIFIARR__ON_DOWNLOAD_CLEANED=true
|
||||
# - NOTIFIARR__ON_CATEGORY_CHANGED=true
|
||||
# - NOTIFIARR__API_KEY=notifiarr_secret
|
||||
# - NOTIFIARR__CHANNEL_ID=discord_channel_id
|
||||
|
||||
@@ -280,6 +286,7 @@ services:
|
||||
volumes:
|
||||
- ./data/cleanuperr/logs:/var/logs
|
||||
- ./data/cleanuperr/ignored_downloads:/ignored
|
||||
- ./data/qbittorrent/downloads:/downloads
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- qbittorrent
|
||||
|
||||
20
docs/.gitignore
vendored
Normal file
20
docs/.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
41
docs/README.md
Normal file
41
docs/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Website
|
||||
|
||||
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
|
||||
|
||||
### Installation
|
||||
|
||||
```
|
||||
$ yarn
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
```
|
||||
$ yarn start
|
||||
```
|
||||
|
||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||
|
||||
### Build
|
||||
|
||||
```
|
||||
$ yarn build
|
||||
```
|
||||
|
||||
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||
|
||||
### Deployment
|
||||
|
||||
Using SSH:
|
||||
|
||||
```
|
||||
$ USE_SSH=true yarn deploy
|
||||
```
|
||||
|
||||
Not using SSH:
|
||||
|
||||
```
|
||||
$ GIT_USER=<Your GitHub username> yarn deploy
|
||||
```
|
||||
|
||||
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
||||
24
docs/docs/1_cleanuperr.mdx
Normal file
24
docs/docs/1_cleanuperr.mdx
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
import { Warning } from '@site/src/components/Admonition';
|
||||
|
||||
# Cleanuperr
|
||||
|
||||
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/).
|
||||
|
||||
<Warning>
|
||||
Because this tool is actively developed and still a work in progress, 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
|
||||
</Warning>
|
||||
|
||||
|
||||
## 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.
|
||||
13
docs/docs/2_supported-apps.mdx
Normal file
13
docs/docs/2_supported-apps.mdx
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
# Supported apps
|
||||
|
||||
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
|
||||
36
docs/docs/4_how_it_works.mdx
Normal file
36
docs/docs/4_how_it_works.mdx
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
# How it works
|
||||
|
||||
This is a detailed explanation of how the recurring cleanup jobs work.
|
||||
|
||||
#### 1. **Content blocker** will:
|
||||
- Run every 5 minutes (or configured cron).
|
||||
- Process all items in the *arr queue.
|
||||
- Find the corresponding item from the download client for each queue item.
|
||||
- Mark the files that were found in the queue as **unwanted/skipped** if:
|
||||
- They **are listed in the blacklist**, or
|
||||
- They **are not included in the whitelist**.
|
||||
- If **all files** of a download **are unwanted**:
|
||||
- It will be removed from the *arr's queue and blocked.
|
||||
- It will be deleted from the download client.
|
||||
- A new search will be triggered for the *arr item.
|
||||
#### 2. **Queue cleaner** will:
|
||||
- Run every 5 minutes (or configured cron, or right after `Content Blocker`).
|
||||
- Process all items in the *arr queue.
|
||||
- Check each queue item if it is **stalled (download speed is 0)**, **stuck in metadata downloading**, **failed to be imported** or **slow**.
|
||||
- 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:
|
||||
- **Marked as completed, but 0 bytes have been downloaded** (due to files being blocked by qBittorrent or the **content blocker**).
|
||||
- All associated files are marked as **unwanted/skipped/do not download**.
|
||||
- If the item **DOES NOT** match the above criteria, it will be skipped.
|
||||
- If the item **DOES** match the criteria or has received the **maximum number of strikes**:
|
||||
- It will be removed from the *arr's queue and blocked.
|
||||
- It will be deleted from the download client.
|
||||
- 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.
|
||||
- Automatically changes the category of downloads that have no hardlinks.
|
||||
11
docs/docs/configuration/1_general.mdx
Normal file
11
docs/docs/configuration/1_general.mdx
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
import GeneralSettings from '@site/src/components/configuration/GeneralSettings';
|
||||
|
||||
# General Settings
|
||||
|
||||
These are the general configuration settings that apply to the entire application.
|
||||
|
||||
<GeneralSettings/>
|
||||
8
docs/docs/configuration/_category_.json
Normal file
8
docs/docs/configuration/_category_.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Configuration",
|
||||
"position": 6,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "This page provides documentation for all the environment variables and settings used in the application."
|
||||
}
|
||||
}
|
||||
18
docs/docs/configuration/arrs/1_sonarr.mdx
Normal file
18
docs/docs/configuration/arrs/1_sonarr.mdx
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
import SonarrSettings from '@site/src/components/configuration/arrs/SonarrSettings';
|
||||
import { Note } from '@site/src/components/Admonition';
|
||||
|
||||
# Sonarr Settings
|
||||
|
||||
<Note>
|
||||
Multiple instances can be specified for each *arr using this format, where `<NUMBER>` starts from 0:
|
||||
```yaml
|
||||
<ARR>__INSTANCES__<NUMBER>__URL
|
||||
<ARR>__INSTANCES__<NUMBER>__APIKEY
|
||||
```
|
||||
</Note>
|
||||
|
||||
<SonarrSettings/>
|
||||
18
docs/docs/configuration/arrs/2_radarr.mdx
Normal file
18
docs/docs/configuration/arrs/2_radarr.mdx
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
import RadarrSettings from '@site/src/components/configuration/arrs/RadarrSettings';
|
||||
import { Note } from '@site/src/components/Admonition';
|
||||
|
||||
# Radarr Settings
|
||||
|
||||
<Note>
|
||||
Multiple instances can be specified for each *arr using this format, where `<NUMBER>` starts from 0:
|
||||
```yaml
|
||||
<ARR>__INSTANCES__<NUMBER>__URL
|
||||
<ARR>__INSTANCES__<NUMBER>__APIKEY
|
||||
```
|
||||
</Note>
|
||||
|
||||
<RadarrSettings/>
|
||||
18
docs/docs/configuration/arrs/3_lidarr.mdx
Normal file
18
docs/docs/configuration/arrs/3_lidarr.mdx
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
import LidarrSettings from '@site/src/components/configuration/arrs/LidarrSettings';
|
||||
import { Note } from '@site/src/components/Admonition';
|
||||
|
||||
# Lidarr Settings
|
||||
|
||||
<Note>
|
||||
Multiple instances can be specified for each *arr using this format, where `<NUMBER>` starts from 0:
|
||||
```yaml
|
||||
<ARR>__INSTANCES__<NUMBER>__URL
|
||||
<ARR>__INSTANCES__<NUMBER>__APIKEY
|
||||
```
|
||||
</Note>
|
||||
|
||||
<LidarrSettings/>
|
||||
8
docs/docs/configuration/arrs/_category_.json
Normal file
8
docs/docs/configuration/arrs/_category_.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Arrs settings",
|
||||
"position": 6,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Servarr settings."
|
||||
}
|
||||
}
|
||||
19
docs/docs/configuration/content-blocker/1_general.mdx
Normal file
19
docs/docs/configuration/content-blocker/1_general.mdx
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
import ContentBlockerGeneralSettings from '@site/src/components/configuration/content-blocker/ContentBlockerGeneralSettings';
|
||||
|
||||
# General Settings
|
||||
|
||||
These settings control the general behavior of the Content Blocker functionality.
|
||||
|
||||
These environment variables are needed to enable the Content Blocker functionality:
|
||||
- [SONARR__BLOCK__TYPE](/docs/configuration/arrs/sonarr?SONARR__BLOCK__TYPE) (if Sonarr is enabled)
|
||||
- [SONARR__BLOCK__PATH](/docs/configuration/arrs/sonarr?SONARR__BLOCK__PATH) (if Sonarr is enabled)
|
||||
- [RADARR__BLOCK__TYPE](/docs/configuration/arrs/radarr?RADARR__BLOCK__TYPE) (if Radarr is enabled)
|
||||
- [RADARR__BLOCK__PATH](/docs/configuration/arrs/radarr?RADARR__BLOCK__PATH) (if Radarr is enabled)
|
||||
- [LIDARR__BLOCK__TYPE](/docs/configuration/arrs/lidarr?LIDARR__BLOCK__TYPE) (if Lidarr is enabled)
|
||||
- [LIDARR__BLOCK__PATH](/docs/configuration/arrs/lidarr?LIDARR__BLOCK__PATH) (if Lidarr is enabled)
|
||||
|
||||
<ContentBlockerGeneralSettings/>
|
||||
8
docs/docs/configuration/content-blocker/_category_.json
Normal file
8
docs/docs/configuration/content-blocker/_category_.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Content Blocker",
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Settings for the Content Blocker functionality."
|
||||
}
|
||||
}
|
||||
11
docs/docs/configuration/download-cleaner/1_general.mdx
Normal file
11
docs/docs/configuration/download-cleaner/1_general.mdx
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
import DownloadCleanerGeneralSettings from '@site/src/components/configuration/download-cleaner/DownloadCleanerGeneralSettings';
|
||||
|
||||
# General Settings
|
||||
|
||||
These settings control the basic functionality of the Download Cleaner.
|
||||
|
||||
<DownloadCleanerGeneralSettings/>
|
||||
26
docs/docs/configuration/download-cleaner/2_categories.mdx
Normal file
26
docs/docs/configuration/download-cleaner/2_categories.mdx
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
import DownloadCleanerCleanupSettings from '@site/src/components/configuration/download-cleaner/DownloadCleanerCleanupSettings';
|
||||
import { Note } from '@site/src/components/Admonition';
|
||||
|
||||
# Cleanup Settings
|
||||
|
||||
These settings control how the Download Cleaner handles different categories of downloads that need to be removed.
|
||||
|
||||
<Note>
|
||||
A download is cleaned when both `MAX_RATIO` and `MIN_SEED_TIME` or just `MAX_SEED_TIME` is reached.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
Multiple categories can be specified using this format, where `<NUMBER>` starts from `0`:
|
||||
```yaml
|
||||
DOWNLOADCLEANER__CATEGORIES__<NUMBER>__NAME
|
||||
DOWNLOADCLEANER__CATEGORIES__<NUMBER>__MAX_RATIO
|
||||
DOWNLOADCLEANER__CATEGORIES__<NUMBER>__MIN_SEED_TIME
|
||||
DOWNLOADCLEANER__CATEGORIES__<NUMBER>__MAX_SEED_TIME
|
||||
```
|
||||
</Note>
|
||||
|
||||
<DownloadCleanerCleanupSettings/>
|
||||
19
docs/docs/configuration/download-cleaner/3_hardlinks.mdx
Normal file
19
docs/docs/configuration/download-cleaner/3_hardlinks.mdx
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
import DownloadCleanerHardlinksSettings from '@site/src/components/configuration/download-cleaner/DownloadCleanerHardlinksSettings';
|
||||
import { Important } from '@site/src/components/Admonition';
|
||||
|
||||
# Hardlinks Settings
|
||||
|
||||
These settings control how the Download Cleaner handles downloads with no hardlinks remaining (they are not available in the arrs anymore).
|
||||
|
||||
The Download Cleaner will change the category of a download that has no hardlinks and the new category can be cleaned based on the rules configured [here](/docs/configuration/download-cleaner/categories).
|
||||
|
||||
<Important>
|
||||
If you are using Docker, make sure to mount the downloads directory the same way it is mounted for the download client.
|
||||
If your download client's download directory is `/downloads`, it should be the same for Cleanuperr.
|
||||
</Important>
|
||||
|
||||
<DownloadCleanerHardlinksSettings/>
|
||||
8
docs/docs/configuration/download-cleaner/_category_.json
Normal file
8
docs/docs/configuration/download-cleaner/_category_.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Download Cleaner",
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Configure the Download Cleaner to automatically clean up downloads that have been seeding for a certain amount of time."
|
||||
}
|
||||
}
|
||||
11
docs/docs/configuration/download-client/1_general.mdx
Normal file
11
docs/docs/configuration/download-client/1_general.mdx
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
import DownloadClientSettings from '@site/src/components/configuration/download-client/DownloadClientSettings';
|
||||
|
||||
# Download Client Settings
|
||||
|
||||
These settings control how Cleanuperr interacts with your download client.
|
||||
|
||||
<DownloadClientSettings/>
|
||||
8
docs/docs/configuration/download-client/_category_.json
Normal file
8
docs/docs/configuration/download-client/_category_.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Download Client",
|
||||
"position": 4,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Configure the download client settings for Cleanuperr."
|
||||
}
|
||||
}
|
||||
157
docs/docs/configuration/examples/1_docker.mdx
Normal file
157
docs/docs/configuration/examples/1_docker.mdx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Note } from '@site/src/components/Admonition';
|
||||
|
||||
# Docker compose
|
||||
|
||||
<Note>
|
||||
**This example contains all settings and should be modified to fit your needs.**
|
||||
</Note>
|
||||
|
||||
```
|
||||
services:
|
||||
cleanuperr:
|
||||
image: ghcr.io/flmorg/cleanuperr:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./cleanuperr/logs:/var/logs
|
||||
- ./cleanuperr/ignored.txt:/ignored.txt
|
||||
environment:
|
||||
# general settings
|
||||
- TZ=America/New_York
|
||||
- DRY_RUN=false
|
||||
- HTTP_MAX_RETRIES=0
|
||||
- HTTP_TIMEOUT=100
|
||||
|
||||
# logging
|
||||
- LOGGING__LOGLEVEL=Information
|
||||
- LOGGING__FILE__ENABLED=false
|
||||
- LOGGING__FILE__PATH=/var/logs/
|
||||
- LOGGING__ENHANCED=true
|
||||
|
||||
# job triggers
|
||||
- TRIGGERS__QUEUECLEANER=0 0/5 * * * ?
|
||||
- TRIGGERS__CONTENTBLOCKER=0 0/5 * * * ?
|
||||
- TRIGGERS__DOWNLOADCLEANER=0 0 * * * ?
|
||||
|
||||
# queue cleaner
|
||||
- QUEUECLEANER__ENABLED=true
|
||||
- QUEUECLEANER__IGNORED_DOWNLOADS_PATH=/ignored.txt
|
||||
- QUEUECLEANER__RUNSEQUENTIALLY=true
|
||||
|
||||
# failed imports
|
||||
- QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES=5
|
||||
- QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE=false
|
||||
- QUEUECLEANER__IMPORT_FAILED_DELETE_PRIVATE=false
|
||||
- QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__0=title mismatch
|
||||
- QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1=manual import required
|
||||
|
||||
# stalled downloads
|
||||
- QUEUECLEANER__STALLED_MAX_STRIKES=5
|
||||
- QUEUECLEANER__STALLED_RESET_STRIKES_ON_PROGRESS=false
|
||||
- QUEUECLEANER__STALLED_IGNORE_PRIVATE=false
|
||||
- QUEUECLEANER__STALLED_DELETE_PRIVATE=false
|
||||
|
||||
# slow downloads
|
||||
- QUEUECLEANER__SLOW_MAX_STRIKES=5
|
||||
- QUEUECLEANER__SLOW_RESET_STRIKES_ON_PROGRESS=true
|
||||
- QUEUECLEANER__SLOW_IGNORE_PRIVATE=false
|
||||
- QUEUECLEANER__SLOW_DELETE_PRIVATE=false
|
||||
- QUEUECLEANER__SLOW_MIN_SPEED=1MB
|
||||
- QUEUECLEANER__SLOW_MAX_TIME=20
|
||||
- QUEUECLEANER__SLOW_IGNORE_ABOVE_SIZE=60GB
|
||||
|
||||
# content blocker
|
||||
- CONTENTBLOCKER__ENABLED=true
|
||||
- CONTENTBLOCKER__IGNORED_DOWNLOADS_PATH=/ignored.txt
|
||||
- CONTENTBLOCKER__IGNORE_PRIVATE=false
|
||||
- CONTENTBLOCKER__DELETE_PRIVATE=false
|
||||
|
||||
# download cleaner
|
||||
- DOWNLOADCLEANER__ENABLED=true
|
||||
- DOWNLOADCLEANER__IGNORED_DOWNLOADS_PATH=/ignored.txt
|
||||
- DOWNLOADCLEANER__DELETE_PRIVATE=false
|
||||
|
||||
# categories to seed until max ratio or min seed time has been reached
|
||||
- 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
|
||||
# remove downloads with no hardlinks
|
||||
- DOWNLOADCLEANER__CATEGORIES__2__NAME=cleanuperr-unlinked
|
||||
- DOWNLOADCLEANER__CATEGORIES__2__MAX_RATIO=-1
|
||||
- DOWNLOADCLEANER__CATEGORIES__2__MIN_SEED_TIME=0
|
||||
- DOWNLOADCLEANER__CATEGORIES__2__MAX_SEED_TIME=0
|
||||
|
||||
# change category for downloads with no hardlinks
|
||||
- DOWNLOADCLEANER__UNLINKED_TARGET_CATEGORY=cleanuperr-unlinked
|
||||
- DOWNLOADCLEANER__UNLINKED_IGNORED_ROOT_DIR=/downloads
|
||||
- DOWNLOADCLEANER__UNLINKED_CATEGORIES__0=tv-sonarr
|
||||
- DOWNLOADCLEANER__UNLINKED_CATEGORIES__1=radarr
|
||||
|
||||
- DOWNLOAD_CLIENT=none
|
||||
# OR
|
||||
# - DOWNLOAD_CLIENT=disabled
|
||||
# OR
|
||||
# - DOWNLOAD_CLIENT=qBittorrent
|
||||
# - QBITTORRENT__URL=http://localhost:8080
|
||||
# - QBITTORRENT__URL_BASE=myCustomPath
|
||||
# - QBITTORRENT__USERNAME=user
|
||||
# - QBITTORRENT__PASSWORD=pass
|
||||
# OR
|
||||
# - DOWNLOAD_CLIENT=deluge
|
||||
# - DELUGE__URL_BASE=myCustomPath
|
||||
# - DELUGE__URL=http://localhost:8112
|
||||
# - DELUGE__PASSWORD=testing
|
||||
# OR
|
||||
# - DOWNLOAD_CLIENT=transmission
|
||||
# - TRANSMISSION__URL=http://localhost:9091
|
||||
# - TRANSMISSION__URL_BASE=myCustomPath
|
||||
# - TRANSMISSION__USERNAME=test
|
||||
# - TRANSMISSION__PASSWORD=testing
|
||||
|
||||
- SONARR__ENABLED=true
|
||||
- SONARR__SEARCHTYPE=Episode
|
||||
- SONARR__BLOCK__TYPE=blacklist
|
||||
- SONARR__BLOCK__PATH=https://example.com/path/to/file.txt
|
||||
- SONARR__INSTANCES__0__URL=http://localhost:8989
|
||||
- SONARR__INSTANCES__0__APIKEY=secret1
|
||||
- SONARR__INSTANCES__1__URL=http://localhost:8990
|
||||
- SONARR__INSTANCES__1__APIKEY=secret2
|
||||
|
||||
- RADARR__ENABLED=true
|
||||
- RADARR__BLOCK__TYPE=blacklist
|
||||
- RADARR__BLOCK__PATH=https://example.com/path/to/file.txt
|
||||
- RADARR__INSTANCES__0__URL=http://localhost:7878
|
||||
- RADARR__INSTANCES__0__APIKEY=secret3
|
||||
- RADARR__INSTANCES__1__URL=http://localhost:7879
|
||||
- RADARR__INSTANCES__1__APIKEY=secret4
|
||||
|
||||
- LIDARR__ENABLED=true
|
||||
- LIDARR__BLOCK__TYPE=blacklist
|
||||
- LIDARR__BLOCK__PATH=https://example.com/path/to/file.txt
|
||||
- LIDARR__INSTANCES__0__URL=http://radarr:8686
|
||||
- LIDARR__INSTANCES__0__APIKEY=secret5
|
||||
- LIDARR__INSTANCES__1__URL=http://radarr:8687
|
||||
- LIDARR__INSTANCES__1__APIKEY=secret6
|
||||
|
||||
- NOTIFIARR__ON_IMPORT_FAILED_STRIKE=true
|
||||
- NOTIFIARR__ON_STALLED_STRIKE=true
|
||||
- NOTIFIARR__ON_SLOW_STRIKE=true
|
||||
- NOTIFIARR__ON_QUEUE_ITEM_DELETED=true
|
||||
- NOTIFIARR__ON_DOWNLOAD_CLEANED=true
|
||||
- NOTIFIARR__ON_CATEGORY_CHANGED=true
|
||||
- NOTIFIARR__API_KEY=notifiarr_secret
|
||||
- NOTIFIARR__CHANNEL_ID=discord_channel_id
|
||||
|
||||
- APPRISE__ON_IMPORT_FAILED_STRIKE=true
|
||||
- APPRISE__ON_STALLED_STRIKE=true
|
||||
- APPRISE__ON_SLOW_STRIKE=true
|
||||
- APPRISE__ON_QUEUE_ITEM_DELETED=true
|
||||
- APPRISE__ON_DOWNLOAD_CLEANED=true
|
||||
- NOTIFIARR__ON_CATEGORY_CHANGED=true
|
||||
- APPRISE__URL=http://apprise:8000
|
||||
- APPRISE__KEY=myConfigKey
|
||||
```
|
||||
180
docs/docs/configuration/examples/2_config-file.mdx
Normal file
180
docs/docs/configuration/examples/2_config-file.mdx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Note } from '@site/src/components/Admonition';
|
||||
|
||||
# Configuration file example (when not using Docker)
|
||||
|
||||
<Note>
|
||||
**This example contains all settings and should be modified to fit your needs.**
|
||||
</Note>
|
||||
|
||||
```
|
||||
{
|
||||
"TZ": "America/New_York",
|
||||
"DRY_RUN": true,
|
||||
"HTTP_MAX_RETRIES": 0,
|
||||
"HTTP_TIMEOUT": 10,
|
||||
"Logging": {
|
||||
"LogLevel": "Information",
|
||||
"Enhanced": true,
|
||||
"File": {
|
||||
"Enabled": false,
|
||||
"Path": "/var/logs"
|
||||
}
|
||||
},
|
||||
"Triggers": {
|
||||
"QueueCleaner": "0 0/5 * * * ?",
|
||||
"ContentBlocker": "0 0/5 * * * ?",
|
||||
"DownloadCleaner": "0 0 * * * ?"
|
||||
},
|
||||
"QueueCleaner": {
|
||||
"Enabled": true,
|
||||
"RunSequentially": true,
|
||||
"IGNORED_DOWNLOADS_PATH": "/ignored.txt",
|
||||
"IMPORT_FAILED_MAX_STRIKES": 5,
|
||||
"IMPORT_FAILED_IGNORE_PRIVATE": false,
|
||||
"IMPORT_FAILED_DELETE_PRIVATE": false,
|
||||
"IMPORT_FAILED_IGNORE_PATTERNS": [
|
||||
"title mismatch",
|
||||
"manual import required"
|
||||
],
|
||||
"STALLED_MAX_STRIKES": 5,
|
||||
"STALLED_RESET_STRIKES_ON_PROGRESS": true,
|
||||
"STALLED_IGNORE_PRIVATE": false,
|
||||
"STALLED_DELETE_PRIVATE": false,
|
||||
"SLOW_MAX_STRIKES": 5,
|
||||
"SLOW_RESET_STRIKES_ON_PROGRESS": true,
|
||||
"SLOW_IGNORE_PRIVATE": false,
|
||||
"SLOW_DELETE_PRIVATE": false,
|
||||
"SLOW_MIN_SPEED": "1MB",
|
||||
"SLOW_MAX_TIME": 20,
|
||||
"SLOW_IGNORE_ABOVE_SIZE": "60GB"
|
||||
},
|
||||
"ContentBlocker": {
|
||||
"Enabled": true,
|
||||
"IGNORE_PRIVATE": false,
|
||||
"DELETE_PRIVATE": false,
|
||||
"IGNORED_DOWNLOADS_PATH": "/ignored.txt"
|
||||
},
|
||||
"DownloadCleaner": {
|
||||
"Enabled": false,
|
||||
"DELETE_PRIVATE": false,
|
||||
"CATEGORIES": [
|
||||
{
|
||||
"Name": "tv-sonarr",
|
||||
"MAX_RATIO": 1,
|
||||
"MIN_SEED_TIME": 0,
|
||||
"MAX_SEED_TIME": 240
|
||||
},
|
||||
{
|
||||
"Name": "radarr",
|
||||
"MAX_RATIO": 1,
|
||||
"MIN_SEED_TIME": 0,
|
||||
"MAX_SEED_TIME": 240
|
||||
},
|
||||
{
|
||||
"Name": "cleanuperr-unlinked",
|
||||
"MAX_RATIO": 1,
|
||||
"MIN_SEED_TIME": 0,
|
||||
"MAX_SEED_TIME": 240
|
||||
}
|
||||
],
|
||||
"UNLINKED_TARGET_CATEGORY": "cleanuperr-unlinked",
|
||||
"UNLINKED_IGNORED_ROOT_DIR": "/downloads",
|
||||
"UNLINKED_CATEGORIES": [
|
||||
"tv-sonarr",
|
||||
"radarr"
|
||||
],
|
||||
"IGNORED_DOWNLOADS_PATH": "/ignored.txt"
|
||||
},
|
||||
"DOWNLOAD_CLIENT": "none",
|
||||
"qBittorrent": {
|
||||
"Url": "http://localhost:8080",
|
||||
"URL_BASE": "myCustomPath",
|
||||
"Username": "user",
|
||||
"Password": "pass"
|
||||
},
|
||||
"Deluge": {
|
||||
"Url": "http://localhost:8112",
|
||||
"URL_BASE": "myCustomPath",
|
||||
"Password": "pass"
|
||||
},
|
||||
"Transmission": {
|
||||
"Url": "http://localhost:9091",
|
||||
"URL_BASE": "myCustomPath",
|
||||
"Username": "user",
|
||||
"Password": "pass"
|
||||
},
|
||||
"Sonarr": {
|
||||
"Enabled": true,
|
||||
"SearchType": "Episode",
|
||||
"Block": {
|
||||
"Type": "blacklist",
|
||||
"Path": "https://example.com/path/to/file.txt"
|
||||
},
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:8989",
|
||||
"ApiKey": "sonarrSecret1"
|
||||
},
|
||||
{
|
||||
"Url": "http://localhost:8990",
|
||||
"ApiKey": "sonarrSecret2"
|
||||
},
|
||||
]
|
||||
},
|
||||
"Radarr": {
|
||||
"Enabled": true,
|
||||
"Block": {
|
||||
"Type": "blacklist",
|
||||
"Path": "https://example.com/path/to/file.txt"
|
||||
},
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:7878",
|
||||
"ApiKey": "sonarrSecret1"
|
||||
},
|
||||
{
|
||||
"Url": "http://localhost:7879",
|
||||
"ApiKey": "sonarrSecret2"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Lidarr": {
|
||||
"Enabled": true,
|
||||
"Block": {
|
||||
"Type": "blacklist",
|
||||
"Path": "https://example.com/path/to/file.txt"
|
||||
},
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:8686",
|
||||
"ApiKey": "lidarrSecret1"
|
||||
},
|
||||
{
|
||||
"Url": "http://localhost:8687",
|
||||
"ApiKey": "lidarrSecret2"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Notifiarr": {
|
||||
"ON_IMPORT_FAILED_STRIKE": true,
|
||||
"ON_STALLED_STRIKE": true,
|
||||
"ON_SLOW_STRIKE": true,
|
||||
"ON_QUEUE_ITEM_DELETED": true,
|
||||
"ON_DOWNLOAD_CLEANED": true,
|
||||
"ON_CATEGORY_CHANGED": true,
|
||||
"API_KEY": "notifiarr_secret",
|
||||
"CHANNEL_ID": "discord_channel_id"
|
||||
},
|
||||
"Apprise": {
|
||||
"ON_IMPORT_FAILED_STRIKE": true,
|
||||
"ON_STALLED_STRIKE": true,
|
||||
"ON_SLOW_STRIKE": true,
|
||||
"ON_QUEUE_ITEM_DELETED": true,
|
||||
"ON_DOWNLOAD_CLEANED": true,
|
||||
"ON_CATEGORY_CHANGED": true,
|
||||
"URL": "http://localhost:8000",
|
||||
"KEY": "myConfigKey"
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
7
docs/docs/configuration/examples/_category_.json
Normal file
7
docs/docs/configuration/examples/_category_.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"label": "Configuration examples",
|
||||
"position": 8,
|
||||
"link": {
|
||||
"type": "generated-index"
|
||||
}
|
||||
}
|
||||
7
docs/docs/configuration/notifications/1_notifiarr.mdx
Normal file
7
docs/docs/configuration/notifications/1_notifiarr.mdx
Normal file
@@ -0,0 +1,7 @@
|
||||
import NotifiarrSettings from '@site/src/components/configuration/notifications/NotifiarrSettings';
|
||||
|
||||
# Notifiarr Settings
|
||||
|
||||
These settings control how Cleanuperr sends notifications through [Notifiarr](https://notifiarr.com/).
|
||||
|
||||
<NotifiarrSettings/>
|
||||
7
docs/docs/configuration/notifications/2_apprise.mdx
Normal file
7
docs/docs/configuration/notifications/2_apprise.mdx
Normal file
@@ -0,0 +1,7 @@
|
||||
import AppriseSettings from '@site/src/components/configuration/notifications/AppriseSettings';
|
||||
|
||||
# Apprise Settings
|
||||
|
||||
These settings control how Cleanuperr sends notifications through [Apprise](https://github.com/caronc/apprise-api).
|
||||
|
||||
<AppriseSettings/>
|
||||
8
docs/docs/configuration/notifications/_category_.json
Normal file
8
docs/docs/configuration/notifications/_category_.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Notifications",
|
||||
"position": 7,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Settings for receiving notifications."
|
||||
}
|
||||
}
|
||||
11
docs/docs/configuration/queue-cleaner/1_general.mdx
Normal file
11
docs/docs/configuration/queue-cleaner/1_general.mdx
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
import QueueCleanerGeneralSettings from '@site/src/components/configuration/queue-cleaner/QueueCleanerGeneralSettings';
|
||||
|
||||
# General Settings
|
||||
|
||||
These settings control the general behavior of the Queue Cleaner functionality.
|
||||
|
||||
<QueueCleanerGeneralSettings/>
|
||||
11
docs/docs/configuration/queue-cleaner/2_import-failed.mdx
Normal file
11
docs/docs/configuration/queue-cleaner/2_import-failed.mdx
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
import QueueCleanerImportFailedSettings from '@site/src/components/configuration/queue-cleaner/QueueCleanerImportFailedSettings';
|
||||
|
||||
# Import Failed Settings
|
||||
|
||||
These settings control how the Queue Cleaner handles failed imports.
|
||||
|
||||
<QueueCleanerImportFailedSettings/>
|
||||
11
docs/docs/configuration/queue-cleaner/3_stalled.mdx
Normal file
11
docs/docs/configuration/queue-cleaner/3_stalled.mdx
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
import QueueCleanerStalledSettings from '@site/src/components/configuration/queue-cleaner/QueueCleanerStalledSettings';
|
||||
|
||||
# Stalled Downloads Settings
|
||||
|
||||
These settings control how the Queue Cleaner handles stalled downloads.
|
||||
|
||||
<QueueCleanerStalledSettings/>
|
||||
11
docs/docs/configuration/queue-cleaner/4_slow.mdx
Normal file
11
docs/docs/configuration/queue-cleaner/4_slow.mdx
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
import QueueCleanerSlowSettings from '@site/src/components/configuration/queue-cleaner/QueueCleanerSlowSettings';
|
||||
|
||||
# Slow Downloads Settings
|
||||
|
||||
These settings control how the Queue Cleaner handles slow downloads.
|
||||
|
||||
<QueueCleanerSlowSettings/>
|
||||
8
docs/docs/configuration/queue-cleaner/_category_.json
Normal file
8
docs/docs/configuration/queue-cleaner/_category_.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Queue Cleaner",
|
||||
"position": 1,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Settings for the Queue Cleaner functionality."
|
||||
}
|
||||
}
|
||||
14
docs/docs/installation/1_quick-start.mdx
Normal file
14
docs/docs/installation/1_quick-start.mdx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Note } from '@site/src/components/Admonition';
|
||||
|
||||
# Quick start
|
||||
|
||||
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](/docs/installation/windows), [Linux](/docs/installation/linux) or [MacOS](/docs/installation/macos).
|
||||
|
||||
<Note>
|
||||
Refer to the [Configuration](/docs/category/configuration) section for detailed configuration instructions.
|
||||
</Note>
|
||||
31
docs/docs/installation/2_windows.mdx
Normal file
31
docs/docs/installation/2_windows.mdx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Note } from '@site/src/components/Admonition';
|
||||
|
||||
# Windows
|
||||
|
||||
<Note>
|
||||
The preferred method of installation method is using Docker.
|
||||
</Note>
|
||||
|
||||
1. Download the zip file from [releases](https://github.com/flmorg/cleanuperr/releases).
|
||||
2. Extract the zip file into `C:\example\directory`.
|
||||
3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](/docs/category/configuration).
|
||||
4. Execute `cleanuperr.exe`.
|
||||
|
||||
<Note>
|
||||
### Run as a Windows Service
|
||||
1. Download latest nssm build from `https://nssm.cc/builds`.
|
||||
2. Unzip `nssm.exe` in `C:\example\directory`.
|
||||
3. Open a terminal with Administrator rights and execute these commands:
|
||||
```
|
||||
nssm.exe install Cleanuperr "C:\example\directory\cleanuperr.exe"
|
||||
nssm.exe set Cleanuperr AppDirectory "C:\example\directory\"
|
||||
nssm.exe set Cleanuperr AppStdout "C:\example\directory\cleanuperr.log"
|
||||
nssm.exe set Cleanuperr AppStderr "C:\example\directory\cleanuperr.crash.log"
|
||||
nssm.exe set Cleanuperr AppRotateFiles 1
|
||||
nssm.exe set Cleanuperr AppRotateOnline 1
|
||||
nssm.exe set Cleanuperr AppRotateBytes 10485760
|
||||
nssm.exe set Cleanuperr AppRotateFiles 10
|
||||
nssm.exe set Cleanuperr Start SERVICE_AUTO_START
|
||||
nssm.exe start Cleanuperr
|
||||
```
|
||||
</Note>
|
||||
17
docs/docs/installation/3_linux.mdx
Normal file
17
docs/docs/installation/3_linux.mdx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Note } from '@site/src/components/Admonition';
|
||||
|
||||
# Linux
|
||||
|
||||
<Note>
|
||||
The preferred method of installation method is using Docker.
|
||||
</Note>
|
||||
|
||||
1. Download the zip file from [releases](https://github.com/flmorg/cleanuperr/releases).
|
||||
2. Extract the zip file into `/example/directory`.
|
||||
3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](/docs/category/configuration).
|
||||
4. Open a terminal and execute these commands:
|
||||
```
|
||||
cd /example/directory
|
||||
chmod +x cleanuperr
|
||||
./cleanuperr
|
||||
```
|
||||
25
docs/docs/installation/4_macos.mdx
Normal file
25
docs/docs/installation/4_macos.mdx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Important, Note } from '@site/src/components/Admonition';
|
||||
|
||||
# MacOS
|
||||
|
||||
<Note>
|
||||
The preferred method of installation method is using Docker.
|
||||
</Note>
|
||||
|
||||
1. Download the zip file from [releases](https://github.com/flmorg/cleanuperr/releases).
|
||||
2. Extract the zip file into `/example/directory`.
|
||||
3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](/docs/category/configuration).
|
||||
4. Open a terminal and execute these commands:
|
||||
```
|
||||
cd /example/directory
|
||||
chmod +x cleanuperr
|
||||
./cleanuperr
|
||||
```
|
||||
|
||||
<Important>
|
||||
Some people have experienced problems when trying to execute cleanuperr on MacOS because the system actively blocked the file for not being signed.
|
||||
As per [this comment](https://stackoverflow.com/a/77907937), you may need to also execute this command:
|
||||
```
|
||||
codesign --sign - --force --preserve-metadata=entitlements,requirements,flags,runtime /example/directory/cleanuperr
|
||||
```
|
||||
</Important>
|
||||
53
docs/docs/installation/5_freebsd.mdx
Normal file
53
docs/docs/installation/5_freebsd.mdx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Note } from '@site/src/components/Admonition';
|
||||
|
||||
# FreeBSD
|
||||
|
||||
<Note>
|
||||
The preferred method of installation method is using Docker.
|
||||
</Note>
|
||||
|
||||
1. Installation:
|
||||
```
|
||||
# install dependencies
|
||||
pkg install -y git icu libinotify libunwind wget
|
||||
|
||||
# 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
|
||||
|
||||
# download NuGet dependencies
|
||||
mkdir -p /tmp/nuget
|
||||
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
|
||||
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
|
||||
|
||||
# add NuGet source
|
||||
dotnet nuget add source /tmp/nuget --name tmp
|
||||
|
||||
# add GitHub NuGet source
|
||||
# a PAT (Personal Access Token) can be generated here https://github.com/settings/tokens
|
||||
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
|
||||
|
||||
# build and publish the app
|
||||
dotnet publish code/Executable/Executable.csproj -c Release --self-contained -o artifacts /p:PublishSingleFile=true
|
||||
|
||||
# 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](/docs/category/configuration).
|
||||
4. Run the app:
|
||||
```
|
||||
cd /example/directory
|
||||
chmod +x cleanuperr
|
||||
./cleanuperr
|
||||
```
|
||||
7
docs/docs/installation/_category_.json
Normal file
7
docs/docs/installation/_category_.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"label": "Installation",
|
||||
"position": 5,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
}
|
||||
}
|
||||
15
docs/docs/setup-scenarios/1_qbit-built-in.mdx
Normal file
15
docs/docs/setup-scenarios/1_qbit-built-in.mdx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Note } from '@site/src/components/Admonition';
|
||||
|
||||
# Using qBit's built-in blacklist (torrent)
|
||||
|
||||
1. Go to qBittorrent -> Options -> Downloads -> make sure `Excluded file names` is checked -> Paste an exclusion list that you have copied.
|
||||
- [blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist), or
|
||||
- [permissive blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist_permissive), or
|
||||
- create your own
|
||||
2. qBittorrent will block files from being downloaded. In the case of malicious content, **nothing is downloaded and the torrent is marked as complete**.
|
||||
3. Start **cleanuperr** with `QUEUECLEANER__ENABLED` set to `true`.
|
||||
4. The **Queue Cleaner** will perform a cleanup process as described in the [How it works](/docs/how_it_works) section.
|
||||
|
||||
<Note>
|
||||
This scenario is an example for blocking malicious files. Other features can be included here by configuring the environment variables.
|
||||
</Note>
|
||||
13
docs/docs/setup-scenarios/2_cleanuperr-blocklist.mdx
Normal file
13
docs/docs/setup-scenarios/2_cleanuperr-blocklist.mdx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Note } from '@site/src/components/Admonition';
|
||||
|
||||
# Using Cleanuperr's blocklist feature (torrent)
|
||||
|
||||
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 settings](/docs/category/arrs-settings) section.
|
||||
3. Once configured, cleanuperr will perform the following tasks:
|
||||
- Execute the **Content Blocker** job, as explained in the [How it works](/docs/how_it_works) section.
|
||||
- Execute the **Queue Cleaner** job, as explained in the [How it works](/docs/how_it_works) section.
|
||||
|
||||
<Note>
|
||||
This scenario is an example for blocking malicious files. Other features can be included here by configuring the environment variables.
|
||||
</Note>
|
||||
16
docs/docs/setup-scenarios/3_failed-imports.mdx
Normal file
16
docs/docs/setup-scenarios/3_failed-imports.mdx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Important } from '@site/src/components/Admonition';
|
||||
|
||||
# Using Cleanuperr just for failed imports (torrent and usenet)
|
||||
|
||||
1. Set `QUEUECLEANER__ENABLED` to `true`.
|
||||
2. Set `QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES` to a desired value.
|
||||
3. Optionally set failed import message patterns to ignore using `QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__<NUMBER>`.
|
||||
4. Set `DOWNLOAD_CLIENT` to `none`(works only for usenet) or `disabled` (works for both usenet and torrent).
|
||||
|
||||
<Important>
|
||||
When `DOWNLOAD_CLIENT=disabled`, no other action involving a download client would work (e.g. content blocking, removing stalled downloads, excluding private trackers).
|
||||
|
||||
When the download client is set to `disabled`, the queue cleaner will be able to remove items that are failed to be imported even if there is no download client configured. This means that all downloads, including private ones, will be completely removed.
|
||||
|
||||
Setting `DOWNLOAD_CLIENT=disabled` means you don't care about seeding, ratio, H&R and potentially losing your private tracker account.
|
||||
</Important>
|
||||
8
docs/docs/setup-scenarios/_category_.json
Normal file
8
docs/docs/setup-scenarios/_category_.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Setup scenarios",
|
||||
"position": 5,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "This page provides documentation on the various setup scenarios for using the application."
|
||||
}
|
||||
}
|
||||
82
docs/docusaurus.config.ts
Normal file
82
docs/docusaurus.config.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {themes as prismThemes} from 'prism-react-renderer';
|
||||
import type {Config} from '@docusaurus/types';
|
||||
import type * as Preset from '@docusaurus/preset-classic';
|
||||
|
||||
const config: Config = {
|
||||
title: 'Cleanuperr',
|
||||
tagline: 'Cleaning arrs since \'24.',
|
||||
favicon: 'img/16.png',
|
||||
|
||||
url: 'https://flmorg.github.io',
|
||||
baseUrl: '/cleanuperr/',
|
||||
|
||||
organizationName: 'flmorg',
|
||||
projectName: 'cleanuperr',
|
||||
|
||||
onBrokenLinks: 'throw',
|
||||
onBrokenMarkdownLinks: 'warn',
|
||||
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en'],
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
'classic',
|
||||
{
|
||||
docs: {
|
||||
sidebarPath: './sidebars.ts',
|
||||
},
|
||||
theme: {
|
||||
customCss: './src/css/custom.css',
|
||||
},
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
colorMode: {
|
||||
defaultMode: 'dark',
|
||||
disableSwitch: false,
|
||||
respectPrefersColorScheme: false,
|
||||
},
|
||||
navbar: {
|
||||
title: 'Cleanuperr',
|
||||
logo: {
|
||||
alt: 'Cleanuperr Logo',
|
||||
src: 'img/cleanuperr.svg',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'docSidebar',
|
||||
sidebarId: 'configurationSidebar',
|
||||
position: 'left',
|
||||
label: 'Docs',
|
||||
activeBasePath: '/docs',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/flmorg/cleanuperr',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
href: 'https://discord.gg/sWggpnmGNY',
|
||||
label: 'Discord',
|
||||
position: 'right',
|
||||
}
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: 'dark',
|
||||
links: [],
|
||||
copyright: `Copyright © ${new Date().getFullYear()} Cleanuperr. Built with Docusaurus.`,
|
||||
},
|
||||
prism: {
|
||||
theme: prismThemes.github,
|
||||
darkTheme: prismThemes.dracula,
|
||||
},
|
||||
} satisfies Preset.ThemeConfig,
|
||||
};
|
||||
|
||||
export default config;
|
||||
16684
docs/package-lock.json
generated
Normal file
16684
docs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
docs/package.json
Normal file
48
docs/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.7.0",
|
||||
"@docusaurus/preset-classic": "3.7.0",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.7.0",
|
||||
"@docusaurus/tsconfig": "3.7.0",
|
||||
"@docusaurus/types": "3.7.0",
|
||||
"typescript": "~5.6.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 3 chrome version",
|
||||
"last 3 firefox version",
|
||||
"last 5 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
}
|
||||
}
|
||||
7
docs/sidebars.ts
Normal file
7
docs/sidebars.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
|
||||
|
||||
const sidebars: SidebarsConfig = {
|
||||
configurationSidebar: [{type: 'autogenerated', dirName: '.'}],
|
||||
};
|
||||
|
||||
export default sidebars;
|
||||
33
docs/src/components/Admonition.tsx
Normal file
33
docs/src/components/Admonition.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
|
||||
export type AdmonitionType = "important" | "warning" | "note";
|
||||
|
||||
interface AdmonitionProps {
|
||||
type: AdmonitionType;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Admonition({ type, children }: AdmonitionProps) {
|
||||
return (
|
||||
<div className={`admonition admonition-${type} alert alert--${type}`}>
|
||||
<div className="admonition-heading">
|
||||
<h5>{type.charAt(0).toUpperCase() + type.slice(1)}</h5>
|
||||
</div>
|
||||
<div className="admonition-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Important({ children }: { children: React.ReactNode }) {
|
||||
return <Admonition type="important">{children}</Admonition>;
|
||||
}
|
||||
|
||||
export function Warning({ children }: { children: React.ReactNode }) {
|
||||
return <Admonition type="warning">{children}</Admonition>;
|
||||
}
|
||||
|
||||
export function Note({ children }: { children: React.ReactNode }) {
|
||||
return <Admonition type="note">{children}</Admonition>;
|
||||
}
|
||||
201
docs/src/components/configuration/EnvVars.tsx
Normal file
201
docs/src/components/configuration/EnvVars.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useLocation } from "@docusaurus/router";
|
||||
import Admonition from "../Admonition";
|
||||
|
||||
export type DescriptionContent =
|
||||
| string
|
||||
| {
|
||||
type: "code" | "list";
|
||||
title: string;
|
||||
content: string | string[];
|
||||
};
|
||||
|
||||
export interface EnvVarProps {
|
||||
name: string;
|
||||
description: DescriptionContent[];
|
||||
type: string;
|
||||
reference?: string;
|
||||
required?: boolean | string;
|
||||
defaultValue: string;
|
||||
defaultValueComment?: string;
|
||||
examples?: string[];
|
||||
acceptedValues?: string[];
|
||||
children?: React.ReactNode;
|
||||
notes?: string[];
|
||||
important?: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
interface EnvVarsProps {
|
||||
vars: EnvVarProps[];
|
||||
}
|
||||
|
||||
export default function EnvVars({ vars }: EnvVarsProps) {
|
||||
return vars.map((env) => <EnvVar key={env.name} env={env} />);
|
||||
}
|
||||
|
||||
function EnvVar({ env }: { env: EnvVarProps }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const queryKeys = Array.from(searchParams.keys());
|
||||
|
||||
const matched = queryKeys.find(
|
||||
(key) => key.toLowerCase() === env.name.toLowerCase()
|
||||
);
|
||||
|
||||
if (matched && ref.current) {
|
||||
// Scroll to the variable
|
||||
ref.current.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
|
||||
// Add highlight effect
|
||||
ref.current.classList.add("env-var-highlight");
|
||||
|
||||
setTimeout(() => {
|
||||
ref.current.classList.add("highlight-removing");
|
||||
}, 2000);
|
||||
|
||||
setTimeout(() => {
|
||||
ref.current.classList.remove("env-var-highlight", "highlight-removing");
|
||||
}, 3000);
|
||||
}
|
||||
}, [location.search, env.name]);
|
||||
|
||||
const renderDescriptionContent = (
|
||||
content: DescriptionContent,
|
||||
index: number
|
||||
) => {
|
||||
if (typeof content === "string") {
|
||||
return <ReactMarkdown components={{ p: ({ children }) => <div>{children}</div> }}>{content}</ReactMarkdown>;
|
||||
}
|
||||
|
||||
switch (content.type) {
|
||||
case "code":
|
||||
return (
|
||||
<section>
|
||||
{content.title && <strong>{content.title}</strong>}
|
||||
<br />
|
||||
<pre key={index}>
|
||||
{content.content}
|
||||
</pre>
|
||||
</section>
|
||||
);
|
||||
case "list":
|
||||
return (
|
||||
<section>
|
||||
{content.title && <strong>{content.title}</strong>}
|
||||
<br />
|
||||
<ul key={index}>
|
||||
{(Array.isArray(content.content)
|
||||
? content.content
|
||||
: [content.content]
|
||||
).map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderAdmonition = (type: "important" | "warning" | "note", items: string[]) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Admonition type={type}>
|
||||
<ul>
|
||||
{items.map((item, idx) => (
|
||||
<li key={idx}>
|
||||
<ReactMarkdown components={{ p: ({ children }) => <>{children}</> }}>
|
||||
{item}
|
||||
</ReactMarkdown>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Admonition>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id={env.name} ref={ref} className="env-var-block">
|
||||
<h3>
|
||||
<code>{env.name}</code>
|
||||
</h3>
|
||||
{env.description.map((desc, index) =>
|
||||
renderDescriptionContent(desc, index)
|
||||
)}
|
||||
{env.required !== undefined && (
|
||||
<section>
|
||||
<strong>Required: </strong>
|
||||
{typeof env.required === "boolean"
|
||||
? env.required
|
||||
? "Yes"
|
||||
: "No"
|
||||
: env.required}
|
||||
</section>
|
||||
)}
|
||||
{env.type !== undefined && (
|
||||
<section>
|
||||
<strong>Type: </strong>
|
||||
{env.type}
|
||||
</section>
|
||||
)}
|
||||
{env.defaultValue !== undefined && (
|
||||
<section>
|
||||
<strong>Default value: </strong>
|
||||
<code>{env.defaultValue}</code> {env.defaultValueComment !== undefined && (`(${env.defaultValueComment})`)}
|
||||
</section>
|
||||
)}
|
||||
{env.reference !== undefined && (
|
||||
<section>
|
||||
<strong>Reference: </strong>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ children }) => <>{children}</>, // No wrapping <p> tag
|
||||
}}
|
||||
>
|
||||
{`[Quartz.NET](${env.reference})`}
|
||||
</ReactMarkdown>
|
||||
</section>
|
||||
)}
|
||||
{env.acceptedValues && env.acceptedValues.length > 0 && (
|
||||
<section>
|
||||
<strong>Accepted values:</strong>
|
||||
<ul>
|
||||
{env.acceptedValues.map((example, index) => (
|
||||
<li key={index}>
|
||||
<code>{example}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{env.examples && env.examples.length > 0 && (
|
||||
<section>
|
||||
<strong>Examples:</strong>
|
||||
<ul>
|
||||
{env.examples.map((example, index) => (
|
||||
<li key={index}>
|
||||
<code>{example}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{env.notes && renderAdmonition("note", env.notes)}
|
||||
{env.important && renderAdmonition("important", env.important)}
|
||||
{env.warnings && renderAdmonition("warning", env.warnings)}
|
||||
|
||||
<div style={{ marginTop: "0.5rem" }}>{env.children}</div>
|
||||
</div>
|
||||
<hr />
|
||||
</>
|
||||
);
|
||||
}
|
||||
89
docs/src/components/configuration/GeneralSettings.tsx
Normal file
89
docs/src/components/configuration/GeneralSettings.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import EnvVars, { EnvVarProps } from "./EnvVars";
|
||||
|
||||
const settings: EnvVarProps[] = [
|
||||
{
|
||||
name: "TZ",
|
||||
description: [
|
||||
"The time zone to use."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "UTC",
|
||||
required: false,
|
||||
examples: ["America/New_York", "Europe/London", "Asia/Tokyo"],
|
||||
},
|
||||
{
|
||||
name: "DRY_RUN",
|
||||
description: [
|
||||
"When enabled, simulates irreversible operations (like deletions and notifications) without making actual changes."
|
||||
],
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
required: false,
|
||||
acceptedValues: ["true", "false"],
|
||||
},
|
||||
{
|
||||
name: "LOGGING__LOGLEVEL",
|
||||
description: [
|
||||
"Controls the detail level of application logs."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "Information",
|
||||
required: false,
|
||||
acceptedValues: ["Verbose", "Debug", "Information", "Warning", "Error", "Fatal"],
|
||||
},
|
||||
{
|
||||
name: "LOGGING__FILE__ENABLED",
|
||||
description: [
|
||||
"Enables logging to a file."
|
||||
],
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
required: false,
|
||||
acceptedValues: ["true", "false"],
|
||||
},
|
||||
{
|
||||
name: "LOGGING__FILE__PATH",
|
||||
description: [
|
||||
"Directory where log files will be saved."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "Empty (file is saved where the app is)",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "LOGGING__ENHANCED",
|
||||
description: [
|
||||
"Provides more detailed descriptions in logs whenever possible.",
|
||||
"Will be deprecated in a future version."
|
||||
],
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
required: false,
|
||||
acceptedValues: ["true", "false"],
|
||||
},
|
||||
{
|
||||
name: "HTTP_MAX_RETRIES",
|
||||
description: [
|
||||
"The number of times to retry a failed HTTP call.",
|
||||
"Applies when communicating with *arrs, download clients and other services through HTTP calls."
|
||||
],
|
||||
type: "positive integer number",
|
||||
defaultValue: "0",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "HTTP_TIMEOUT",
|
||||
description: [
|
||||
"The number of seconds to wait before failing an HTTP call.",
|
||||
"Applies to calls to *arrs, download clients, and other services."
|
||||
],
|
||||
type: "positive integer number",
|
||||
defaultValue: "100",
|
||||
required: false,
|
||||
}
|
||||
];
|
||||
|
||||
export default function GeneralSettings() {
|
||||
return <EnvVars vars={settings} />;
|
||||
}
|
||||
68
docs/src/components/configuration/arrs/LidarrSettings.tsx
Normal file
68
docs/src/components/configuration/arrs/LidarrSettings.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
import EnvVars, { EnvVarProps } from "../EnvVars";
|
||||
|
||||
const settings: EnvVarProps[] = [
|
||||
{
|
||||
name: "LIDARR__ENABLED",
|
||||
description: [
|
||||
"Enables or disables Lidarr cleanup."
|
||||
],
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
required: false,
|
||||
acceptedValues: ["true", "false"],
|
||||
},
|
||||
{
|
||||
name: "LIDARR__BLOCK__TYPE",
|
||||
description: [
|
||||
"Determines how file blocking works for Lidarr."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "blacklist",
|
||||
required: false,
|
||||
acceptedValues: ["blacklist", "whitelist"],
|
||||
},
|
||||
{
|
||||
name: "LIDARR__BLOCK__PATH",
|
||||
description: [
|
||||
"Path to the blocklist file (local file or URL).",
|
||||
"The value must be JSON compatible.",
|
||||
{
|
||||
type: "code",
|
||||
title: "The blocklists support the following patterns:",
|
||||
content: `*example // file name ends with \"example\"
|
||||
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:\"`,
|
||||
}
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "Empty",
|
||||
required: false,
|
||||
examples: ["/blocklist.json", "https://example.com/blocklist.json"]
|
||||
},
|
||||
{
|
||||
name: "LIDARR__INSTANCES__0__URL",
|
||||
description: [
|
||||
"URL of the Lidarr instance."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "http://localhost:8686",
|
||||
required: false,
|
||||
examples: ["http://localhost:8686", "http://lidarr:8686"],
|
||||
},
|
||||
{
|
||||
name: "LIDARR__INSTANCES__0__APIKEY",
|
||||
description: [
|
||||
"API key for the Lidarr instance."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "Empty",
|
||||
required: false,
|
||||
}
|
||||
];
|
||||
|
||||
export default function LidarrSettings() {
|
||||
return <EnvVars vars={settings} />;
|
||||
}
|
||||
71
docs/src/components/configuration/arrs/RadarrSettings.tsx
Normal file
71
docs/src/components/configuration/arrs/RadarrSettings.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
import EnvVars, { EnvVarProps } from "../EnvVars";
|
||||
|
||||
const settings: EnvVarProps[] = [
|
||||
{
|
||||
name: "RADARR__ENABLED",
|
||||
description: [
|
||||
"Enables or disables Radarr cleanup."
|
||||
],
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
required: false,
|
||||
acceptedValues: ["true", "false"],
|
||||
},
|
||||
{
|
||||
name: "RADARR__BLOCK__TYPE",
|
||||
description: [
|
||||
"Determines how file blocking works for Radarr."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "blacklist",
|
||||
required: false,
|
||||
acceptedValues: ["blacklist", "whitelist"],
|
||||
},
|
||||
{
|
||||
name: "RADARR__BLOCK__PATH",
|
||||
description: [
|
||||
"Path to the blocklist file (local file or URL).",
|
||||
"The value must be JSON compatible.",
|
||||
{
|
||||
type: "code",
|
||||
title: "The blocklists support the following patterns:",
|
||||
content: `*example // file name ends with \"example\"
|
||||
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:\"`,
|
||||
}
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "Empty",
|
||||
required: false,
|
||||
examples: ["/blocklist.json", "https://example.com/blocklist.json"],
|
||||
notes: [
|
||||
"[This blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist), [this permissive blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist_permissive) and [this whitelist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/whitelist) can be used for Sonarr and Radarr."
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "RADARR__INSTANCES__0__URL",
|
||||
description: [
|
||||
"URL of the Radarr instance."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "http://localhost:7878",
|
||||
required: false,
|
||||
examples: ["http://localhost:7878", "http://radarr:7878"],
|
||||
},
|
||||
{
|
||||
name: "RADARR__INSTANCES__0__APIKEY",
|
||||
description: [
|
||||
"API key for the Radarr instance."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "Empty",
|
||||
required: false
|
||||
}
|
||||
];
|
||||
|
||||
export default function RadarrSettings() {
|
||||
return <EnvVars vars={settings} />;
|
||||
}
|
||||
81
docs/src/components/configuration/arrs/SonarrSettings.tsx
Normal file
81
docs/src/components/configuration/arrs/SonarrSettings.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from "react";
|
||||
import EnvVars, { EnvVarProps } from "../EnvVars";
|
||||
|
||||
const settings: EnvVarProps[] = [
|
||||
{
|
||||
name: "SONARR__ENABLED",
|
||||
description: [
|
||||
"Enables or disables Sonarr cleanup."
|
||||
],
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
required: false,
|
||||
acceptedValues: ["true", "false"],
|
||||
},
|
||||
{
|
||||
name: "SONARR__BLOCK__TYPE",
|
||||
description: [
|
||||
"Determines how file blocking works for Sonarr."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "blacklist",
|
||||
required: false,
|
||||
acceptedValues: ["blacklist", "whitelist"],
|
||||
},
|
||||
{
|
||||
name: "SONARR__BLOCK__PATH",
|
||||
description: [
|
||||
"Path to the blocklist file (local file or URL).",
|
||||
"The value must be JSON compatible.",
|
||||
{
|
||||
type: "code",
|
||||
title: "The blocklists support the following patterns:",
|
||||
content: `*example // file name ends with \"example\"
|
||||
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:\"`,
|
||||
}
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "Empty",
|
||||
required: false,
|
||||
examples: ["/blocklist.json", "https://example.com/blocklist.json"],
|
||||
notes: [
|
||||
"[This blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist), [this permissive blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist_permissive) and [this whitelist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/whitelist) can be used for Sonarr and Radarr."
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "SONARR__SEARCHTYPE",
|
||||
description: [
|
||||
"Determines what to search for after removing a queue item."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "Episode",
|
||||
required: false,
|
||||
acceptedValues: ["Episode", "Season", "Series"],
|
||||
},
|
||||
{
|
||||
name: "SONARR__INSTANCES__0__URL",
|
||||
description: [
|
||||
"URL of the Sonarr instance."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "http://localhost:8989",
|
||||
required: false,
|
||||
examples: ["http://localhost:8989", "http://sonarr:8989"],
|
||||
},
|
||||
{
|
||||
name: "SONARR__INSTANCES__0__APIKEY",
|
||||
description: [
|
||||
"API key for the Sonarr instance."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "Empty",
|
||||
required: false
|
||||
}
|
||||
];
|
||||
|
||||
export default function SonarrSettings() {
|
||||
return <EnvVars vars={settings} />;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from "react";
|
||||
import EnvVars, { EnvVarProps } from "../EnvVars";
|
||||
|
||||
const settings: EnvVarProps[] = [
|
||||
{
|
||||
name: "CONTENTBLOCKER__ENABLED",
|
||||
description: [
|
||||
"Enables or disables the Content Blocker functionality.",
|
||||
"When enabled, processes all items in the *arr queue and marks unwanted files."
|
||||
],
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
required: false,
|
||||
examples: ["true", "false"],
|
||||
},
|
||||
{
|
||||
name: "TRIGGERS__CONTENTBLOCKER",
|
||||
description: [
|
||||
"Cron schedule for the Content Blocker job."
|
||||
],
|
||||
type: "text",
|
||||
reference: "https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html",
|
||||
defaultValue: "0 0/5 * * * ?",
|
||||
defaultValueComment: "every 5 minutes",
|
||||
required: "Only required if CONTENTBLOCKER__ENABLED is true",
|
||||
examples: ["0 0/5 * * * ?", "0 0 * * * ?", "0 0 0/1 * * ?"],
|
||||
notes: [
|
||||
"Maximum interval is 6 hours."
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "CONTENTBLOCKER__IGNORED_DOWNLOADS_PATH",
|
||||
description: [
|
||||
"Local path to the file containing downloads to be ignored from being processed by Cleanuperr.",
|
||||
"If the contents of the file are changed, they will be reloaded on the next job run.",
|
||||
"This file is not automatically created, so you need to create it manually.",
|
||||
{
|
||||
type: "list",
|
||||
title:
|
||||
"Accepted values inside the file (each value needs to be on a new line):",
|
||||
content: [
|
||||
"torrent hash",
|
||||
"qBitTorrent tag or category",
|
||||
"Deluge label",
|
||||
"Transmission category (last directory from the save location)",
|
||||
"torrent tracker domain",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "code",
|
||||
title: "Example of file contents:",
|
||||
content: `fa800a7d7c443a2c3561d1f8f393c089036dade1
|
||||
tv-sonarr
|
||||
qbit-tag
|
||||
mytracker.com
|
||||
...`,
|
||||
},
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "Empty",
|
||||
required: false,
|
||||
examples: ["/ignored.txt", "/config/ignored.txt"],
|
||||
warnings: [
|
||||
"Some people have experienced problems using Docker where the mounted file would not update inside the container if it was modified on the host. This is a Docker configuration problem and can not be solved by cleanuperr.",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "CONTENTBLOCKER__IGNORE_PRIVATE",
|
||||
description: [
|
||||
"Controls whether to ignore downloads from private trackers from being processed by Cleanuperr."
|
||||
],
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
required: false,
|
||||
acceptedValues: ["true", "false"],
|
||||
},
|
||||
{
|
||||
name: "CONTENTBLOCKER__DELETE_PRIVATE",
|
||||
description: [
|
||||
"Controls whether to delete private downloads that have all files blocked from the download client.",
|
||||
"Has no effect if CONTENTBLOCKER__IGNORE_PRIVATE is true."
|
||||
],
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
required: false,
|
||||
acceptedValues: ["true", "false"],
|
||||
important: [
|
||||
"Setting CONTENTBLOCKER__DELETE_PRIVATE=true means you don't care about seeding, ratio, H&R and potentially losing your private tracker account."
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default function ContentBlockerGeneralSettings() {
|
||||
return <EnvVars vars={settings} />;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
import EnvVars, { EnvVarProps } from "../EnvVars";
|
||||
|
||||
const settings: EnvVarProps[] = [
|
||||
{
|
||||
name: "DOWNLOADCLEANER__CATEGORIES__0__NAME",
|
||||
description: ["Name of the category to clean."],
|
||||
type: "text",
|
||||
defaultValue: "Empty",
|
||||
required: false,
|
||||
examples: ["tv-sonarr", "movies-radarr", "music-lidarr"],
|
||||
notes: [
|
||||
"The category name must match the category that was set in the *arr.",
|
||||
"For qBittorrent, the category name is the name of the download category.",
|
||||
"For Deluge, the category name is the name of the label.",
|
||||
"For Transmission, the category name is the last directory from the save location.",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "DOWNLOADCLEANER__CATEGORIES__0__MAX_RATIO",
|
||||
description: ["Maximum ratio to reach before removing a download."],
|
||||
type: "decimal number",
|
||||
defaultValue: "-1",
|
||||
required: false,
|
||||
examples: ["-1", "1.0", "2.0", "3.0"],
|
||||
notes: ["`-1` means no limit/disabled."],
|
||||
},
|
||||
{
|
||||
name: "DOWNLOADCLEANER__CATEGORIES__0__MIN_SEED_TIME",
|
||||
description: [
|
||||
"Minimum number of hours to seed before removing a download, if the ratio has been met.",
|
||||
"Used with `MAX_RATIO` to ensure a minimum seed time.",
|
||||
],
|
||||
type: "positive decimal number",
|
||||
defaultValue: "0",
|
||||
required: false,
|
||||
examples: ["0", "24", "48", "72"],
|
||||
},
|
||||
{
|
||||
name: "DOWNLOADCLEANER__CATEGORIES__0__MAX_SEED_TIME",
|
||||
description: [
|
||||
"Maximum number of hours to seed before removing a download.",
|
||||
],
|
||||
type: "decimal number",
|
||||
defaultValue: "-1",
|
||||
required: false,
|
||||
examples: ["-1", "24", "48", "72"],
|
||||
notes: ["`-1` means no limit/disabled."],
|
||||
},
|
||||
];
|
||||
|
||||
export default function DownloadCleanerCleanupSettings() {
|
||||
return <EnvVars vars={settings} />;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from "react";
|
||||
import EnvVars, { EnvVarProps } from "../EnvVars";
|
||||
|
||||
const settings: EnvVarProps[] = [
|
||||
{
|
||||
name: "DOWNLOADCLEANER__ENABLED",
|
||||
description: [
|
||||
"Enables or disables the Download Cleaner functionality.",
|
||||
"When enabled, automatically cleans up downloads that have been seeding for a certain amount of time."
|
||||
],
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
required: false,
|
||||
acceptedValues: ["true", "false"],
|
||||
},
|
||||
{
|
||||
name: "TRIGGERS__DOWNLOADCLEANER",
|
||||
description: [
|
||||
"Cron schedule for the Download Cleaner job."
|
||||
],
|
||||
type: "text",
|
||||
reference: "https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html",
|
||||
defaultValue: "0 0 * * * ?",
|
||||
defaultValueComment: "every hour",
|
||||
required: false,
|
||||
notes: [
|
||||
"Maximum interval is 6 hours."
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "DOWNLOADCLEANER__IGNORED_DOWNLOADS_PATH",
|
||||
description: [
|
||||
"Local path to the file containing ignored downloads.",
|
||||
"If the contents of the file are changed, they will be reloaded on the next job run.",
|
||||
{
|
||||
type: "list",
|
||||
title: "Accepted values inside the file (each value needs to be on a new line):",
|
||||
content: [
|
||||
"torrent hash",
|
||||
"qBitTorrent tag or category",
|
||||
"Deluge label",
|
||||
"Transmission category (last directory from the save location)",
|
||||
"torrent tracker domain"
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "code",
|
||||
title: "Example of file contents:",
|
||||
content: `fa800a7d7c443a2c3561d1f8f393c089036dade1
|
||||
tv-sonarr
|
||||
qbit-tag
|
||||
mytracker.com
|
||||
...`
|
||||
}
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "Empty",
|
||||
required: false,
|
||||
examples: ["/ignored.txt", "/config/ignored.txt"],
|
||||
warnings: [
|
||||
"Some people have experienced problems using Docker where the mounted file would not update inside the container if it was modified on the host. This is a Docker configuration problem and can not be solved by cleanuperr."
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "DOWNLOADCLEANER__DELETE_PRIVATE",
|
||||
description: [
|
||||
"Controls whether to delete private downloads."
|
||||
],
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
required: false,
|
||||
acceptedValues: ["true", "false"],
|
||||
important: [
|
||||
"Setting `DOWNLOADCLEANER__DELETE_PRIVATE=true` means you don't care about seeding, ratio, H&R and potentially losing your private tracker account."
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default function DownloadCleanerGeneralSettings() {
|
||||
return <EnvVars vars={settings} />;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import EnvVars, { EnvVarProps } from "../EnvVars";
|
||||
|
||||
const settings: EnvVarProps[] = [
|
||||
{
|
||||
name: "DOWNLOADCLEANER__UNLINKED_TARGET_CATEGORY",
|
||||
description: [
|
||||
"The category to set on downloads that do not have hardlinks."
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "cleanuperr-unlinked",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "DOWNLOADCLEANER__UNLINKED_IGNORED_ROOT_DIR",
|
||||
description: [
|
||||
"This is useful if you are using [cross-seed](https://www.cross-seed.org/).",
|
||||
"The downloads root directory where the original and cross-seed hardlinks reside. All other hardlinks from this directory will be treated as if they do not exist (e.g. if you have a download with the original file and a cross-seed hardlink, it will be deleted).",
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "Empty",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "DOWNLOADCLEANER__UNLINKED_CATEGORIES__0",
|
||||
description: [
|
||||
"The categories of downloads to check for available hardlinks.",
|
||||
{
|
||||
type: "code",
|
||||
title: "Multiple patterns can be specified using incrementing numbers starting from 0.",
|
||||
content: `DOWNLOADCLEANER__UNLINKED_CATEGORIES__0=tv-sonarr
|
||||
DOWNLOADCLEANER__UNLINKED_CATEGORIES__1=radarr`
|
||||
}
|
||||
],
|
||||
type: "text",
|
||||
defaultValue: "Empty",
|
||||
required: false,
|
||||
}
|
||||
];
|
||||
|
||||
export default function DownloadCleanerHardlinksSettings() {
|
||||
return <EnvVars vars={settings} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user