mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-24 06:28:55 -05:00
Compare commits
213 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
375094862c | ||
|
|
58a72cef0f | ||
|
|
4ceff127a7 | ||
|
|
c07b811cf8 | ||
|
|
b16fa70774 | ||
|
|
b343165644 | ||
|
|
02dff0bb9b | ||
|
|
ac3be75082 | ||
|
|
a1663b865a | ||
|
|
c97a416d1e | ||
|
|
d28ab42303 | ||
|
|
fbb2bba3b6 | ||
|
|
08eda22587 | ||
|
|
a4045eebd3 | ||
|
|
a57cbccbb4 | ||
|
|
2221f118bb | ||
|
|
2cc3eb4ebb | ||
|
|
3a064a22bd | ||
|
|
ee764ff215 | ||
|
|
402677b69b | ||
|
|
97f63434fd | ||
|
|
07d0cf07e3 | ||
|
|
4be107439a | ||
|
|
89ef03a859 | ||
|
|
905384034d | ||
|
|
bf826da1ae | ||
|
|
6aac35181b | ||
|
|
efbf60dcdd | ||
|
|
ebb166a7b9 | ||
|
|
7aced28262 | ||
|
|
ae3e793498 | ||
|
|
4eb98b18a1 | ||
|
|
128e7e5f11 | ||
|
|
d224b2dea0 | ||
|
|
16e823b8d3 | ||
|
|
f2f11e3472 | ||
|
|
a3549c80a9 | ||
|
|
2b9c347ed6 | ||
|
|
98ccee866d | ||
|
|
911849c6dd | ||
|
|
cce3bb2c4a | ||
|
|
bcc117cd0d | ||
|
|
8e20a68ae2 | ||
|
|
736c146f25 | ||
|
|
6398ef1cc6 | ||
|
|
83e6a289be | ||
|
|
5662118b01 | ||
|
|
22dfc7b40d | ||
|
|
a51e387453 | ||
|
|
c7d2ec7311 | ||
|
|
bb9ac5b67b | ||
|
|
f93494adb2 | ||
|
|
7201520411 | ||
|
|
2a1e65e1af | ||
|
|
da318c3339 | ||
|
|
7149b6243f | ||
|
|
11f5a28c04 | ||
|
|
9cc36c7a50 | ||
|
|
861c135cc6 | ||
|
|
3b0275c411 | ||
|
|
cad1b51202 | ||
|
|
f50acd29f4 | ||
|
|
af11d595d8 | ||
|
|
44994d5b21 | ||
|
|
592fd2d846 | ||
|
|
e96be1fca2 | ||
|
|
ee44e2b5ac | ||
|
|
323bfc4d2e | ||
|
|
dca45585ca | ||
|
|
8b5918d221 | ||
|
|
9c227c1f59 | ||
|
|
2ad4499a6f | ||
|
|
33a5bf9ab3 | ||
|
|
de06d1c2d3 | ||
|
|
72855bc030 | ||
|
|
b185ea6899 | ||
|
|
1e0127e97e | ||
|
|
5bdbc98d68 | ||
|
|
e1aeb3da31 | ||
|
|
283b09e8f1 | ||
|
|
b03c96249b | ||
|
|
2971445090 | ||
|
|
55c23419cd | ||
|
|
c4b9d9503a | ||
|
|
823b73d9f0 | ||
|
|
31632d25a4 | ||
|
|
c59951a39c | ||
|
|
d9140d7b5b | ||
|
|
90865a73b5 | ||
|
|
cc45233223 | ||
|
|
5d12d601ae | ||
|
|
88f40438af | ||
|
|
0a9ec06841 | ||
|
|
a0ca6ec4b8 | ||
|
|
eb6cf96470 | ||
|
|
2ca0616771 | ||
|
|
bc85144e60 | ||
|
|
236e31c841 | ||
|
|
7a15139aa6 | ||
|
|
fb6ccfd011 | ||
|
|
ef85e2b690 | ||
|
|
bb734230aa | ||
|
|
aa31c31955 | ||
|
|
1a89822f36 | ||
|
|
fc9e0eca36 | ||
|
|
0010dcb1c6 | ||
|
|
0ab8611f29 | ||
|
|
9e02408a7e | ||
|
|
1bd0db05e6 | ||
|
|
fb438f2ca7 | ||
|
|
d4de7f2ec3 | ||
|
|
98ee1943f9 | ||
|
|
4a57c0fba3 | ||
|
|
db0698d515 | ||
|
|
712cc9ff1e | ||
|
|
501be0e4e7 | ||
|
|
19b7613eea | ||
|
|
3d9cd8f6a9 | ||
|
|
c8add22d3d | ||
|
|
69d8cc8fa0 | ||
|
|
8b8a4b3837 | ||
|
|
c45006f219 | ||
|
|
bc306a37c9 | ||
|
|
aab0487020 | ||
|
|
ca892ce188 | ||
|
|
cff5dc20e5 | ||
|
|
0050c45f09 | ||
|
|
2a8f4634e5 | ||
|
|
81da704e6f | ||
|
|
c82b5e11b1 | ||
|
|
c36d9eb9cf | ||
|
|
2f21603e8e | ||
|
|
586f9964b5 | ||
|
|
124670bb98 | ||
|
|
baf6a8c2f4 | ||
|
|
cd345afc54 | ||
|
|
246ec4d6eb | ||
|
|
569eeae181 | ||
|
|
5a0ef56074 | ||
|
|
09bd4321fb | ||
|
|
4939e37210 | ||
|
|
9463d7587f | ||
|
|
7d2bf41bec | ||
|
|
93bb8cc18d | ||
|
|
449d9e623f | ||
|
|
3a50d9be3c | ||
|
|
693f80fe6a | ||
|
|
8cfc73213a | ||
|
|
6fbae768a4 | ||
|
|
8e9d0127e0 | ||
|
|
b92d70769a | ||
|
|
75b001cf6a | ||
|
|
479ca7884e | ||
|
|
00d8910118 | ||
|
|
bd28c7ab05 | ||
|
|
720279df65 | ||
|
|
2d4ec648b8 | ||
|
|
704fdaca4a | ||
|
|
b134136e51 | ||
|
|
5ca717d7e0 | ||
|
|
7068ee5e5a | ||
|
|
9f770473e5 | ||
|
|
5fe0f5750a | ||
|
|
b8ce225ccc | ||
|
|
f21f7388b7 | ||
|
|
a1354f231a | ||
|
|
4bc1c33e81 | ||
|
|
32bcbab523 | ||
|
|
b94ae21e11 | ||
|
|
a92ebd75c2 | ||
|
|
e6d3929fc9 | ||
|
|
a68e13af35 | ||
|
|
324c3ace8f | ||
|
|
3a9d5d9085 | ||
|
|
89a6eaf0ce | ||
|
|
027c4a0f4d | ||
|
|
81990c6768 | ||
|
|
ba02aa0e49 | ||
|
|
5adbdbd920 | ||
|
|
b3b211d956 | ||
|
|
279bd6d82d | ||
|
|
5dced28228 | ||
|
|
51bdaf64e4 | ||
|
|
9c8e0ebedc | ||
|
|
e1bea8a8c8 | ||
|
|
a6d3820104 | ||
|
|
36c793a5fb | ||
|
|
aade8a91c3 | ||
|
|
3fe7c3de1a | ||
|
|
596a5aed8d | ||
|
|
19b3675701 | ||
|
|
1713d0fd1e | ||
|
|
3a95a302c0 | ||
|
|
e738ba2334 | ||
|
|
c813215f3e | ||
|
|
0f63a2d271 | ||
|
|
133c34de53 | ||
|
|
a3ca735b12 | ||
|
|
519ab6a0cd | ||
|
|
0c691a540a | ||
|
|
209f78717f | ||
|
|
a02be80ac1 | ||
|
|
8a8b906b6f | ||
|
|
b88ddde417 | ||
|
|
666c2656ec | ||
|
|
7786776ed8 | ||
|
|
2c60b38edf | ||
|
|
922f586706 | ||
|
|
2bc8e445ce | ||
|
|
058507ac39 | ||
|
|
f0dc51f10b | ||
|
|
c7ad1c5ee6 | ||
|
|
d7913ae2b8 |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: Flaminel
|
||||
24
.github/ISSUE_TEMPLATE/1-bug.yml
vendored
24
.github/ISSUE_TEMPLATE/1-bug.yml
vendored
@@ -1,12 +1,19 @@
|
||||
name: Bug report
|
||||
description: File a bug report if something is not working right.
|
||||
title: "[BUG]: "
|
||||
title: "[BUG] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to improve cleanuperr!
|
||||
Thanks for taking the time to improve Cleanuparr!
|
||||
- type: checkboxes
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: "Duplicate check"
|
||||
options:
|
||||
- label: I have searched for existing issues and confirmed this is not a duplicate.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: init
|
||||
attributes:
|
||||
@@ -14,8 +21,12 @@ body:
|
||||
options:
|
||||
- label: Reviewed the documentation.
|
||||
required: true
|
||||
- label: Ensured I am using ghcr.io/cleanuparr/cleanuparr docker repository.
|
||||
required: true
|
||||
- label: Ensured I am using the latest version.
|
||||
required: true
|
||||
- label: Enabled verbose logging.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
@@ -23,14 +34,6 @@ body:
|
||||
description: If applicable, mention what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of our software are you running?
|
||||
placeholder: e.g. 1.3.0 or latest
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
@@ -40,6 +43,7 @@ body:
|
||||
- Windows
|
||||
- Linux
|
||||
- MacOS
|
||||
- Unraid
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
23
.github/ISSUE_TEMPLATE/2-feature.yml
vendored
23
.github/ISSUE_TEMPLATE/2-feature.yml
vendored
@@ -1,12 +1,31 @@
|
||||
name: Feature request
|
||||
description: File a feature request.
|
||||
title: "[FEATURE]: "
|
||||
title: "[FEATURE] "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to improve cleanuperr!
|
||||
Thanks for taking the time to improve Cleanuparr!
|
||||
- type: checkboxes
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: "Duplicate check"
|
||||
options:
|
||||
- label: I have searched for existing issues and confirmed this is not a duplicate.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: init
|
||||
attributes:
|
||||
label: Implementation & testing support
|
||||
description: The requester should help answer questions, provide support for the implementation and test changes.
|
||||
options:
|
||||
- label: I understand I must be available to assist with implementation questions and to test the feature before being released.
|
||||
required: true
|
||||
- label: I understand that joining the Discord server may be necessary for better coordination and faster communication.
|
||||
required: true
|
||||
- label: I understand that failure to assist in the development process of my request will result in the request being closed.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
|
||||
22
.github/ISSUE_TEMPLATE/3-help.yml
vendored
22
.github/ISSUE_TEMPLATE/3-help.yml
vendored
@@ -1,12 +1,32 @@
|
||||
name: Help request
|
||||
description: Ask a question to receive help.
|
||||
title: "[HELP]: "
|
||||
title: "[HELP] "
|
||||
labels: ["question"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
If you are experiencing unexpected behavior, please consider submitting a bug report instead.
|
||||
- type: checkboxes
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: "Duplicate check"
|
||||
options:
|
||||
- label: I have searched for existing issues and confirmed this is not a duplicate.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: init
|
||||
attributes:
|
||||
label: "Before submitting a help request, I have:"
|
||||
options:
|
||||
- label: Reviewed the documentation.
|
||||
required: true
|
||||
- label: Ensured I am using ghcr.io/cleanuparr/cleanuparr docker repository.
|
||||
required: true
|
||||
- label: Ensured I am using the latest version.
|
||||
required: true
|
||||
- label: Enabled verbose logging.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
8
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,2 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links: []
|
||||
contact_links:
|
||||
- name: Discord Community
|
||||
url: https://discord.gg/SCtMCgtsc4
|
||||
about: Join our Discord for real-time help and discussions
|
||||
- name: Documentation
|
||||
url: https://cleanuparr.github.io/Cleanuparr/
|
||||
about: Check the documentation for configurations and usage guidelines
|
||||
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
## Description
|
||||
<!-- Brief description of what this PR does -->
|
||||
|
||||
## Related Issue
|
||||
Closes #ISSUE_NUMBER
|
||||
|
||||
## Type of Change
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Documentation update
|
||||
|
||||
## Testing
|
||||
<!-- Describe how you tested your changes -->
|
||||
|
||||
## Screenshots (if applicable)
|
||||
<!-- Add screenshots here -->
|
||||
|
||||
## Checklist
|
||||
- [ ] I have read the [Contributing Guide](../CONTRIBUTING.md)
|
||||
- [ ] I have announced my intent to work on this and received approval
|
||||
- [ ] My code follows the project's code standards
|
||||
- [ ] I have tested my changes thoroughly
|
||||
- [ ] I have updated relevant documentation
|
||||
30
.github/actions/vault-secrets/action.yml
vendored
Normal file
30
.github/actions/vault-secrets/action.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: 'Get Vault Secrets'
|
||||
description: 'Retrieves secrets from HashiCorp Vault using AppRole authentication'
|
||||
inputs:
|
||||
vault_host:
|
||||
description: 'Vault server URL'
|
||||
required: true
|
||||
vault_role_id:
|
||||
description: 'Vault AppRole Role ID'
|
||||
required: true
|
||||
vault_secret_id:
|
||||
description: 'Vault AppRole Secret ID'
|
||||
required: true
|
||||
secrets:
|
||||
description: 'Secrets to retrieve (multiline string, one per line in format: path | output_name)'
|
||||
required: true
|
||||
default: |
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ inputs.vault_host }}
|
||||
method: approle
|
||||
roleId: ${{ inputs.vault_role_id }}
|
||||
secretId: ${{ inputs.vault_secret_id }}
|
||||
secrets: ${{ inputs.secrets }}
|
||||
166
.github/workflows/build-docker.yml
vendored
Normal file
166
.github/workflows/build-docker.yml
vendored
Normal file
@@ -0,0 +1,166 @@
|
||||
name: Build Docker Images
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/**'
|
||||
workflow_call:
|
||||
inputs:
|
||||
push_docker:
|
||||
description: 'Push Docker image to registry'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build_app:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Set github context
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
echo 'githubRepository=${{ github.repository }}' >> $GITHUB_ENV
|
||||
echo 'githubSha=${{ github.sha }}' >> $GITHUB_ENV
|
||||
echo 'githubRef=${{ github.ref }}' >> $GITHUB_ENV
|
||||
echo 'githubHeadRef=${{ github.head_ref }}' >> $GITHUB_ENV
|
||||
|
||||
- name: Initialize build info
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
githubHeadRef=${{ env.githubHeadRef }}
|
||||
inputVersion="${{ inputs.app_version }}"
|
||||
latestDockerTag=""
|
||||
versionDockerTag=""
|
||||
majorVersionDockerTag=""
|
||||
minorVersionDockerTag=""
|
||||
version="0.0.1"
|
||||
|
||||
if [[ -n "$inputVersion" ]]; then
|
||||
# Version provided via input (manual release)
|
||||
branch="main"
|
||||
latestDockerTag="latest"
|
||||
versionDockerTag="$inputVersion"
|
||||
version="$inputVersion"
|
||||
|
||||
# Extract major and minor versions for additional tags
|
||||
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
|
||||
majorVersionDockerTag="${BASH_REMATCH[1]}"
|
||||
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
fi
|
||||
elif [[ "$githubRef" =~ ^"refs/tags/" ]]; then
|
||||
# Tag push
|
||||
branch=${githubRef##*/}
|
||||
latestDockerTag="latest"
|
||||
versionDockerTag=${branch#v}
|
||||
version=${branch#v}
|
||||
|
||||
# Extract major and minor versions for additional tags
|
||||
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
|
||||
majorVersionDockerTag="${BASH_REMATCH[1]}"
|
||||
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
fi
|
||||
else
|
||||
# Determine if this run is for the main branch or another branch
|
||||
if [[ -z "$githubHeadRef" ]]; then
|
||||
# Main branch
|
||||
githubRef=${{ env.githubRef }}
|
||||
branch=${githubRef##*/}
|
||||
versionDockerTag="$branch"
|
||||
else
|
||||
# Pull request
|
||||
branch=$githubHeadRef
|
||||
versionDockerTag="$branch"
|
||||
fi
|
||||
fi
|
||||
|
||||
githubTags=""
|
||||
|
||||
if [ -n "$latestDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$latestDockerTag"
|
||||
fi
|
||||
if [ -n "$versionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$versionDockerTag"
|
||||
fi
|
||||
if [ -n "$minorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$minorVersionDockerTag"
|
||||
fi
|
||||
if [ -n "$majorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$majorVersionDockerTag"
|
||||
fi
|
||||
|
||||
# set env vars
|
||||
echo "branch=$branch" >> $GITHUB_ENV
|
||||
echo "githubTags=$githubTags" >> $GITHUB_ENV
|
||||
echo "versionDockerTag=$versionDockerTag" >> $GITHUB_ENV
|
||||
echo "version=$version" >> $GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/docker username | DOCKER_USERNAME;
|
||||
secrets/data/docker password | DOCKER_PASSWORD;
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout target repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ env.branch }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push docker image
|
||||
id: docker-build
|
||||
timeout-minutes: 15
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ github.workspace }}/code
|
||||
file: ${{ github.workspace }}/code/Dockerfile
|
||||
provenance: false
|
||||
labels: |
|
||||
commit=sha-${{ env.githubSha }}
|
||||
version=${{ env.versionDockerTag }}
|
||||
build-args: |
|
||||
VERSION=${{ env.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
push: ${{ github.event_name == 'pull_request' || inputs.push_docker == true }}
|
||||
tags: |
|
||||
${{ env.githubTags }}
|
||||
# Enable BuildKit cache for faster builds
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
126
.github/workflows/build-executable.yml
vendored
Normal file
126
.github/workflows/build-executable.yml
vendored
Normal file
@@ -0,0 +1,126 @@
|
||||
name: Build Executables
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
# Build for each platform in parallel using matrix strategy
|
||||
build-platform:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- runtime: win-x64
|
||||
platform: win-amd64
|
||||
- runtime: linux-x64
|
||||
platform: linux-amd64
|
||||
- runtime: linux-arm64
|
||||
platform: linux-arm64
|
||||
- runtime: osx-x64
|
||||
platform: osx-amd64
|
||||
- runtime: osx-arm64
|
||||
platform: osx-arm64
|
||||
|
||||
steps:
|
||||
- name: Set variables
|
||||
run: |
|
||||
ref=${{ github.ref }}
|
||||
|
||||
# Use input version if provided, otherwise determine from ref
|
||||
if [[ -n "${{ inputs.app_version }}" ]]; then
|
||||
appVersion="${{ inputs.app_version }}"
|
||||
releaseVersion="v$appVersion"
|
||||
elif [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
releaseVersion=${ref##refs/tags/}
|
||||
appVersion=${releaseVersion#v}
|
||||
else
|
||||
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
|
||||
appVersion="0.0.1-dev"
|
||||
fi
|
||||
|
||||
repoFullName=${{ github.repository }}
|
||||
repositoryName=${repoFullName#*/}
|
||||
|
||||
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
|
||||
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
|
||||
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
|
||||
echo "appVersion=$appVersion" >> $GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout target repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nuget-
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Install dependencies and restore
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
|
||||
|
||||
- name: Copy frontend to backend wwwroot
|
||||
run: |
|
||||
mkdir -p code/backend/${{ env.executableName }}/wwwroot
|
||||
cp -r code/frontend/dist/ui/browser/* code/backend/${{ env.executableName }}/wwwroot/
|
||||
|
||||
- name: Build ${{ matrix.platform }}
|
||||
run: |
|
||||
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
|
||||
-c Release \
|
||||
--runtime ${{ matrix.runtime }} \
|
||||
--self-contained \
|
||||
-o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }} \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:Version=${{ env.appVersion }} \
|
||||
/p:DebugSymbols=false
|
||||
|
||||
- name: Zip artifact
|
||||
run: |
|
||||
cd ./artifacts
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}/
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: executable-${{ matrix.platform }}
|
||||
path: ./artifacts/*.zip
|
||||
retention-days: 30
|
||||
46
.github/workflows/build-frontend.yml
vendored
Normal file
46
.github/workflows/build-frontend.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Build Frontend
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: code/frontend/package-lock.json
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd code/frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Upload frontend artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
retention-days: 1
|
||||
376
.github/workflows/build-macos-installer.yml
vendored
Normal file
376
.github/workflows/build-macos-installer.yml
vendored
Normal file
@@ -0,0 +1,376 @@
|
||||
name: Build macOS Installers
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build-macos-installer:
|
||||
name: Build macOS ${{ matrix.arch }} Installer
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: Intel
|
||||
runner: macos-13
|
||||
runtime: osx-x64
|
||||
min_os_version: "10.15"
|
||||
artifact_suffix: intel
|
||||
- arch: ARM
|
||||
runner: macos-14
|
||||
runtime: osx-arm64
|
||||
min_os_version: "11.0"
|
||||
artifact_suffix: arm64
|
||||
|
||||
steps:
|
||||
- name: Set variables
|
||||
run: |
|
||||
repoFullName=${{ github.repository }}
|
||||
ref=${{ github.ref }}
|
||||
|
||||
# Use input version if provided, otherwise determine from ref
|
||||
if [[ -n "${{ inputs.app_version }}" ]]; then
|
||||
appVersion="${{ inputs.app_version }}"
|
||||
releaseVersion="v$appVersion"
|
||||
elif [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
releaseVersion=${ref##refs/tags/}
|
||||
appVersion=${releaseVersion#v}
|
||||
else
|
||||
# For manual dispatch, use a default version
|
||||
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
|
||||
appVersion="0.0.1-dev"
|
||||
fi
|
||||
|
||||
repositoryName=${repoFullName#*/}
|
||||
|
||||
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
|
||||
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
|
||||
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
|
||||
echo "appVersion=$appVersion" >> $GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
|
||||
|
||||
- name: Build macOS ${{ matrix.arch }} executable
|
||||
run: |
|
||||
# Clean any existing output directory
|
||||
rm -rf dist
|
||||
mkdir -p dist/temp
|
||||
|
||||
# Build to a temporary location
|
||||
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
|
||||
-c Release \
|
||||
--runtime ${{ matrix.runtime }} \
|
||||
--self-contained true \
|
||||
-o dist/temp \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:Version=${{ env.appVersion }} \
|
||||
/p:DebugType=None \
|
||||
/p:DebugSymbols=false \
|
||||
/p:UseAppHost=true \
|
||||
/p:EnableMacOSCodeSign=false \
|
||||
/p:CodeSignOnCopy=false \
|
||||
/p:_CodeSignDuringBuild=false \
|
||||
/p:PublishTrimmed=false \
|
||||
/p:TrimMode=link
|
||||
|
||||
# Create proper app bundle structure
|
||||
mkdir -p dist/Cleanuparr.app/Contents/MacOS
|
||||
|
||||
# Copy the built executable (note: AssemblyName is "Cleanuparr" not "Cleanuparr.Api")
|
||||
cp dist/temp/Cleanuparr dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
|
||||
# Copy frontend directly to where it belongs in the app bundle
|
||||
mkdir -p dist/Cleanuparr.app/Contents/MacOS/wwwroot
|
||||
cp -r code/frontend/dist/ui/browser/* dist/Cleanuparr.app/Contents/MacOS/wwwroot/
|
||||
|
||||
# Copy any additional runtime files if they exist
|
||||
if [ -d "dist/temp" ]; then
|
||||
find dist/temp -name "*.dylib" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
|
||||
find dist/temp -name "createdump" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Post-build setup
|
||||
run: |
|
||||
# Make sure the executable is actually executable
|
||||
chmod +x dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
|
||||
# Remove any .pdb files that might have been created
|
||||
find dist/Cleanuparr.app/Contents/MacOS -name "*.pdb" -delete 2>/dev/null || true
|
||||
|
||||
echo "Checking architecture of built binary:"
|
||||
file dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
if command -v lipo >/dev/null 2>&1; then
|
||||
lipo -info dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
fi
|
||||
|
||||
echo "Files in MacOS directory:"
|
||||
ls -la dist/Cleanuparr.app/Contents/MacOS/
|
||||
|
||||
- name: Create macOS app bundle structure
|
||||
run: |
|
||||
# Create proper app bundle structure
|
||||
mkdir -p dist/Cleanuparr.app/Contents/{MacOS,Resources,Frameworks}
|
||||
|
||||
# Convert ICO to ICNS for macOS app bundle
|
||||
if command -v iconutil >/dev/null 2>&1; then
|
||||
# Create iconset directory structure
|
||||
mkdir -p Cleanuparr.iconset
|
||||
|
||||
# Use existing PNG files from Logo directory for different sizes
|
||||
cp Logo/16.png Cleanuparr.iconset/icon_16x16.png
|
||||
cp Logo/32.png Cleanuparr.iconset/icon_16x16@2x.png
|
||||
cp Logo/32.png Cleanuparr.iconset/icon_32x32.png
|
||||
cp Logo/64.png Cleanuparr.iconset/icon_32x32@2x.png
|
||||
cp Logo/128.png Cleanuparr.iconset/icon_128x128.png
|
||||
cp Logo/256.png Cleanuparr.iconset/icon_128x128@2x.png
|
||||
cp Logo/256.png Cleanuparr.iconset/icon_256x256.png
|
||||
cp Logo/512.png Cleanuparr.iconset/icon_256x256@2x.png
|
||||
cp Logo/512.png Cleanuparr.iconset/icon_512x512.png
|
||||
cp Logo/1024.png Cleanuparr.iconset/icon_512x512@2x.png
|
||||
|
||||
# Create ICNS file
|
||||
iconutil -c icns Cleanuparr.iconset -o dist/Cleanuparr.app/Contents/Resources/Cleanuparr.icns
|
||||
|
||||
# Clean up iconset directory
|
||||
rm -rf Cleanuparr.iconset
|
||||
fi
|
||||
|
||||
# Create Launch Daemon plist
|
||||
cat > dist/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.cleanuparr.daemon</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/var/log/cleanuparr.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/var/log/cleanuparr.error.log</string>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Applications/Cleanuparr.app/Contents/MacOS</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>HTTP_PORTS</key>
|
||||
<string>11011</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
# Create Info.plist with proper configuration
|
||||
cat > dist/Cleanuparr.app/Contents/Info.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Cleanuparr</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.Cleanuparr</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Cleanuparr</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Cleanuparr</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${{ env.appVersion }}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${{ env.appVersion }}</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>CLNR</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Cleanuparr</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSRequiresAquaSystemAppearance</key>
|
||||
<false/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>${{ matrix.min_os_version }}</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.productivity</string>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
<key>NSSupportsSuddenTermination</key>
|
||||
<false/>
|
||||
<key>LSBackgroundOnly</key>
|
||||
<false/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf dist/temp
|
||||
|
||||
- name: Create PKG installer
|
||||
run: |
|
||||
# Create preinstall script to handle existing installations
|
||||
mkdir -p scripts
|
||||
cat > scripts/preinstall << 'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
# Stop and unload existing launch daemon if it exists
|
||||
if launchctl list | grep -q "com.cleanuparr.daemon"; then
|
||||
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
|
||||
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Stop any running instances of Cleanuparr
|
||||
pkill -f "Cleanuparr" || true
|
||||
sleep 2
|
||||
|
||||
# Remove old installation if it exists
|
||||
if [[ -d "/Applications/Cleanuparr.app" ]]; then
|
||||
rm -rf "/Applications/Cleanuparr.app"
|
||||
fi
|
||||
|
||||
# Remove old launch daemon plist if it exists
|
||||
if [[ -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist" ]]; then
|
||||
rm -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
chmod +x scripts/preinstall
|
||||
|
||||
# Create postinstall script
|
||||
cat > scripts/postinstall << 'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
# Set proper permissions for the app bundle
|
||||
chmod -R 755 /Applications/Cleanuparr.app
|
||||
chmod +x /Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
|
||||
# Install the launch daemon
|
||||
cp /Applications/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist /Library/LaunchDaemons/
|
||||
chown root:wheel /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
chmod 644 /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
|
||||
# Load and start the service
|
||||
launchctl load /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
launchctl start com.cleanuparr.daemon
|
||||
|
||||
# Wait a moment for service to start
|
||||
sleep 3
|
||||
|
||||
# Display as system notification
|
||||
osascript -e 'display notification "Cleanuparr service started! Visit http://localhost:11011 in your browser." with title "Installation Complete"' 2>/dev/null || true
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
chmod +x scripts/postinstall
|
||||
|
||||
# Create uninstall script (optional, for user reference)
|
||||
cat > scripts/uninstall_cleanuparr.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Cleanuparr Uninstall Script
|
||||
# Run this script with sudo to completely remove Cleanuparr
|
||||
|
||||
echo "Stopping Cleanuparr service..."
|
||||
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
|
||||
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
|
||||
|
||||
echo "Removing service files..."
|
||||
rm -f /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
|
||||
echo "Removing application..."
|
||||
rm -rf /Applications/Cleanuparr.app
|
||||
|
||||
echo "Removing logs..."
|
||||
rm -f /var/log/cleanuparr.log
|
||||
rm -f /var/log/cleanuparr.error.log
|
||||
|
||||
echo "Cleanuparr has been completely removed."
|
||||
echo "Note: Configuration files in /Applications/Cleanuparr.app/Contents/MacOS/config/ have been removed with the app."
|
||||
EOF
|
||||
|
||||
chmod +x scripts/uninstall_cleanuparr.sh
|
||||
|
||||
# Copy uninstall script to app bundle for user access
|
||||
cp scripts/uninstall_cleanuparr.sh dist/Cleanuparr.app/Contents/Resources/
|
||||
|
||||
# Determine package name - if app_version input was provided, it's a release build
|
||||
if [[ -n "${{ inputs.app_version }}" ]] || [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}.pkg"
|
||||
else
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}-dev.pkg"
|
||||
fi
|
||||
|
||||
# Create PKG installer with better metadata
|
||||
pkgbuild --root dist/ \
|
||||
--scripts scripts/ \
|
||||
--identifier com.Cleanuparr \
|
||||
--version ${{ env.appVersion }} \
|
||||
--install-location /Applications \
|
||||
--ownership preserve \
|
||||
${pkg_name}
|
||||
|
||||
echo "pkgName=${pkg_name}" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload installer as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Cleanuparr-macos-${{ matrix.artifact_suffix }}-installer
|
||||
path: '${{ env.pkgName }}'
|
||||
retention-days: 30
|
||||
147
.github/workflows/build-windows-installer.yml
vendored
Normal file
147
.github/workflows/build-windows-installer.yml
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
name: Build Windows Installer
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build-windows-installer:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Set variables
|
||||
shell: pwsh
|
||||
run: |
|
||||
$repoFullName = "${{ github.repository }}"
|
||||
$ref = "${{ github.ref }}"
|
||||
$inputVersion = "${{ inputs.app_version }}"
|
||||
|
||||
# Use input version if provided, otherwise determine from ref
|
||||
if ($inputVersion -ne "") {
|
||||
$appVersion = $inputVersion
|
||||
$releaseVersion = "v$appVersion"
|
||||
} elseif ($ref -match "^refs/tags/") {
|
||||
$releaseVersion = $ref -replace "refs/tags/", ""
|
||||
$appVersion = $releaseVersion -replace "^v", ""
|
||||
} else {
|
||||
# For manual dispatch, use a default version
|
||||
$releaseVersion = "dev-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
$appVersion = "0.0.1-dev"
|
||||
}
|
||||
|
||||
$repositoryName = $repoFullName.Split("/")[1]
|
||||
|
||||
echo "githubRepository=${{ github.repository }}" >> $env:GITHUB_ENV
|
||||
echo "githubRepositoryName=$repositoryName" >> $env:GITHUB_ENV
|
||||
echo "releaseVersion=$releaseVersion" >> $env:GITHUB_ENV
|
||||
echo "appVersion=$appVersion" >> $env:GITHUB_ENV
|
||||
echo "APP_VERSION=$appVersion" >> $env:GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
|
||||
|
||||
- name: Copy frontend to backend wwwroot
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path "code/backend/${{ env.executableName }}/wwwroot"
|
||||
Copy-Item -Path "code/frontend/dist/ui/browser/*" -Destination "code/backend/${{ env.executableName }}/wwwroot/" -Recurse -Force
|
||||
|
||||
- name: Build Windows executable
|
||||
run: |
|
||||
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o dist /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugType=None /p:DebugSymbols=false
|
||||
|
||||
- name: Setup Inno Setup
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Download and install Inno Setup
|
||||
$url = "https://jrsoftware.org/download.php/is.exe"
|
||||
$output = "innosetup-installer.exe"
|
||||
|
||||
Invoke-WebRequest -Uri $url -OutFile $output
|
||||
Start-Process -FilePath $output -ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART" -Wait
|
||||
|
||||
# Add Inno Setup to PATH
|
||||
$innoPath = "C:\Program Files (x86)\Inno Setup 6"
|
||||
echo "$innoPath" >> $env:GITHUB_PATH
|
||||
|
||||
- name: Verify LICENSE file exists
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path "LICENSE")) {
|
||||
Write-Error "LICENSE file not found in repository root"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "LICENSE file found successfully"
|
||||
|
||||
- name: Build Windows installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Copy installer script to root
|
||||
Copy-Item "installers/windows/cleanuparr-installer.iss" -Destination "cleanuparr-installer.iss"
|
||||
|
||||
# The installer script has been pre-updated with proper icon and config paths
|
||||
# No dynamic modifications needed as the base script now includes correct references
|
||||
|
||||
# Run Inno Setup compiler
|
||||
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "cleanuparr-installer.iss"
|
||||
|
||||
# Check if installer was created
|
||||
if (Test-Path "installer/Cleanuparr_Setup.exe") {
|
||||
Write-Host "Installer created successfully"
|
||||
} else {
|
||||
Write-Error "Installer creation failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Rename installer with version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installerName = "Cleanuparr-${{ env.appVersion }}-Setup.exe"
|
||||
Move-Item "installer/Cleanuparr_Setup.exe" "installer/$installerName"
|
||||
echo "installerName=$installerName" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Upload installer artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Cleanuparr-windows-installer
|
||||
path: installer/${{ env.installerName }}
|
||||
retention-days: 30
|
||||
|
||||
# Removed individual release step - handled by main release workflow
|
||||
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
@@ -1,12 +0,0 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: flmorg/universal-workflows/.github/workflows/dotnet.build.app.yml@main
|
||||
with:
|
||||
dockerRepository: flaminel/cleanuperr
|
||||
githubContext: ${{ toJSON(github) }}
|
||||
outputName: cleanuperr
|
||||
secrets: inherit
|
||||
36
.github/workflows/cloudflare-pages-blocklists.yml
vendored
Normal file
36
.github/workflows/cloudflare-pages-blocklists.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'Cloudflare/**'
|
||||
- 'blacklist'
|
||||
- 'blacklist_permissive'
|
||||
- 'whitelist'
|
||||
- 'whitelist_with_subtitles'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Copy root static files to Cloudflare static directory
|
||||
run: |
|
||||
cp blacklist Cloudflare/static/
|
||||
cp blacklist_permissive Cloudflare/static/
|
||||
cp whitelist Cloudflare/static/
|
||||
cp whitelist_with_subtitles Cloudflare/static/
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
workingDirectory: "Cloudflare"
|
||||
command: pages deploy . --project-name=cleanuparr
|
||||
30
.github/workflows/cloudflare-pages-status.yml
vendored
Normal file
30
.github/workflows/cloudflare-pages-status.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
steps:
|
||||
- name: Create status files
|
||||
run: |
|
||||
mkdir -p status
|
||||
echo "{ \"version\": \"${GITHUB_REF_NAME}\" }" > status/status.json
|
||||
|
||||
# Cache static files for 10 minutes
|
||||
cat > status/_headers << 'EOF'
|
||||
/*
|
||||
Cache-Control: public, max-age=600, s-maxage=600
|
||||
EOF
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
workingDirectory: "status"
|
||||
command: pages deploy . --project-name=cleanuparr-status
|
||||
45
.github/workflows/dependency-review.yml
vendored
Normal file
45
.github/workflows/dependency-review.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: Dependency Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@v4
|
||||
with:
|
||||
# Fail on critical and high severity vulnerabilities
|
||||
fail-on-severity: high
|
||||
# Warn on moderate vulnerabilities
|
||||
warn-on-severity: moderate
|
||||
# Allow licenses
|
||||
# allow-licenses: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD
|
||||
# Comment summarizes the vulnerabilities found
|
||||
comment-summary-in-pr: on-failure
|
||||
# Show dependency changes in PR
|
||||
show-openssf-scorecard: true
|
||||
vulnerability-check: true
|
||||
|
||||
- name: Upload dependency review results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dependency-review-results
|
||||
path: dependency-review-*.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
19
.github/workflows/deploy.yml
vendored
19
.github/workflows/deploy.yml
vendored
@@ -1,19 +0,0 @@
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- 'chart/**'
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
uses: flmorg/universal-workflows/.github/workflows/chart.install.yml@main
|
||||
with:
|
||||
githubContext: ${{ toJSON(github) }}
|
||||
chartRepo: oci://ghcr.io/flmorg
|
||||
chartName: universal-chart
|
||||
version: ^1.0.0
|
||||
valuesPath: chart/values.yaml
|
||||
releaseName: main
|
||||
secrets: inherit
|
||||
50
.github/workflows/docs.yml
vendored
Normal file
50
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Deploy Docusaurus to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch: {}
|
||||
|
||||
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
|
||||
ref: main
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.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
|
||||
20
.github/workflows/pipeline.yml
vendored
20
.github/workflows/pipeline.yml
vendored
@@ -1,20 +0,0 @@
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
# paths:
|
||||
# - 'code/**'
|
||||
# branches: [ main ]
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: flmorg/cleanuperr/.github/workflows/build.yml@main
|
||||
secrets: inherit
|
||||
|
||||
# deploy:
|
||||
# needs: [ build ]
|
||||
# uses: flmorg/cleanuperr/.github/workflows/deploy.yml@main
|
||||
# secrets: inherit
|
||||
284
.github/workflows/release.yml
vendored
284
.github/workflows/release.yml
vendored
@@ -1,11 +1,287 @@
|
||||
name: Release Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (e.g., 1.0.0)'
|
||||
required: true
|
||||
runTests:
|
||||
description: 'Run test suite'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
buildDocker:
|
||||
description: 'Build Docker image'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
pushDocker:
|
||||
description: 'Push Docker image to registry'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
buildBinaries:
|
||||
description: 'Build executables and installers'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
createRelease:
|
||||
description: 'Create GitHub release'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: flmorg/universal-workflows/.github/workflows/dotnet.release.yml@main
|
||||
# Validate release
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
app_version: ${{ steps.version.outputs.app_version }}
|
||||
release_version: ${{ steps.version.outputs.release_version }}
|
||||
is_tag: ${{ steps.version.outputs.is_tag }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version info
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
# Tag event
|
||||
release_version=${GITHUB_REF##refs/tags/}
|
||||
app_version=${release_version#v}
|
||||
is_tag=true
|
||||
else
|
||||
# Manual workflow with version
|
||||
app_version="${{ github.event.inputs.version }}"
|
||||
|
||||
# Validate version format (x.x.x)
|
||||
if ! [[ "$app_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: Version must be in format x.x.x (e.g., 1.0.0)"
|
||||
echo "Provided version: $app_version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release_version="v$app_version"
|
||||
is_tag=false
|
||||
fi
|
||||
|
||||
echo "app_version=$app_version" >> $GITHUB_OUTPUT
|
||||
echo "release_version=$release_version" >> $GITHUB_OUTPUT
|
||||
echo "is_tag=$is_tag" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "Release Version: $release_version"
|
||||
echo "App Version: $app_version"
|
||||
echo "Is Tag: $is_tag"
|
||||
|
||||
- name: Check if release already exists
|
||||
run: |
|
||||
if gh release view "${{ steps.version.outputs.release_version }}" &>/dev/null; then
|
||||
echo "❌ Release ${{ steps.version.outputs.release_version }} already exists. Stopping workflow."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Release ${{ steps.version.outputs.release_version }} does not exist. Proceeding."
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
needs: validate
|
||||
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
|
||||
uses: ./.github/workflows/test.yml
|
||||
secrets: inherit
|
||||
|
||||
# Build frontend once for all build jobs and cache it
|
||||
build-frontend:
|
||||
needs: [validate, test]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-frontend.yml
|
||||
secrets: inherit
|
||||
|
||||
# Build portable executables
|
||||
build-executables:
|
||||
needs: [validate, test, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-executable.yml
|
||||
with:
|
||||
githubContext: ${{ toJSON(github) }}
|
||||
secrets: inherit
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Build Windows installer
|
||||
build-windows-installer:
|
||||
needs: [validate, test, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-windows-installer.yml
|
||||
with:
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Build macOS installers (Intel and ARM)
|
||||
build-macos:
|
||||
needs: [validate, test, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-macos-installer.yml
|
||||
with:
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Build and push Docker image(s)
|
||||
build-docker:
|
||||
needs: [validate, test]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildDocker == 'true')
|
||||
uses: ./.github/workflows/build-docker.yml
|
||||
with:
|
||||
push_docker: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.pushDocker == 'true' }}
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Create GitHub release
|
||||
create-release:
|
||||
needs: [validate, build-executables, build-windows-installer, build-macos]
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
needs.build-executables.result == 'success' &&
|
||||
needs.build-windows-installer.result == 'success' &&
|
||||
needs.build-macos.result == 'success' &&
|
||||
(
|
||||
needs.validate.outputs.is_tag == 'true' ||
|
||||
(github.event.inputs.createRelease == 'true' && github.event.inputs.buildBinaries == 'true')
|
||||
)
|
||||
|
||||
steps:
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||
|
||||
- name: Download executable artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: executable-*
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Windows installer
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: Cleanuparr-windows-installer
|
||||
path: ./artifacts
|
||||
|
||||
- name: Download macOS installers
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: Cleanuparr-macos-*-installer
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: List downloaded artifacts
|
||||
run: |
|
||||
echo "Downloaded artifacts:"
|
||||
find ./artifacts -type f \( -name "*.zip" -o -name "*.pkg" -o -name "*.exe" \) | sort
|
||||
echo ""
|
||||
echo "Total files: $(find ./artifacts -type f \( -name "*.zip" -o -name "*.pkg" -o -name "*.exe" \) | wc -l)"
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ needs.validate.outputs.release_version }}
|
||||
tag_name: ${{ needs.validate.outputs.release_version }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
make_latest: true
|
||||
target_commitish: main
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
./artifacts/*.zip
|
||||
./artifacts/*.pkg
|
||||
./artifacts/*.exe
|
||||
|
||||
# Summary job
|
||||
summary:
|
||||
needs: [validate, test, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Record workflow start time
|
||||
id: workflow-start
|
||||
run: |
|
||||
# Get workflow start time from GitHub API
|
||||
workflow_start=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }} --jq '.run_started_at')
|
||||
start_epoch=$(date -d "$workflow_start" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$workflow_start" +%s)
|
||||
echo "start=$start_epoch" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
# Calculate total workflow duration
|
||||
start_time=${{ steps.workflow-start.outputs.start }}
|
||||
end_time=$(date +%s)
|
||||
duration=$((end_time - start_time))
|
||||
minutes=$((duration / 60))
|
||||
seconds=$((duration % 60))
|
||||
echo "## 🏗️ Cleanuparr Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version**: ${{ needs.validate.outputs.release_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**App Version**: ${{ needs.validate.outputs.app_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Is Tag**: ${{ needs.validate.outputs.is_tag }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total Duration**: ${minutes}m ${seconds}s" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Build Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Helper function to print job result
|
||||
print_result() {
|
||||
local name="$1"
|
||||
local result="$2"
|
||||
case "$result" in
|
||||
success) echo "✅ **$name**: Success" >> $GITHUB_STEP_SUMMARY ;;
|
||||
skipped) echo "⏭️ **$name**: Skipped" >> $GITHUB_STEP_SUMMARY ;;
|
||||
*) echo "❌ **$name**: $result" >> $GITHUB_STEP_SUMMARY ;;
|
||||
esac
|
||||
}
|
||||
|
||||
print_result "Tests" "${{ needs.test.result }}"
|
||||
print_result "Frontend Build" "${{ needs.build-frontend.result }}"
|
||||
print_result "Portable Executables" "${{ needs.build-executables.result }}"
|
||||
print_result "Windows Installer" "${{ needs.build-windows-installer.result }}"
|
||||
print_result "macOS Installers (Intel & ARM)" "${{ needs.build-macos.result }}"
|
||||
print_result "Docker Image Build" "${{ needs.build-docker.result }}"
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "🎉 **Build completed!**" >> $GITHUB_STEP_SUMMARY
|
||||
99
.github/workflows/test.yml
vendored
Normal file
99
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'code/backend/**'
|
||||
- '.github/workflows/test.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/backend/**'
|
||||
- '.github/workflows/test.yml'
|
||||
workflow_call:
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nuget-
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Restore dependencies
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/cleanuparr.sln
|
||||
|
||||
- name: Build solution
|
||||
run: dotnet build code/backend/cleanuparr.sln --configuration Release --no-restore
|
||||
|
||||
- name: Run tests
|
||||
id: run-tests
|
||||
run: dotnet test code/backend/cleanuparr.sln --configuration Release --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" --settings code/backend/coverage.runsettings --results-directory ./coverage
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results
|
||||
path: ./coverage/*.trx
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: ./coverage/**/coverage.cobertura.xml
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: ./coverage/**/coverage.cobertura.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
flags: backend
|
||||
name: backend-coverage
|
||||
|
||||
- name: Test Summary
|
||||
run: |
|
||||
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ steps.run-tests.outcome }}" == "success" ]; then
|
||||
echo "✅ All tests passed!" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ Tests failed or were cancelled. Status: ${{ steps.run-tests.outcome }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Test artifacts have been uploaded for detailed analysis." >> $GITHUB_STEP_SUMMARY
|
||||
66
.github/workflows/version-info.yml
vendored
Normal file
66
.github/workflows/version-info.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
name: Get Version Info
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
manual_version:
|
||||
description: 'Manual version override (e.g., 1.0.0)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
outputs:
|
||||
app_version:
|
||||
description: 'Application version (without v prefix)'
|
||||
value: ${{ jobs.version.outputs.app_version }}
|
||||
release_version:
|
||||
description: 'Release version (with v prefix)'
|
||||
value: ${{ jobs.version.outputs.release_version }}
|
||||
is_tag:
|
||||
description: 'Whether this is a tag event'
|
||||
value: ${{ jobs.version.outputs.is_tag }}
|
||||
repository_name:
|
||||
description: 'Repository name without owner'
|
||||
value: ${{ jobs.version.outputs.repository_name }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
app_version: ${{ steps.version.outputs.app_version }}
|
||||
release_version: ${{ steps.version.outputs.release_version }}
|
||||
is_tag: ${{ steps.version.outputs.is_tag }}
|
||||
repository_name: ${{ steps.version.outputs.repository_name }}
|
||||
|
||||
steps:
|
||||
- name: Calculate version info
|
||||
id: version
|
||||
run: |
|
||||
repoFullName="${{ github.repository }}"
|
||||
repositoryName="${repoFullName#*/}"
|
||||
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
# Tag event
|
||||
release_version="${GITHUB_REF##refs/tags/}"
|
||||
app_version="${release_version#v}"
|
||||
is_tag="true"
|
||||
elif [[ -n "${{ inputs.manual_version }}" ]]; then
|
||||
# Manual workflow with version
|
||||
app_version="${{ inputs.manual_version }}"
|
||||
release_version="v${app_version}"
|
||||
is_tag="false"
|
||||
else
|
||||
# Development build
|
||||
app_version="0.0.1-dev-$(date +%Y%m%d-%H%M%S)"
|
||||
release_version="v${app_version}"
|
||||
is_tag="false"
|
||||
fi
|
||||
|
||||
echo "app_version=${app_version}" >> $GITHUB_OUTPUT
|
||||
echo "release_version=${release_version}" >> $GITHUB_OUTPUT
|
||||
echo "is_tag=${is_tag}" >> $GITHUB_OUTPUT
|
||||
echo "repository_name=${repositoryName}" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "📦 Repository: ${repositoryName}"
|
||||
echo "🏷️ Release Version: ${release_version}"
|
||||
echo "📱 App Version: ${app_version}"
|
||||
echo "🔖 Is Tag: ${is_tag}"
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -105,7 +105,6 @@ _NCrunch_*
|
||||
_TeamCity*
|
||||
|
||||
# Sonarr
|
||||
config.xml
|
||||
nzbdrone.log*txt
|
||||
UpdateLogs/
|
||||
*workspace.xml
|
||||
|
||||
325
CONTRIBUTING.md
Normal file
325
CONTRIBUTING.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# Contributing to Cleanuparr
|
||||
|
||||
Thanks for your interest in contributing to Cleanuparr! This guide will help you get started with development.
|
||||
|
||||
## Before You Start
|
||||
|
||||
### Announce Your Intent
|
||||
|
||||
Before starting any work, please let us know what you want to contribute:
|
||||
|
||||
- For existing issues: Comment on the issue stating you'd like to work on it
|
||||
- For new features/changes: Create a new issue first and mention that you want to work on it
|
||||
|
||||
This helps us avoid redundant work, git conflicts, and contributions that may not align with the project's direction.
|
||||
|
||||
**Wait for approval from the maintainers before proceeding with your contribution.**
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)
|
||||
- [Node.js 18+](https://nodejs.org/)
|
||||
- [Git](https://git-scm.com/)
|
||||
- (Optional) [Make](https://www.gnu.org/software/make/) for database migrations
|
||||
- (Optional) IDE: [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio](https://visualstudio.microsoft.com/)
|
||||
|
||||
### Repository Setup
|
||||
|
||||
1. Fork the repository on GitHub
|
||||
2. Clone your fork locally:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/Cleanuparr.git
|
||||
cd Cleanuparr
|
||||
```
|
||||
3. Add the upstream repository:
|
||||
```bash
|
||||
git remote add upstream https://github.com/Cleanuparr/Cleanuparr.git
|
||||
```
|
||||
|
||||
## Backend Development
|
||||
|
||||
### Initial Setup
|
||||
|
||||
#### 1. Create a GitHub Personal Access Token (PAT)
|
||||
|
||||
Cleanuparr uses GitHub Packages for NuGet dependencies. You'll need a PAT with `read:packages` permission:
|
||||
|
||||
1. Go to [GitHub Settings > Developer Settings > Personal Access Tokens > Tokens (classic)](https://github.com/settings/tokens)
|
||||
2. Click "Generate new token" → "Generate new token (classic)"
|
||||
3. Give it a descriptive name (e.g., "Cleanuparr NuGet Access")
|
||||
4. Set an expiration (recommend 90 days or longer for development)
|
||||
5. Select only the `read:packages` scope
|
||||
6. Click "Generate token" and copy it
|
||||
|
||||
#### 2. Configure NuGet Source
|
||||
|
||||
Add the Cleanuparr NuGet repository:
|
||||
|
||||
```bash
|
||||
dotnet nuget add source \
|
||||
--username YOUR_GITHUB_USERNAME \
|
||||
--password YOUR_GITHUB_PAT \
|
||||
--store-password-in-clear-text \
|
||||
--name Cleanuparr \
|
||||
https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
```
|
||||
|
||||
Replace `YOUR_GITHUB_USERNAME` and `YOUR_GITHUB_PAT` with your GitHub username and the PAT you created.
|
||||
|
||||
### Running the Backend
|
||||
|
||||
#### Option 1: Using .NET CLI
|
||||
|
||||
Navigate to the backend directory:
|
||||
```bash
|
||||
cd code/backend
|
||||
```
|
||||
|
||||
Build the application:
|
||||
```bash
|
||||
dotnet build Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||
```
|
||||
|
||||
Run the application:
|
||||
```bash
|
||||
dotnet run --project Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||
```
|
||||
|
||||
Run tests:
|
||||
```bash
|
||||
dotnet test
|
||||
```
|
||||
|
||||
The API will be available at http://localhost:5000
|
||||
|
||||
#### Option 2: Using an IDE
|
||||
|
||||
For JetBrains Rider or Visual Studio:
|
||||
1. Open the solution file: `code/backend/cleanuparr.sln`
|
||||
2. Set `Cleanuparr.Api` as the startup project
|
||||
3. Press `F5` to start the application
|
||||
|
||||
### Database Migrations
|
||||
|
||||
Cleanuparr uses two separate database contexts: `DataContext` and `EventsContext`.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
Install Make if not already installed:
|
||||
- Windows: Install via [Chocolatey](https://chocolatey.org/) (`choco install make`) or use [WSL](https://docs.microsoft.com/windows/wsl/)
|
||||
- macOS: Install via Homebrew (`brew install make`)
|
||||
- Linux: Usually pre-installed, or install via package manager (`apt install make`, `yum install make`, etc.)
|
||||
|
||||
#### Creating Migrations
|
||||
|
||||
From the `code` directory:
|
||||
|
||||
For data migrations (DataContext):
|
||||
```bash
|
||||
make migrate-data name=YourMigrationName
|
||||
```
|
||||
|
||||
For events migrations (EventsContext):
|
||||
```bash
|
||||
make migrate-events name=YourMigrationName
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
make migrate-data name=AddUserPreferences
|
||||
make migrate-events name=AddAuditLogEvents
|
||||
```
|
||||
|
||||
## Frontend Development
|
||||
|
||||
### Setup
|
||||
|
||||
1. Navigate to the frontend directory:
|
||||
```bash
|
||||
cd code/frontend
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The UI will be available at http://localhost:4200
|
||||
|
||||
## Documentation Development
|
||||
|
||||
### Setup
|
||||
|
||||
1. Navigate to the docs directory:
|
||||
```bash
|
||||
cd docs
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The documentation site will be available at http://localhost:3000
|
||||
|
||||
## Building with Docker
|
||||
|
||||
### Building a Local Docker Image
|
||||
|
||||
To build the Docker image locally for testing:
|
||||
|
||||
1. Navigate to the `code` directory:
|
||||
```bash
|
||||
cd code
|
||||
```
|
||||
|
||||
2. Build the image:
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||
-t cleanuparr:local \
|
||||
-f Dockerfile .
|
||||
```
|
||||
|
||||
Replace `YOUR_GITHUB_USERNAME` and `YOUR_GITHUB_PAT` with your credentials.
|
||||
|
||||
3. Run the container:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name cleanuparr-dev \
|
||||
-p 11011:11011 \
|
||||
-v /path/to/config:/config \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e TZ=Etc/UTC \
|
||||
cleanuparr:local
|
||||
```
|
||||
|
||||
4. Access the application at http://localhost:11011
|
||||
|
||||
### Building for Multiple Architectures
|
||||
|
||||
Use Docker Buildx for multi-platform builds:
|
||||
|
||||
```bash
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||
-t cleanuparr:local \
|
||||
-f Dockerfile .
|
||||
```
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Backend (.NET/C#)
|
||||
- Follow existing conventions and [Microsoft C# Coding Conventions](https://docs.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
|
||||
- Use meaningful variable and method names
|
||||
- Add XML documentation comments for public APIs
|
||||
- Write unit tests whenever possible
|
||||
|
||||
### Frontend (Angular/TypeScript)
|
||||
- Follow existing conventions and the [Angular Style Guide](https://angular.io/guide/styleguide)
|
||||
- Use TypeScript strict mode
|
||||
- Write unit tests whenever possible
|
||||
|
||||
### Documentation
|
||||
- Use clear, concise language
|
||||
- Include code examples where appropriate
|
||||
- Update relevant documentation when adding/changing features
|
||||
- Check for spelling and grammar
|
||||
|
||||
## Submitting Your Contribution
|
||||
|
||||
### 1. Create a Feature Branch
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
# or
|
||||
git checkout -b fix/your-bug-fix-name
|
||||
```
|
||||
|
||||
### 2. Make Your Changes
|
||||
|
||||
- Write clean, well-documented code
|
||||
- Follow the code standards outlined above
|
||||
- **Test your changes thoroughly!**
|
||||
|
||||
### 3. Commit Your Changes
|
||||
|
||||
Write clear, descriptive commit messages:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add feature: brief description of your changes"
|
||||
```
|
||||
|
||||
### 4. Keep Your Branch Updated
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
### 5. Push to Your Fork
|
||||
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
### 6. Create a Pull Request
|
||||
|
||||
1. Go to the [Cleanuparr repository](https://github.com/Cleanuparr/Cleanuparr)
|
||||
2. Click "New Pull Request"
|
||||
3. Select your fork and branch
|
||||
4. Fill out the PR template with:
|
||||
- A descriptive title (e.g., "Add support for Prowlarr integration" or "Fix memory leak in download client polling")
|
||||
- Description of changes
|
||||
- Related issue number
|
||||
- Testing performed
|
||||
- Screenshots (if applicable)
|
||||
|
||||
### 7. Code Review Process
|
||||
|
||||
- Maintainers will review your PR
|
||||
- Address any feedback or requested changes
|
||||
- Once approved, your PR will be merged
|
||||
|
||||
## Other Ways to Contribute
|
||||
|
||||
### Help Test New Features
|
||||
|
||||
We're always looking for testers to help validate new features before they are released. If you'd like to help test upcoming changes:
|
||||
|
||||
1. Join our [Discord community](https://discord.gg/SCtMCgtsc4)
|
||||
2. Let us know you're interested in testing
|
||||
3. We'll provide you with pre-release builds and testing instructions
|
||||
|
||||
Your feedback helps us catch issues early and deliver better releases.
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Discord: Join our [Discord community](https://discord.gg/SCtMCgtsc4) for real-time help
|
||||
- Issues: Check existing [GitHub issues](https://github.com/Cleanuparr/Cleanuparr/issues) or create a new one
|
||||
- Documentation: Review the [complete documentation](https://cleanuparr.github.io/Cleanuparr/)
|
||||
|
||||
## License
|
||||
|
||||
By contributing to Cleanuparr, you agree that your contributions will be licensed under the same license as the project.
|
||||
|
||||
---
|
||||
|
||||
Thanks for contributing to Cleanuparr!
|
||||
3
Cloudflare/_headers
Normal file
3
Cloudflare/_headers
Normal file
@@ -0,0 +1,3 @@
|
||||
# Cache static files for 5 minutes
|
||||
/static/*
|
||||
Cache-Control: public, max-age=300, s-maxage=300
|
||||
2
Cloudflare/static/known_malware_file_name_patterns
Normal file
2
Cloudflare/static/known_malware_file_name_patterns
Normal file
@@ -0,0 +1,2 @@
|
||||
thepirateheaven.org
|
||||
RARBG.work
|
||||
BIN
Logo/favicon.ico
Normal file
BIN
Logo/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
10
Logo/logo.svg
Normal file
10
Logo/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 112 KiB |
266
README.md
266
README.md
@@ -1,219 +1,119 @@
|
||||
# <img width="24px" src="./Logo/256.png" alt="cleanuperr"></img> cleanuperr
|
||||
_Love this project? Give it a ⭐️ and let others know!_
|
||||
|
||||
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.
|
||||
# <img width="24px" src="./Logo/256.png" alt="Cleanuparr"></img> Cleanuparr
|
||||
|
||||
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/).
|
||||

|
||||

|
||||
[](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml)
|
||||
|
||||
The tool supports both qBittorrent's built-in exclusion features and its own blocklist-based system. Binaries for all platforms are provided, along with Docker images for easy deployment.
|
||||
|
||||
Refer to the [Environment variables](#Environment-variables) section for detailed configuration instructions and the [Setup](#Setup) section for an in-depth explanation of the cleanup process.
|
||||
[](https://discord.gg/SCtMCgtsc4)
|
||||
|
||||
## Key features
|
||||
- Marks unwanted files as skip/unwanted in the download client.
|
||||
- Automatically strikes stalled or stuck downloads.
|
||||
- Removes and blocks downloads that reached the maximum number of strikes or are marked as unwanted by the download client or by cleanuperr and triggers a search for removed downloads.
|
||||
Cleanuparr 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, Cleanuparr can also trigger a search to replace the deleted shows/movies.
|
||||
|
||||
## Important note
|
||||
Cleanuparr was created primarily to address malicious files, such as `*.lnk` or `*.zipx`, that were getting stuck in Sonarr/Radarr and required manual intervention. Some of the reddit posts that made Cleanuparr come to life can be found [here](https://www.reddit.com/r/sonarr/comments/1gqnx16/psa_sonarr_downloaded_a_virus/), [here](https://www.reddit.com/r/sonarr/comments/1gqwklr/sonar_downloaded_a_mkv_file_which_looked_like_a/), [here](https://www.reddit.com/r/sonarr/comments/1gpw2wa/downloaded_waiting_to_import/) and [here](https://www.reddit.com/r/sonarr/comments/1gpi344/downloads_not_importing_no_files_found/).
|
||||
|
||||
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
|
||||
> [!IMPORTANT]
|
||||
> **Features:**
|
||||
> - Strike system to mark bad downloads.
|
||||
> - Remove and block downloads that reached a maximum number of strikes.
|
||||
> - Remove and block downloads that are **failing to be imported** by the arrs.
|
||||
> - Remove and block downloads that are **stalled** or in **metadata downloading** state.
|
||||
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**.
|
||||
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
|
||||
> - Remove and block known malware based on patterns found by the community.
|
||||
> - Automatically trigger a search for downloads removed from the arrs.
|
||||
> - Clean up downloads that have been **seeding** for a certain amount of time.
|
||||
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
|
||||
> - Notify on strike or download removal.
|
||||
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
|
||||
|
||||
This tool is actively developed and still a work in progress. 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:
|
||||
## Screenshots
|
||||
|
||||
> https://discord.gg/sWggpnmGNY
|
||||
https://cleanuparr.github.io/Cleanuparr/docs/screenshots
|
||||
|
||||
# How it works
|
||||
## 🎯 Supported Applications
|
||||
|
||||
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**.
|
||||
2. **Queue cleaner** will:
|
||||
- Run every 5 minutes (or configured cron).
|
||||
- Process all items in the *arr queue.
|
||||
- Check each queue item if it is **stalled (download speed is 0)**, **stuck in matadata downloading** or **failed to be imported**.
|
||||
- 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 of are marked as **unwanted/skipped**.
|
||||
- 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.
|
||||
### *Arr Applications
|
||||
- **Sonarr**
|
||||
- **Radarr**
|
||||
- **Lidarr**
|
||||
- **Readarr**
|
||||
- **Whisparr**
|
||||
|
||||
# Setup
|
||||
### Download Clients
|
||||
- **qBittorrent**
|
||||
- **Transmission**
|
||||
- **Deluge**
|
||||
- **µTorrent**
|
||||
|
||||
## Using qBittorrent's built-in feature (works only with qBittorrent)
|
||||
### Platforms
|
||||
- **Docker**
|
||||
- **Windows**
|
||||
- **macOS**
|
||||
- **Linux**
|
||||
- **Unraid**
|
||||
|
||||
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.
|
||||
## 🚀 Quick Start
|
||||
|
||||
## 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 [Environment variables](#Environment-variables) 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.
|
||||
|
||||
## Usage
|
||||
|
||||
### Docker compose yaml
|
||||
|
||||
```
|
||||
version: "3.3"
|
||||
services:
|
||||
cleanuperr:
|
||||
volumes:
|
||||
- ./cleanuperr/logs:/var/logs
|
||||
environment:
|
||||
- LOGGING__LOGLEVEL=Information
|
||||
- LOGGING__FILE__ENABLED=false
|
||||
- LOGGING__FILE__PATH=/var/logs/
|
||||
- LOGGING__ENHANCED=true
|
||||
|
||||
- TRIGGERS__QUEUECLEANER=0 0/5 * * * ?
|
||||
- TRIGGERS__CONTENTBLOCKER=0 0/5 * * * ?
|
||||
|
||||
- QUEUECLEANER__ENABLED=true
|
||||
- QUEUECLEANER__RUNSEQUENTIALLY=true
|
||||
- QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES=5
|
||||
- QUEUECLEANER__STALLED_MAX_STRIKES=5
|
||||
|
||||
- CONTENTBLOCKER__ENABLED=true
|
||||
- CONTENTBLOCKER__BLACKLIST__ENABLED=true
|
||||
- CONTENTBLOCKER__BLACKLIST__PATH=https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist
|
||||
# OR
|
||||
# - CONTENTBLOCKER__WHITELIST__ENABLED=true
|
||||
# - CONTENTBLOCKER__WHITELIST__PATH=https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/whitelist
|
||||
|
||||
- DOWNLOAD_CLIENT=qBittorrent
|
||||
- QBITTORRENT__URL=http://localhost:8080
|
||||
- QBITTORRENT__USERNAME=user
|
||||
- QBITTORRENT__PASSWORD=pass
|
||||
# OR
|
||||
# - DOWNLOAD_CLIENT=deluge
|
||||
# - DELUGE__URL=http://localhost:8112
|
||||
# - DELUGE__PASSWORD=testing
|
||||
# OR
|
||||
# - DOWNLOAD_CLIENT=transmission
|
||||
# - TRANSMISSION__URL=http://localhost:9091
|
||||
# - TRANSMISSION__USERNAME=test
|
||||
# - TRANSMISSION__PASSWORD=testing
|
||||
|
||||
- SONARR__ENABLED=true
|
||||
- SONARR__SEARCHTYPE=Episode
|
||||
- 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__INSTANCES__0__URL=http://localhost:7878
|
||||
- RADARR__INSTANCES__0__APIKEY=secret3
|
||||
- RADARR__INSTANCES__1__URL=http://localhost:7879
|
||||
- RADARR__INSTANCES__1__APIKEY=secret4
|
||||
image: ghcr.io/flmorg/cleanuperr:latest
|
||||
restart: unless-stopped
|
||||
```bash
|
||||
docker run -d --name cleanuparr \
|
||||
--restart unless-stopped \
|
||||
-p 11011:11011 \
|
||||
-v /path/to/config:/config \
|
||||
-e PORT=11011 \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e TZ=Etc/UTC \
|
||||
ghcr.io/cleanuparr/cleanuparr:latest
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed), but not before reading the [Prerequisites](https://cleanuparr.github.io/Cleanuparr/docs/installation/).
|
||||
|
||||
| Variable | Required | Description | Default value |
|
||||
|---|---|---|---|
|
||||
| LOGGING__LOGLEVEL | No | Can be `Verbose`, `Debug`, `Information`, `Warning`, `Error` or `Fatal` | `Information` |
|
||||
| LOGGING__FILE__ENABLED | No | Enable or disable logging to file | false |
|
||||
| LOGGING__FILE__PATH | No | Directory where to save the log files | empty |
|
||||
| LOGGING__ENHANCED | No | Enhance logs whenever possible<br>A more detailed description is provided [here](variables.md#LOGGING__ENHANCED) | true |
|
||||
|||||
|
||||
| TRIGGERS__QUEUECLEANER | Yes if queue cleaner is enabled | [Quartz cron trigger](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html)<br>Can be a max of 6h interval<br>**Is ignored if `QUEUECLEANER__RUNSEQUENTIALLY=true` and `CONTENTBLOCKER__ENABLED=true`** | 0 0/5 * * * ? |
|
||||
| TRIGGERS__CONTENTBLOCKER | Yes if content blocker is enabled | [Quartz cron trigger](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html)<br>Can be a max of 6h interval | 0 0/5 * * * ? |
|
||||
|||||
|
||||
| QUEUECLEANER__ENABLED | No | Enable or disable the queue cleaner | true |
|
||||
| QUEUECLEANER__RUNSEQUENTIALLY | No | If set to true, the queue cleaner will run after the content blocker instead of running in parallel, streamlining the cleaning process | true |
|
||||
| QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES | No | After how many strikes should a failed import be removed<br>0 means never | 0 |
|
||||
| QUEUECLEANER__STALLED_MAX_STRIKES | No | After how many strikes should a stalled download be removed<br>0 means never | 0 |
|
||||
|||||
|
||||
| CONTENTBLOCKER__ENABLED | No | Enable or disable the content blocker | false |
|
||||
| CONTENTBLOCKER__BLACKLIST__ENABLED | Yes if content blocker is enabled and whitelist is not enabled | Enable or disable the blacklist | false |
|
||||
| CONTENTBLOCKER__BLACKLIST__PATH | Yes if blacklist is enabled | Path to the blacklist (local file or url)<br>Needs to be json compatible | empty |
|
||||
| CONTENTBLOCKER__WHITELIST__ENABLED | Yes if content blocker is enabled and blacklist is not enabled | Enable or disable the whitelist | false |
|
||||
| CONTENTBLOCKER__WHITELIST__PATH | Yes if whitelist is enabled | Path to the whitelist (local file or url)<br>Needs to be json compatible | empty |
|
||||
|||||
|
||||
| DOWNLOAD_CLIENT | No | Download client that is used by *arrs<br>Can be `qbittorrent`, `deluge` or `transmission` | `qbittorrent` |
|
||||
| QBITTORRENT__URL | No | qBittorrent instance url | http://localhost:8112 |
|
||||
| QBITTORRENT__USERNAME | No | qBittorrent user | empty |
|
||||
| QBITTORRENT__PASSWORD | No | qBittorrent password | empty |
|
||||
|||||
|
||||
| DELUGE__URL | No | Deluge instance url | http://localhost:8080 |
|
||||
| DELUGE__PASSWORD | No | Deluge password | empty |
|
||||
|||||
|
||||
| TRANSMISSION__URL | No | Transmission instance url | http://localhost:9091 |
|
||||
| TRANSMISSION__USERNAME | No | Transmission user | empty |
|
||||
| TRANSMISSION__PASSWORD | No | Transmission password | empty |
|
||||
|||||
|
||||
| SONARR__ENABLED | No | Enable or disable Sonarr cleanup | true |
|
||||
| SONARR__SEARCHTYPE | No | What to search for after removing a queue item<br>Can be `Episode`, `Season` or `Series` | `Episode` |
|
||||
| SONARR__INSTANCES__0__URL | No | First Sonarr instance url | http://localhost:8989 |
|
||||
| SONARR__INSTANCES__0__APIKEY | No | First Sonarr instance API key | empty |
|
||||
|||||
|
||||
| RADARR__ENABLED | No | Enable or disable Radarr cleanup | false |
|
||||
| RADARR__INSTANCES__0__URL | No | First Radarr instance url | http://localhost:8989 |
|
||||
| RADARR__INSTANCES__0__APIKEY | No | First Radarr instance API key | empty |
|
||||
### 🌐 Access the Web Interface
|
||||
|
||||
#
|
||||
### To be noted
|
||||
|
||||
1. The blacklist and the whitelist can not be both enabled at the same time.
|
||||
2. The queue cleaner and content blocker can be enabled or disabled separately, if you want to run only one of them.
|
||||
3. Only one download client can be enabled at a time. If you have more than one download client, you should deploy multiple instances of cleanuperr.
|
||||
4. The blocklists (blacklist/whitelist) should have a single pattern on each line and supports the following:
|
||||
After installation, open your browser and navigate to:
|
||||
```
|
||||
*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:"
|
||||
```
|
||||
5. Multiple Sonarr/Radarr instances can be specified using this format, where `<NUMBER>` starts from 0:
|
||||
```
|
||||
SONARR__INSTANCES__<NUMBER>__URL
|
||||
SONARR__INSTANCES__<NUMBER>__APIKEY
|
||||
http://localhost:11011
|
||||
```
|
||||
|
||||
#
|
||||
**Next Steps:** Check out the [📖 Complete Documentation](https://cleanuparr.github.io/Cleanuparr/) for detailed configuration guides and setup instructions.
|
||||
|
||||
### Binaries (if you're not using Docker)
|
||||
## 📖 Documentation & Support
|
||||
|
||||
1. Download the binaries from [releases](https://github.com/flmorg/cleanuperr/releases).
|
||||
2. Extract them from the zip file.
|
||||
3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [above](#environment-variables).
|
||||
- **📚 [Complete Documentation](https://cleanuparr.github.io/Cleanuparr/)** - Installation guides, configuration, and troubleshooting
|
||||
- **⚙️ [Configuration Guide](https://cleanuparr.github.io/Cleanuparr/docs/category/configuration)** - Set up download clients, *arr apps, and features
|
||||
- **🔧 [Setup Scenarios](https://cleanuparr.github.io/Cleanuparr/docs/category/setup-scenarios)** - Common use cases and examples
|
||||
- **💬 [Discord Community](https://discord.gg/SCtMCgtsc4)** - Get help and discuss with other users
|
||||
- **🔗 [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases)** - Download binaries and view changelog
|
||||
|
||||
### Run as a Windows Service
|
||||
## 🤝 Contributing
|
||||
|
||||
Check out this stackoverflow answer on how to do it: https://stackoverflow.com/a/15719678
|
||||
We welcome contributions from the community! Whether it's bug fixes, new features, documentation improvements, or testing, your help is appreciated.
|
||||
|
||||
**Before contributing:** Please read our [Contributing Guide](CONTRIBUTING.md) and announce your intent to work on an issue before starting.
|
||||
|
||||
- **[Contributing Guide](CONTRIBUTING.md)** - Learn how to set up your development environment and submit contributions
|
||||
- **[Report Issues](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Found a bug? Let us know!
|
||||
- **[Feature Requests](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Share your ideas for new features
|
||||
- **[Help Test Features](https://discord.gg/SCtMCgtsc4)** - Join Discord to test pre-release features and provide feedback
|
||||
|
||||
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuparr"> <span style="vertical-align: middle;">Cleanuparr</span> <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/x.svg" height="24px" width="30px" style="vertical-align: middle;"> <span style="vertical-align: middle;">Huntarr</span> <img style="vertical-align: middle;" width="24px" src="https://github.com/plexguide/Huntarr.io/blob/main/frontend/static/logo/512.png?raw=true" alt Huntarr></img>
|
||||
|
||||
Think of **Cleanuparr** as the janitor of your server; it keeps your download queue spotless, removes clutter, and blocks malicious files. Now imagine combining that with **Huntarr**, the compulsive librarian who finds missing and upgradable media to complete your collection
|
||||
|
||||
While **Huntarr** fills in the blanks and improves what you already have, **Cleanuparr** makes sure that only clean downloads get through. If you're aiming for a reliable and self-sufficient setup, **Cleanuparr** and **Huntarr** will take your automated media stack to another level.
|
||||
|
||||
<span style="font-size:24px"> ➡️ [**Huntarr**](https://github.com/plexguide/Huntarr.io) <span style="vertical-align: middle"></span></span>
|
||||
|
||||
# Credits
|
||||
Special thanks for inspiration go to:
|
||||
- [ThijmenGThN/swaparr](https://github.com/ThijmenGThN/swaparr)
|
||||
- [ManiMatter/decluttarr](https://github.com/ManiMatter/decluttarr)
|
||||
- [PaeyMoopy/sonarr-radarr-queue-cleaner](https://github.com/PaeyMoopy/sonarr-radarr-queue-cleaner)
|
||||
- [Sonarr](https://github.com/Sonarr/Sonarr) & [Radarr](https://github.com/Radarr/Radarr) for the logo
|
||||
- [Sonarr](https://github.com/Sonarr/Sonarr) & [Radarr](https://github.com/Radarr/Radarr)
|
||||
|
||||
# Buy me a coffee
|
||||
If I made your life just a tiny bit easier, consider buying me a coffee!
|
||||
|
||||
<a href="https://buymeacoffee.com/flaminel" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
<a href="https://buymeacoffee.com/flaminel" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
|
||||
345
blacklist
345
blacklist
@@ -1,11 +1,27 @@
|
||||
*(sample).*
|
||||
*sample.avchd
|
||||
*sample.avi
|
||||
*sample.mkv
|
||||
*sample.mov
|
||||
*sample.mp4
|
||||
*sample.webm
|
||||
*sample.wmv
|
||||
*.000
|
||||
*.001
|
||||
*.002
|
||||
*.004
|
||||
*.0xe
|
||||
*.73k
|
||||
*.73p
|
||||
*.7z
|
||||
*.7z.001
|
||||
*.7z.002
|
||||
*.89k
|
||||
*.89z
|
||||
*.8ck
|
||||
*.a00
|
||||
*.a01
|
||||
*.a02
|
||||
*.a7r
|
||||
*.ac
|
||||
*.acc
|
||||
@@ -21,8 +37,11 @@
|
||||
*.ahk
|
||||
*.ai
|
||||
*.aif
|
||||
*.ain
|
||||
*.air
|
||||
*.alz
|
||||
*.ana
|
||||
*.apex
|
||||
*.api
|
||||
*.apk
|
||||
*.app
|
||||
@@ -30,15 +49,28 @@
|
||||
*.applescript
|
||||
*.application
|
||||
*.appx
|
||||
*.apz
|
||||
*.ar
|
||||
*.arc
|
||||
*.archiver
|
||||
*.arduboy
|
||||
*.arh
|
||||
*.ari
|
||||
*.arj
|
||||
*.ark
|
||||
*.arscript
|
||||
*.asb
|
||||
*.asice
|
||||
*.asp
|
||||
*.aspx
|
||||
*.aspx-exe
|
||||
*.atmx
|
||||
*.ayt
|
||||
*.azw2
|
||||
*.b1
|
||||
*.b6z
|
||||
*.b64
|
||||
*.ba
|
||||
*.ba_
|
||||
*.bak
|
||||
*.bas
|
||||
@@ -46,26 +78,48 @@
|
||||
*.bat
|
||||
*.bdjo
|
||||
*.bdmv
|
||||
*.bdoc
|
||||
*.beam
|
||||
*.bh
|
||||
*.bin
|
||||
*.bmp
|
||||
*.bms
|
||||
*.bndl
|
||||
*.bns
|
||||
*.boo
|
||||
*.bsa
|
||||
*.btm
|
||||
*.bundle
|
||||
*.bz
|
||||
*.bz2
|
||||
*.bza
|
||||
*.bzabw
|
||||
*.bzip
|
||||
*.bzip2
|
||||
*.c
|
||||
*.c00
|
||||
*.c01
|
||||
*.c02
|
||||
*.c10
|
||||
*.cab
|
||||
*.caction
|
||||
*.car
|
||||
*.cb7
|
||||
*.cba
|
||||
*.cbr
|
||||
*.cbt
|
||||
*.cbz
|
||||
*.cci
|
||||
*.cda
|
||||
*.cdb
|
||||
*.cdz
|
||||
*.cel
|
||||
*.celx
|
||||
*.cfs
|
||||
*.cgi
|
||||
*.cheat
|
||||
*.chm
|
||||
*.cit
|
||||
*.ckpt
|
||||
*.cla
|
||||
*.class
|
||||
@@ -75,9 +129,15 @@
|
||||
*.coffee
|
||||
*.com
|
||||
*.command
|
||||
*.comppkg.hauptwerk.rar
|
||||
*.comppkg_hauptwerk_rar
|
||||
*.conda
|
||||
*.conf
|
||||
*.config
|
||||
*.cp9
|
||||
*.cpgz
|
||||
*.cpl
|
||||
*.cpt
|
||||
*.crt
|
||||
*.cs
|
||||
*.csh
|
||||
@@ -85,17 +145,27 @@
|
||||
*.csproj
|
||||
*.css
|
||||
*.csv
|
||||
*.ctx
|
||||
*.ctz
|
||||
*.cue
|
||||
*.cur
|
||||
*.cxarchive
|
||||
*.cyw
|
||||
*.czip
|
||||
*.daemon
|
||||
*.daf
|
||||
*.dar
|
||||
*.dat
|
||||
*.data-00000-of-00001
|
||||
*.db
|
||||
*.dd
|
||||
*.deamon
|
||||
*.deb
|
||||
*.dek
|
||||
*.dgc
|
||||
*.dist
|
||||
*.diz
|
||||
*.dl_
|
||||
*.dld
|
||||
*.dll
|
||||
*.dmc
|
||||
@@ -112,19 +182,27 @@
|
||||
*.dw
|
||||
*.dword
|
||||
*.dxl
|
||||
*.dz
|
||||
*.e_e
|
||||
*.ear
|
||||
*.ebacmd
|
||||
*.ebm
|
||||
*.ebs
|
||||
*.ebs2
|
||||
*.ecar
|
||||
*.ecf
|
||||
*.ecs
|
||||
*.ecsbx
|
||||
*.edz
|
||||
*.efw
|
||||
*.egg
|
||||
*.eham
|
||||
*.elf
|
||||
*.elf-so
|
||||
*.email
|
||||
*.emu
|
||||
*.epk
|
||||
*.epi
|
||||
*.es
|
||||
*.esh
|
||||
*.etc
|
||||
@@ -140,36 +218,62 @@
|
||||
*.exz
|
||||
*.ezs
|
||||
*.ezt
|
||||
*.f
|
||||
*.f3z
|
||||
*.fas
|
||||
*.fba
|
||||
*.fcx
|
||||
*.fky
|
||||
*.flac
|
||||
*.flatpak
|
||||
*.flv
|
||||
*.fp8
|
||||
*.fpi
|
||||
*.frs
|
||||
*.fxp
|
||||
*.fzpz
|
||||
*.gadget
|
||||
*.gar
|
||||
*.gat
|
||||
*.gca
|
||||
*.gif
|
||||
*.gifv
|
||||
*.gm9
|
||||
*.gmz
|
||||
*.gpe
|
||||
*.gpu
|
||||
*.gs
|
||||
*.gz
|
||||
*.gz2
|
||||
*.gza
|
||||
*.gzi
|
||||
*.gzip
|
||||
*.h5
|
||||
*.ha
|
||||
*.ham
|
||||
*.hbc
|
||||
*.hbc2
|
||||
*.hbe
|
||||
*.hex
|
||||
*.hki
|
||||
*.hki1
|
||||
*.hki2
|
||||
*.hki3
|
||||
*.hlp
|
||||
*.hms
|
||||
*.hpf
|
||||
*.hpk
|
||||
*.hpkg
|
||||
*.hta
|
||||
*.hta-psh
|
||||
*.htaccess
|
||||
*.htm
|
||||
*.html
|
||||
*.htmi
|
||||
*.hyp
|
||||
*.iadproj
|
||||
*.icd
|
||||
*.ice
|
||||
*.icns
|
||||
*.ico
|
||||
*.idx
|
||||
@@ -182,17 +286,27 @@
|
||||
*.ins
|
||||
*.ipa
|
||||
*.ipf
|
||||
*.ipg
|
||||
*.ipk
|
||||
*.ipsw
|
||||
*.iqylink
|
||||
*.ish
|
||||
*.iso
|
||||
*.isp
|
||||
*.isu
|
||||
*.isx
|
||||
*.ita
|
||||
*.ize
|
||||
*.izh
|
||||
*.izma ace
|
||||
*.j
|
||||
*.jar
|
||||
*.jar.pack
|
||||
*.java
|
||||
*.jex
|
||||
*.jgz
|
||||
*.jhh
|
||||
*.jic
|
||||
*.jpeg
|
||||
*.jpg
|
||||
*.js
|
||||
@@ -201,27 +315,51 @@
|
||||
*.jse
|
||||
*.jsf
|
||||
*.json
|
||||
*.jsonlz4
|
||||
*.jsp
|
||||
*.jsx
|
||||
*.kextraction
|
||||
*.kgb
|
||||
*.kix
|
||||
*.ksh
|
||||
*.ksp
|
||||
*.kwgt
|
||||
*.kx
|
||||
*.kz
|
||||
*.layout
|
||||
*.lbr
|
||||
*.lck
|
||||
*.ldb
|
||||
*.lemon
|
||||
*.lha
|
||||
*.lhzd
|
||||
*.lib
|
||||
*.libzip
|
||||
*.link
|
||||
*.lnk
|
||||
*.lo
|
||||
*.lock
|
||||
*.log
|
||||
*.loop-vbs
|
||||
*.lpkg
|
||||
*.lqr
|
||||
*.ls
|
||||
*.lz
|
||||
*.lz4
|
||||
*.lzh
|
||||
*.lzm
|
||||
*.lzma
|
||||
*.lzo
|
||||
*.lzr
|
||||
*.lzx
|
||||
*.m3u
|
||||
*.m4a
|
||||
*.mac
|
||||
*.macho
|
||||
*.mamc
|
||||
*.manifest
|
||||
*.mar
|
||||
*.mbz
|
||||
*.mcr
|
||||
*.md
|
||||
*.mda
|
||||
@@ -232,22 +370,29 @@
|
||||
*.mdt
|
||||
*.mel
|
||||
*.mem
|
||||
*.memo
|
||||
*.meta
|
||||
*.mgm
|
||||
*.mhm
|
||||
*.mht
|
||||
*.mhtml
|
||||
*.mid
|
||||
*.mint
|
||||
*.mio
|
||||
*.mlappinstall
|
||||
*.mlproj
|
||||
*.mlx
|
||||
*.mm
|
||||
*.mobileconfig
|
||||
*.model
|
||||
*.moo
|
||||
*.mou
|
||||
*.movpkg
|
||||
*.mozlz4
|
||||
*.mp3
|
||||
*.mpa
|
||||
*.mpk
|
||||
*.mpkg
|
||||
*.mpls
|
||||
*.mrc
|
||||
*.mrp
|
||||
@@ -266,41 +411,79 @@
|
||||
*.msp
|
||||
*.mst
|
||||
*.msu
|
||||
*.mxc
|
||||
*.mxe
|
||||
*.mzp
|
||||
*.n
|
||||
*.nar
|
||||
*.ncl
|
||||
*.net
|
||||
*.nex
|
||||
*.nexe
|
||||
*.nfo
|
||||
*.npk
|
||||
*.nrg
|
||||
*.num
|
||||
*.nz
|
||||
*.nzb.bz2
|
||||
*.nzb.gz
|
||||
*.nzbs
|
||||
*.oar
|
||||
*.ocx
|
||||
*.odlgz
|
||||
*.odt
|
||||
*.opk
|
||||
*.ore
|
||||
*.osf
|
||||
*.ost
|
||||
*.osx
|
||||
*.osx-app
|
||||
*.otm
|
||||
*.out
|
||||
*.ova
|
||||
*.oz
|
||||
*.p
|
||||
*.p01
|
||||
*.p19
|
||||
*.p7z
|
||||
*.pa
|
||||
*.pack.gz
|
||||
*.package
|
||||
*.pae
|
||||
*.paf
|
||||
*.pak
|
||||
*.paq6
|
||||
*.paq7
|
||||
*.paq8
|
||||
*.paq8f
|
||||
*.paq8l
|
||||
*.paq8p
|
||||
*.par
|
||||
*.par2
|
||||
*.pax
|
||||
*.pb
|
||||
*.pbi
|
||||
*.pcd
|
||||
*.pcv
|
||||
*.pdb
|
||||
*.pdf
|
||||
*.pea
|
||||
*.perl
|
||||
*.pet
|
||||
*.pex
|
||||
*.pf
|
||||
*.phar
|
||||
*.php
|
||||
*.php5
|
||||
*.pif
|
||||
*.pim
|
||||
*.pima
|
||||
*.pit
|
||||
*.piz
|
||||
*.pkg
|
||||
*.pkg.tar.xz
|
||||
*.pkg.tar.zst
|
||||
*.pkz
|
||||
*.pl
|
||||
*.plsc
|
||||
*.plx
|
||||
@@ -318,6 +501,7 @@
|
||||
*.pptx
|
||||
*.prc
|
||||
*.prg
|
||||
*.prs
|
||||
*.ps
|
||||
*.ps1
|
||||
*.ps1xml
|
||||
@@ -333,9 +517,16 @@
|
||||
*.psh-reflection
|
||||
*.psm1
|
||||
*.pst
|
||||
*.psz
|
||||
*.pt
|
||||
*.pup
|
||||
*.puz
|
||||
*.pvd
|
||||
*.pvmp
|
||||
*.pvmz
|
||||
*.pwa
|
||||
*.pwc
|
||||
*.pxl
|
||||
*.pxo
|
||||
*.py
|
||||
*.pyc
|
||||
@@ -343,8 +534,20 @@
|
||||
*.pyo
|
||||
*.python
|
||||
*.pyz
|
||||
*.q
|
||||
*.qda
|
||||
*.qit
|
||||
*.qpx
|
||||
*.r0
|
||||
*.r00
|
||||
*.r01
|
||||
*.r02
|
||||
*.r03
|
||||
*.r04
|
||||
*.r1
|
||||
*.r2
|
||||
*.r21
|
||||
*.r30
|
||||
*.ram
|
||||
*.rar
|
||||
*.raw
|
||||
@@ -355,22 +558,35 @@
|
||||
*.reg
|
||||
*.resources
|
||||
*.resx
|
||||
*.rev
|
||||
*.rfs
|
||||
*.rfu
|
||||
*.rgs
|
||||
*.rk
|
||||
*.rm
|
||||
*.rnc
|
||||
*.rox
|
||||
*.rp9
|
||||
*.rpg
|
||||
*.rpj
|
||||
*.rpm
|
||||
*.rss
|
||||
*.ruby
|
||||
*.run
|
||||
*.rxe
|
||||
*.rz
|
||||
*.s00
|
||||
*.s01
|
||||
*.s02
|
||||
*.s09
|
||||
*.s2a
|
||||
*.s7z
|
||||
*.sample
|
||||
*.sapk
|
||||
*.sar
|
||||
*.savedmodel
|
||||
*.sbs
|
||||
*.sbx
|
||||
*.sca
|
||||
*.scar
|
||||
*.scb
|
||||
@@ -380,42 +596,86 @@
|
||||
*.scr
|
||||
*.script
|
||||
*.sct
|
||||
*.sdc
|
||||
*.sdn
|
||||
*.sdoc
|
||||
*.sdocx
|
||||
*.sea
|
||||
*.seed
|
||||
*.sen
|
||||
*.server
|
||||
*.service
|
||||
*.sfg
|
||||
*.sfm
|
||||
*.sfs
|
||||
*.sfv
|
||||
*.sfx
|
||||
*.sh
|
||||
*.shar
|
||||
*.shb
|
||||
*.shell
|
||||
*.shk
|
||||
*.shortcut
|
||||
*.shr
|
||||
*.shs
|
||||
*.shtml
|
||||
*.sifz
|
||||
*.sipa
|
||||
*.sit
|
||||
*.sitx
|
||||
*.sk
|
||||
*.sldm
|
||||
*.sln
|
||||
*.smm
|
||||
*.smpf
|
||||
*.snap
|
||||
*.snagitstamps
|
||||
*.snappy
|
||||
*.snb
|
||||
*.snd
|
||||
*.snz
|
||||
*.spa
|
||||
*.spd
|
||||
*.spl
|
||||
*.spm
|
||||
*.spr
|
||||
*.spt
|
||||
*.sql
|
||||
*.sqf
|
||||
*.sqx
|
||||
*.sqz
|
||||
*.srec
|
||||
*.srep
|
||||
*.srt
|
||||
*.ssm
|
||||
*.stg
|
||||
*.stkdoodlz
|
||||
*.stproj
|
||||
*.sts
|
||||
*.sub
|
||||
*.svg
|
||||
*.swf
|
||||
*.sy_
|
||||
*.sys
|
||||
*.tar
|
||||
*.tar.bz2
|
||||
*.tar.gz
|
||||
*.tar.gz2
|
||||
*.tar.lz
|
||||
*.tar.lzma
|
||||
*.tar.xz
|
||||
*.tar.z
|
||||
*.tar.zip
|
||||
*.taz
|
||||
*.tbl
|
||||
*.tbz
|
||||
*.tbz2
|
||||
*.tcp
|
||||
*.tcx
|
||||
*.text
|
||||
*.tf
|
||||
*.tg
|
||||
*.tgs
|
||||
*.tgz
|
||||
*.thm
|
||||
*.thmx
|
||||
@@ -424,19 +684,35 @@
|
||||
*.tif
|
||||
*.tiff
|
||||
*.tipa
|
||||
*.tlz
|
||||
*.tlzma
|
||||
*.tmp
|
||||
*.tms
|
||||
*.toast
|
||||
*.torrent
|
||||
*.tpk
|
||||
*.tpsr
|
||||
*.trs
|
||||
*.txt
|
||||
*.tx_
|
||||
*.txz
|
||||
*.tz
|
||||
*.tzst
|
||||
*.u3p
|
||||
*.ubz
|
||||
*.uc2
|
||||
*.udf
|
||||
*.ufdr
|
||||
*.ufs.uzip
|
||||
*.uha
|
||||
*.upk
|
||||
*.upx
|
||||
*.url
|
||||
*.uue
|
||||
*.uvm
|
||||
*.uw8
|
||||
*.uzed
|
||||
*.uzip
|
||||
*.vb
|
||||
*.vba
|
||||
*.vba-exe
|
||||
@@ -448,26 +724,46 @@
|
||||
*.vbscript
|
||||
*.vcd
|
||||
*.vdo
|
||||
*.vem
|
||||
*.vexe
|
||||
*.vfs
|
||||
*.vhd
|
||||
*.vhdx
|
||||
*.vib
|
||||
*.vip
|
||||
*.vlx
|
||||
*.vm
|
||||
*.vmcz
|
||||
*.vmdk
|
||||
*.vms
|
||||
*.vob
|
||||
*.vocab
|
||||
*.voca
|
||||
*.vpk
|
||||
*.vpm
|
||||
*.vrpackage
|
||||
*.vsi
|
||||
*.vwi
|
||||
*.vxp
|
||||
*.wa
|
||||
*.wacz
|
||||
*.waff
|
||||
*.war
|
||||
*.wastickers
|
||||
*.wav
|
||||
*.wbk
|
||||
*.wcm
|
||||
*.wdz
|
||||
*.webm
|
||||
*.whl
|
||||
*.wick
|
||||
*.widget
|
||||
*.wim
|
||||
*.wiz
|
||||
*.wlb
|
||||
*.wma
|
||||
*.workflow
|
||||
*.wot
|
||||
*.wpk
|
||||
*.wpl
|
||||
*.wpm
|
||||
@@ -476,14 +772,26 @@
|
||||
*.wsc
|
||||
*.wsf
|
||||
*.wsh
|
||||
*.wux
|
||||
*.x86
|
||||
*.x86_64
|
||||
*.xaml
|
||||
*.xap
|
||||
*.xapk
|
||||
*.xar
|
||||
*.xbap
|
||||
*.xbe
|
||||
*.xcf.bz2
|
||||
*.xcf.gz
|
||||
*.xcf.xz
|
||||
*.xcfbz2
|
||||
*.xcfgz
|
||||
*.xcfxz
|
||||
*.xex
|
||||
*.xez
|
||||
*.xfp
|
||||
*.xig
|
||||
*.xip
|
||||
*.xla
|
||||
*.xlam
|
||||
*.xll
|
||||
@@ -496,24 +804,47 @@
|
||||
*.xltb
|
||||
*.xltm
|
||||
*.xlw
|
||||
*.xmcdz
|
||||
*.xml
|
||||
*.xoj
|
||||
*.xopp
|
||||
*.xqt
|
||||
*.xrt
|
||||
*.xx
|
||||
*.xys
|
||||
*.xz
|
||||
*.xzm
|
||||
*.y
|
||||
*.yc
|
||||
*.ygh
|
||||
*.yz1
|
||||
*.z
|
||||
*.z00
|
||||
*.z01
|
||||
*.z02
|
||||
*.z03
|
||||
*.z04
|
||||
*.zabw
|
||||
*.zap
|
||||
*.zed
|
||||
*.zfsendtotarget
|
||||
*.zhelp
|
||||
*.zi
|
||||
*.zi_
|
||||
*.zim
|
||||
*.zip
|
||||
*.zipx
|
||||
*.zix
|
||||
*.zl
|
||||
*.zl9
|
||||
*.zoo
|
||||
*sample.avchd
|
||||
*sample.avi
|
||||
*sample.mkv
|
||||
*sample.mov
|
||||
*sample.mp4
|
||||
*sample.webm
|
||||
*sample.wmv
|
||||
*.zpaq
|
||||
*.zpi
|
||||
*.zsplit
|
||||
*.zst
|
||||
*.zw
|
||||
*.zwi
|
||||
*.zz
|
||||
Trailer.*
|
||||
VOSTFR
|
||||
api
|
||||
@@ -1,51 +1,410 @@
|
||||
*.000
|
||||
*.001
|
||||
*.002
|
||||
*.004
|
||||
*.7z
|
||||
*.7z.001
|
||||
*.7z.002
|
||||
*.a00
|
||||
*.a01
|
||||
*.a02
|
||||
*.ace
|
||||
*.ain
|
||||
*.alz
|
||||
*.ana
|
||||
*.apex
|
||||
*.apk
|
||||
*.apz
|
||||
*.ar
|
||||
*.arc
|
||||
*.archiver
|
||||
*.arduboy
|
||||
*.arh
|
||||
*.ari
|
||||
*.arj
|
||||
*.ark
|
||||
*.asice
|
||||
*.ayt
|
||||
*.b1
|
||||
*.b6z
|
||||
*.b64
|
||||
*.ba
|
||||
*.bat
|
||||
*.bdoc
|
||||
*.bh
|
||||
*.bin
|
||||
*.bmp
|
||||
*.bndl
|
||||
*.boo
|
||||
*.bundle
|
||||
*.bz
|
||||
*.bz2
|
||||
*.bza
|
||||
*.bzabw
|
||||
*.bzip
|
||||
*.bzip2
|
||||
*.c00
|
||||
*.c01
|
||||
*.c02
|
||||
*.c10
|
||||
*.car
|
||||
*.cb7
|
||||
*.cba
|
||||
*.cbr
|
||||
*.cbt
|
||||
*.cbz
|
||||
*.cdz
|
||||
*.cit
|
||||
*.cmd
|
||||
*.com
|
||||
*.comppkg.hauptwerk.rar
|
||||
*.comppkg_hauptwerk_rar
|
||||
*.conda
|
||||
*.cp9
|
||||
*.cpgz
|
||||
*.cpt
|
||||
*.ctx
|
||||
*.ctz
|
||||
*.cxarchive
|
||||
*.czip
|
||||
*.daf
|
||||
*.dar
|
||||
*.db
|
||||
*.dd
|
||||
*.deb
|
||||
*.dgc
|
||||
*.dist
|
||||
*.diz
|
||||
*.dl_
|
||||
*.dll
|
||||
*.dmg
|
||||
*.dz
|
||||
*.ecar
|
||||
*.ecs
|
||||
*.ecsbx
|
||||
*.edz
|
||||
*.efw
|
||||
*.egg
|
||||
*.epi
|
||||
*.etc
|
||||
*.exe
|
||||
*.f
|
||||
*.f3z
|
||||
*.fcx
|
||||
*.fp8
|
||||
*.fzpz
|
||||
*.gar
|
||||
*.gca
|
||||
*.gif
|
||||
*.gmz
|
||||
*.gz
|
||||
*.gz2
|
||||
*.gza
|
||||
*.gzi
|
||||
*.gzip
|
||||
*.ha
|
||||
*.hbc
|
||||
*.hbc2
|
||||
*.hbe
|
||||
*.hki
|
||||
*.hki1
|
||||
*.hki2
|
||||
*.hki3
|
||||
*.hpk
|
||||
*.hpkg
|
||||
*.htm
|
||||
*.html
|
||||
*.htmi
|
||||
*.hyp
|
||||
*.iadproj
|
||||
*.ice
|
||||
*.ico
|
||||
*.ini
|
||||
*.ipg
|
||||
*.ipk
|
||||
*.ish
|
||||
*.iso
|
||||
*.isx
|
||||
*.ita
|
||||
*.ize
|
||||
*.j
|
||||
*.jar
|
||||
*.jar.pack
|
||||
*.jex
|
||||
*.jgz
|
||||
*.jhh
|
||||
*.jic
|
||||
*.jpg
|
||||
*.js
|
||||
*.jsonlz4
|
||||
*.kextraction
|
||||
*.kgb
|
||||
*.ksp
|
||||
*.kwgt
|
||||
*.kz
|
||||
*.layout
|
||||
*.lbr
|
||||
*.lemon
|
||||
*.lha
|
||||
*.lhzd
|
||||
*.libzip
|
||||
*.link
|
||||
*.lnk
|
||||
*.lpkg
|
||||
*.lqr
|
||||
*.lz
|
||||
*.lz4
|
||||
*.lzh
|
||||
*.lzm
|
||||
*.lzma
|
||||
*.lzo
|
||||
*.lzr
|
||||
*.lzx
|
||||
*.mar
|
||||
*.mbz
|
||||
*.md
|
||||
*.memo
|
||||
*.mint
|
||||
*.mlproj
|
||||
*.mou
|
||||
*.movpkg
|
||||
*.mozlz4
|
||||
*.mpkg
|
||||
*.msi
|
||||
*.mxc
|
||||
*.mzp
|
||||
*.nar
|
||||
*.nex
|
||||
*.nfo
|
||||
*.npk
|
||||
*.nz
|
||||
*.oar
|
||||
*.odlgz
|
||||
*.opk
|
||||
*.osf
|
||||
*.oz
|
||||
*.p01
|
||||
*.p19
|
||||
*.p7z
|
||||
*.pa
|
||||
*.pack.gz
|
||||
*.package
|
||||
*.pae
|
||||
*.pak
|
||||
*.paq6
|
||||
*.paq7
|
||||
*.paq8
|
||||
*.paq8f
|
||||
*.paq8l
|
||||
*.paq8p
|
||||
*.par
|
||||
*.par2
|
||||
*.pax
|
||||
*.pbi
|
||||
*.pcv
|
||||
*.pea
|
||||
*.perl
|
||||
*.pet
|
||||
*.pf
|
||||
*.php
|
||||
*.pim
|
||||
*.pima
|
||||
*.pit
|
||||
*.piz
|
||||
*.pkg
|
||||
*.pkg.tar.xz
|
||||
*.pkg.tar.zst
|
||||
*.pkz
|
||||
*.pl
|
||||
*.png
|
||||
*.prs
|
||||
*.ps1
|
||||
*.psc1
|
||||
*.psd1
|
||||
*.psm1
|
||||
*.psz
|
||||
*.pup
|
||||
*.puz
|
||||
*.pvmp
|
||||
*.pvmz
|
||||
*.pwa
|
||||
*.pxl
|
||||
*.py
|
||||
*.pyd
|
||||
*.q
|
||||
*.qda
|
||||
*.r0
|
||||
*.r00
|
||||
*.r01
|
||||
*.r02
|
||||
*.r03
|
||||
*.r04
|
||||
*.r1
|
||||
*.r2
|
||||
*.r21
|
||||
*.r30
|
||||
*.rar
|
||||
*.rb
|
||||
*.readme
|
||||
*.reg
|
||||
*.rev
|
||||
*.rk
|
||||
*.rnc
|
||||
*.rp9
|
||||
*.rpm
|
||||
*.rss
|
||||
*.run
|
||||
*.rz
|
||||
*.s00
|
||||
*.s01
|
||||
*.s02
|
||||
*.s09
|
||||
*.s7z
|
||||
*.sar
|
||||
*.sbx
|
||||
*.scr
|
||||
*.sdc
|
||||
*.sdn
|
||||
*.sdoc
|
||||
*.sdocx
|
||||
*.sea
|
||||
*.sen
|
||||
*.sfg
|
||||
*.sfm
|
||||
*.sfs
|
||||
*.sfx
|
||||
*.sh
|
||||
*.shar
|
||||
*.shk
|
||||
*.shr
|
||||
*.sifz
|
||||
*.sipa
|
||||
*.sit
|
||||
*.sitx
|
||||
*.smpf
|
||||
*.snagitstamps
|
||||
*.snappy
|
||||
*.snb
|
||||
*.snz
|
||||
*.spa
|
||||
*.spd
|
||||
*.spl
|
||||
*.spm
|
||||
*.spt
|
||||
*.sql
|
||||
*.sqf
|
||||
*.sqx
|
||||
*.sqz
|
||||
*.srep
|
||||
*.stg
|
||||
*.stkdoodlz
|
||||
*.stproj
|
||||
*.sy_
|
||||
*.tar.bz2
|
||||
*.tar.gz
|
||||
*.tar.gz2
|
||||
*.tar.lz
|
||||
*.tar.lzma
|
||||
*.tar.xz
|
||||
*.tar.z
|
||||
*.tar.zip
|
||||
*.taz
|
||||
*.tbz
|
||||
*.tbz2
|
||||
*.tcx
|
||||
*.text
|
||||
*.tg
|
||||
*.tgs
|
||||
*.tgz
|
||||
*.thumb
|
||||
*.tlz
|
||||
*.tlzma
|
||||
*.torrent
|
||||
*.tpsr
|
||||
*.trs
|
||||
*.txt
|
||||
*.tx_
|
||||
*.txz
|
||||
*.tz
|
||||
*.tzst
|
||||
*.ubz
|
||||
*.uc2
|
||||
*.ufdr
|
||||
*.ufs.uzip
|
||||
*.uha
|
||||
*.url
|
||||
*.uue
|
||||
*.uvm
|
||||
*.uzed
|
||||
*.uzip
|
||||
*.vbs
|
||||
*.vem
|
||||
*.vfs
|
||||
*.vib
|
||||
*.vip
|
||||
*.vmcz
|
||||
*.vms
|
||||
*.voca
|
||||
*.vpk
|
||||
*.vrpackage
|
||||
*.vsi
|
||||
*.vwi
|
||||
*.wa
|
||||
*.wacz
|
||||
*.waff
|
||||
*.war
|
||||
*.wastickers
|
||||
*.wdz
|
||||
*.whl
|
||||
*.wick
|
||||
*.wlb
|
||||
*.wot
|
||||
*.wsf
|
||||
*.wux
|
||||
*.xapk
|
||||
*.xar
|
||||
*.xcf.bz2
|
||||
*.xcf.gz
|
||||
*.xcf.xz
|
||||
*.xcfbz2
|
||||
*.xcfgz
|
||||
*.xcfxz
|
||||
*.xez
|
||||
*.xfp
|
||||
*.xip
|
||||
*.xml
|
||||
*.zipx
|
||||
*.xmcdz
|
||||
*.xoj
|
||||
*.xopp
|
||||
*.xx
|
||||
*.xz
|
||||
*.xzm
|
||||
*.y
|
||||
*.yc
|
||||
*.yz1
|
||||
*.z
|
||||
*.z00
|
||||
*.z01
|
||||
*.z02
|
||||
*.z03
|
||||
*.z04
|
||||
*.zabw
|
||||
*.zap
|
||||
*.zed
|
||||
*.zfsendtotarget
|
||||
*.zhelp
|
||||
*.zi
|
||||
*.zi_
|
||||
*.zim
|
||||
*.zip
|
||||
*.zipx
|
||||
*.zix
|
||||
*.zl
|
||||
*.zoo
|
||||
*.zpaq
|
||||
*.zpi
|
||||
*.zsplit
|
||||
*.zst
|
||||
*.zw
|
||||
*.zwi
|
||||
*.zz
|
||||
@@ -1,115 +0,0 @@
|
||||
deployment:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
containers:
|
||||
- name: qbit
|
||||
image:
|
||||
repository: ghcr.io/flmorg/cleanuperr
|
||||
tag: latest
|
||||
env:
|
||||
- name: LOGGING__LOGLEVEL
|
||||
value: Information
|
||||
- name: LOGGING__FILE__ENABLED
|
||||
value: "true"
|
||||
- name: LOGGING__FILE__PATH
|
||||
value: /var/logs
|
||||
- name: LOGGING__ENHANCED
|
||||
value: "true"
|
||||
- name: TRIGGERS__QUEUECLEANER
|
||||
value: 0 0/5 * * * ?
|
||||
- name: TRIGGERS__CONTENTBLOCKER
|
||||
value: 0 0/5 * * * ?
|
||||
- name: QUEUECLEANER__ENABLED
|
||||
value: "true"
|
||||
- name: QUEUECLEANER__RUNSEQUENTIALLY
|
||||
value: "true"
|
||||
- name: QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES
|
||||
value: "3"
|
||||
- name: QUEUECLEANER__STALLED_MAX_STRIKES
|
||||
value: "3"
|
||||
- name: CONTENTBLOCKER__ENABLED
|
||||
value: "true"
|
||||
- name: CONTENTBLOCKER__BLACKLIST__ENABLED
|
||||
value: "true"
|
||||
- name: CONTENTBLOCKER__BLACKLIST__PATH
|
||||
value: https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist
|
||||
- name: DOWNLOAD_CLIENT
|
||||
value: qbittorrent
|
||||
- name: QBITTORRENT__URL
|
||||
value: http://service.qbittorrent-videos.svc.cluster.local
|
||||
- name: SONARR__ENABLED
|
||||
value: "true"
|
||||
- name: SONARR__SEARCHTYPE
|
||||
value: Episode
|
||||
- name: SONARR__INSTANCES__0__URL
|
||||
value: http://service.sonarr-low-res.svc.cluster.local
|
||||
- name: SONARR__INSTANCES__1__URL
|
||||
value: http://service.sonarr-high-res.svc.cluster.local
|
||||
- name: RADARR__ENABLED
|
||||
value: "true"
|
||||
- name: RADARR__INSTANCES__0__URL
|
||||
value: http://service.radarr-low-res.svc.cluster.local
|
||||
- name: RADARR__INSTANCES__1__URL
|
||||
value: http://service.radarr-high-res.svc.cluster.local
|
||||
envFromSecret:
|
||||
- secretName: qbit-auth
|
||||
envs:
|
||||
- name: QBITTORRENT__USERNAME
|
||||
key: QBIT_USER
|
||||
- name: QBITTORRENT__PASSWORD
|
||||
key: QBIT_PASS
|
||||
- secretName: sonarr-auth
|
||||
envs:
|
||||
- name: SONARR__INSTANCES__0__APIKEY
|
||||
key: SNRL_API_KEY
|
||||
- name: SONARR__INSTANCES__1__APIKEY
|
||||
key: SNRH_API_KEY
|
||||
- secretName: radarr-auth
|
||||
envs:
|
||||
- name: RADARR__INSTANCES__0__APIKEY
|
||||
key: RDRL_API_KEY
|
||||
- name: RADARR__INSTANCES__1__APIKEY
|
||||
key: RDRH_API_KEY
|
||||
resources:
|
||||
requests:
|
||||
cpu: 0m
|
||||
memory: 0Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1000Mi
|
||||
volumeMounts:
|
||||
- name: storage
|
||||
mountPath: /var/logs
|
||||
subPath: cleanuperr/logs
|
||||
volumes:
|
||||
- name: storage
|
||||
type: pvc
|
||||
typeName: storage-pvc
|
||||
|
||||
pvcs:
|
||||
- name: storage-pvc
|
||||
storageClassName: local-path-persistent
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
size: 1Gi
|
||||
volumeMode: Filesystem
|
||||
|
||||
vaultSecrets:
|
||||
- name: qbit-auth
|
||||
path: secrets/qbittorrent
|
||||
templates:
|
||||
QBIT_USER: "{% .Secrets.username %}"
|
||||
QBIT_PASS: "{% .Secrets.password %}"
|
||||
- name: radarr-auth
|
||||
path: secrets/radarr
|
||||
templates:
|
||||
RDRL_API_KEY: "{% .Secrets.low_api_key %}"
|
||||
RDRH_API_KEY: "{% .Secrets.high_api_key %}"
|
||||
- name: sonarr-auth
|
||||
path: secrets/sonarr
|
||||
templates:
|
||||
SNRL_API_KEY: "{% .Secrets.low_api_key %}"
|
||||
SNRH_API_KEY: "{% .Secrets.high_api_key %}"
|
||||
41
code/.dockerignore
Normal file
41
code/.dockerignore
Normal file
@@ -0,0 +1,41 @@
|
||||
# Documentation
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# Version control
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Node.js
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.angular/
|
||||
|
||||
# .NET
|
||||
backend/bin/
|
||||
backend/obj/
|
||||
backend/*/bin/
|
||||
backend/*/obj/
|
||||
backend/.vs/
|
||||
|
||||
# Build artifacts
|
||||
artifacts/
|
||||
dist/
|
||||
|
||||
# Test files
|
||||
backend/**/*Tests/
|
||||
backend/**/Tests/
|
||||
|
||||
# Development files
|
||||
docker-compose*.yml
|
||||
test/
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Common.Configuration.Arr;
|
||||
|
||||
public abstract record ArrConfig
|
||||
{
|
||||
public required bool Enabled { get; init; }
|
||||
|
||||
public required List<ArrInstance> Instances { get; init; }
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Common.Configuration.Arr;
|
||||
|
||||
public sealed class ArrInstance
|
||||
{
|
||||
public required Uri Url { get; set; }
|
||||
|
||||
public required string ApiKey { get; set; }
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Common.Configuration.Arr;
|
||||
|
||||
public sealed record RadarrConfig : ArrConfig
|
||||
{
|
||||
public const string SectionName = "Radarr";
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Common.Configuration.Arr;
|
||||
|
||||
public sealed record SonarrConfig : ArrConfig
|
||||
{
|
||||
public const string SectionName = "Sonarr";
|
||||
|
||||
public SonarrSearchType SearchType { get; init; }
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Common.Configuration.Arr;
|
||||
|
||||
public enum SonarrSearchType
|
||||
{
|
||||
Episode,
|
||||
Season,
|
||||
Series
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
namespace Common.Configuration.ContentBlocker;
|
||||
|
||||
public sealed record ContentBlockerConfig : IJobConfig
|
||||
{
|
||||
public const string SectionName = "ContentBlocker";
|
||||
|
||||
public required bool Enabled { get; init; }
|
||||
|
||||
public PatternConfig? Blacklist { get; init; }
|
||||
|
||||
public PatternConfig? Whitelist { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Blacklist is null && Whitelist is null)
|
||||
{
|
||||
throw new Exception("content blocker is enabled, but both blacklist and whitelist are missing");
|
||||
}
|
||||
|
||||
if (Blacklist?.Enabled is true && Whitelist?.Enabled is true)
|
||||
{
|
||||
throw new Exception("only one exclusion (blacklist/whitelist) list is allowed");
|
||||
}
|
||||
|
||||
if (Blacklist?.Enabled is true && string.IsNullOrEmpty(Blacklist.Path))
|
||||
{
|
||||
throw new Exception("blacklist path is required");
|
||||
}
|
||||
|
||||
if (Whitelist?.Enabled is true && string.IsNullOrEmpty(Whitelist.Path))
|
||||
{
|
||||
throw new Exception("blacklist path is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Common.Configuration.ContentBlocker;
|
||||
|
||||
public sealed record PatternConfig
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string? Path { get; init; }
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
namespace Common.Configuration.DownloadClient;
|
||||
|
||||
public sealed record DelugeConfig : IConfig
|
||||
{
|
||||
public const string SectionName = "Deluge";
|
||||
|
||||
public Uri? Url { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Url is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(Url));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
namespace Common.Configuration.DownloadClient;
|
||||
|
||||
public sealed class QBitConfig : IConfig
|
||||
{
|
||||
public const string SectionName = "qBittorrent";
|
||||
|
||||
public Uri? Url { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Url is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(Url));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
namespace Common.Configuration.DownloadClient;
|
||||
|
||||
public record TransmissionConfig : IConfig
|
||||
{
|
||||
public const string SectionName = "Transmission";
|
||||
|
||||
public Uri? Url { get; init; }
|
||||
|
||||
public string? Username { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Url is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(Url));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public static class EnvironmentVariables
|
||||
{
|
||||
public const string DownloadClient = "DOWNLOAD_CLIENT";
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public interface IJobConfig : IConfig
|
||||
{
|
||||
bool Enabled { get; init; }
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace Common.Configuration.Logging;
|
||||
|
||||
public class FileLogConfig : IConfig
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using Serilog.Events;
|
||||
|
||||
namespace Common.Configuration.Logging;
|
||||
|
||||
public class LoggingConfig : IConfig
|
||||
{
|
||||
public const string SectionName = "Logging";
|
||||
|
||||
public LogEventLevel LogLevel { get; set; }
|
||||
|
||||
public bool Enhanced { get; set; }
|
||||
|
||||
public FileLogConfig? File { get; set; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Common.Configuration.QueueCleaner;
|
||||
|
||||
public sealed record QueueCleanerConfig : IJobConfig
|
||||
{
|
||||
public const string SectionName = "QueueCleaner";
|
||||
|
||||
public required bool Enabled { get; init; }
|
||||
|
||||
public required bool RunSequentially { get; init; }
|
||||
|
||||
[ConfigurationKeyName("IMPORT_FAILED_MAX_STRIKES")]
|
||||
public ushort ImportFailedMaxStrikes { get; init; }
|
||||
|
||||
[ConfigurationKeyName("STALLED_MAX_STRIKES")]
|
||||
public ushort StalledMaxStrikes { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public sealed class TriggersConfig
|
||||
{
|
||||
public const string SectionName = "Triggers";
|
||||
|
||||
public required string QueueCleaner { get; init; }
|
||||
|
||||
public required string ContentBlocker { get; init; }
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Common.Helpers;
|
||||
|
||||
public static class Constants
|
||||
{
|
||||
public static readonly TimeSpan TriggerMaxLimit = TimeSpan.FromHours(6);
|
||||
public static readonly TimeSpan CacheLimitBuffer = TimeSpan.FromHours(2);
|
||||
}
|
||||
78
code/Dockerfile
Normal file
78
code/Dockerfile
Normal file
@@ -0,0 +1,78 @@
|
||||
# Build Angular frontend
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-build
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first for better layer caching
|
||||
COPY frontend/package*.json ./
|
||||
# Use cache mount for npm to speed up builds
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci && npm install -g @angular/cli
|
||||
|
||||
# Copy source code
|
||||
COPY frontend/ .
|
||||
|
||||
# Build with appropriate base-href and deploy-url
|
||||
RUN npm run build
|
||||
|
||||
# Build .NET backend
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build
|
||||
ARG TARGETARCH
|
||||
ARG VERSION=0.0.1
|
||||
ARG PACKAGES_USERNAME
|
||||
ARG PACKAGES_PAT
|
||||
WORKDIR /app
|
||||
EXPOSE 11011
|
||||
|
||||
# Copy solution and project files first for better layer caching
|
||||
# COPY backend/*.sln ./backend/
|
||||
# COPY backend/*/*.csproj ./backend/*/
|
||||
|
||||
# Copy source code
|
||||
COPY backend/ ./backend/
|
||||
|
||||
# Add NuGet source
|
||||
RUN dotnet nuget add source --username ${PACKAGES_USERNAME} --password ${PACKAGES_PAT} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
|
||||
# Restore and publish with cache mount
|
||||
RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
|
||||
dotnet restore ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj -a $TARGETARCH && \
|
||||
dotnet publish ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj \
|
||||
-a $TARGETARCH \
|
||||
-c Release \
|
||||
-o /app/publish \
|
||||
--no-restore \
|
||||
/p:Version=${VERSION} \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:DebugSymbols=false
|
||||
|
||||
# Runtime stage
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
|
||||
|
||||
# Install required packages for user management and timezone support
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
tzdata \
|
||||
gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
TZ=Etc/UTC \
|
||||
HTTP_PORTS=11011
|
||||
|
||||
# Fix FileSystemWatcher in Docker: https://github.com/dotnet/dotnet-docker/issues/3546
|
||||
ENV DOTNET_USE_POLLING_FILE_WATCHER=true
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy backend
|
||||
COPY --from=build /app/publish .
|
||||
# Copy frontend to wwwroot
|
||||
COPY --from=frontend-build /app/dist/ui/browser ./wwwroot
|
||||
# Copy entrypoint script
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["./Cleanuparr"]
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Domain.Enums;
|
||||
|
||||
public enum DownloadClient
|
||||
{
|
||||
QBittorrent,
|
||||
Deluge,
|
||||
Transmission
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Domain.Enums;
|
||||
|
||||
public enum InstanceType
|
||||
{
|
||||
Sonarr,
|
||||
Radarr,
|
||||
Lidarr,
|
||||
Readarr
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Domain.Enums;
|
||||
|
||||
public enum StrikeType
|
||||
{
|
||||
Stalled,
|
||||
ImportFailed
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace Domain.Models.Arr.Queue;
|
||||
|
||||
public record QueueRecord
|
||||
{
|
||||
public int SeriesId { get; init; }
|
||||
public int EpisodeId { get; init; }
|
||||
public int SeasonNumber { get; init; }
|
||||
public int MovieId { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public string Status { get; init; }
|
||||
public string TrackedDownloadStatus { get; init; }
|
||||
public string TrackedDownloadState { get; init; }
|
||||
public required string DownloadId { get; init; }
|
||||
public required string Protocol { get; init; }
|
||||
public required int Id { get; init; }
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace Domain.Models.Deluge.Response;
|
||||
|
||||
public sealed record TorrentStatus
|
||||
{
|
||||
public string? Hash { get; set; }
|
||||
|
||||
public string? State { get; set; }
|
||||
|
||||
public string? Name { get; set; }
|
||||
|
||||
public ulong Eta { get; set; }
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.Logging;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Domain.Enums;
|
||||
|
||||
namespace Executable.DependencyInjection;
|
||||
|
||||
public static class ConfigurationDI
|
||||
{
|
||||
public static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.Configure<QueueCleanerConfig>(configuration.GetSection(QueueCleanerConfig.SectionName))
|
||||
.Configure<ContentBlockerConfig>(configuration.GetSection(ContentBlockerConfig.SectionName))
|
||||
.Configure<QBitConfig>(configuration.GetSection(QBitConfig.SectionName))
|
||||
.Configure<DelugeConfig>(configuration.GetSection(DelugeConfig.SectionName))
|
||||
.Configure<TransmissionConfig>(configuration.GetSection(TransmissionConfig.SectionName))
|
||||
.Configure<SonarrConfig>(configuration.GetSection(SonarrConfig.SectionName))
|
||||
.Configure<RadarrConfig>(configuration.GetSection(RadarrConfig.SectionName))
|
||||
.Configure<LoggingConfig>(configuration.GetSection(LoggingConfig.SectionName));
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using Common.Configuration.Logging;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Templates;
|
||||
using Serilog.Templates.Themes;
|
||||
|
||||
namespace Executable.DependencyInjection;
|
||||
|
||||
public static class LoggingDI
|
||||
{
|
||||
public static ILoggingBuilder AddLogging(this ILoggingBuilder builder, IConfiguration configuration)
|
||||
{
|
||||
LoggingConfig? config = configuration.GetSection(LoggingConfig.SectionName).Get<LoggingConfig>();
|
||||
|
||||
if (!string.IsNullOrEmpty(config?.File?.Path) && !Directory.Exists(config.File.Path))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(config.File.Path);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new Exception($"log file path is not a valid directory | {config.File.Path}", exception);
|
||||
}
|
||||
}
|
||||
|
||||
LoggerConfiguration logConfig = new();
|
||||
const string consoleOutputTemplate = "[{@t:yyyy-MM-dd HH:mm:ss.fff} {@l:u3}]{#if JobName is not null} {Concat('[',JobName,']'),PAD}{#end} {@m}\n{@x}";
|
||||
const string fileOutputTemplate = "{@t:yyyy-MM-dd HH:mm:ss.fff zzz} [{@l:u3}]{#if JobName is not null} {Concat('[',JobName,']'),PAD}{#end} {@m:lj}\n{@x}";
|
||||
LogEventLevel level = LogEventLevel.Information;
|
||||
List<string> jobNames = [nameof(ContentBlocker), nameof(QueueCleaner)];
|
||||
int padding = jobNames.Max(x => x.Length) + 2;
|
||||
|
||||
if (config is not null)
|
||||
{
|
||||
level = config.LogLevel;
|
||||
|
||||
if (config.File?.Enabled is true)
|
||||
{
|
||||
logConfig.WriteTo.File(
|
||||
path: Path.Combine(config.File.Path, "cleanuperr-.txt"),
|
||||
formatter: new ExpressionTemplate(fileOutputTemplate.Replace("PAD", padding.ToString())),
|
||||
fileSizeLimitBytes: 10L * 1024 * 1024,
|
||||
rollingInterval: RollingInterval.Day,
|
||||
rollOnFileSizeLimit: true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Log.Logger = logConfig
|
||||
.MinimumLevel.Is(level)
|
||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
||||
.MinimumLevel.Override("Microsoft.Extensions.Http", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
|
||||
.WriteTo.Console(new ExpressionTemplate(consoleOutputTemplate.Replace("PAD", padding.ToString())))
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithProperty("ApplicationName", "cleanuperr")
|
||||
.CreateLogger();
|
||||
|
||||
return builder
|
||||
.ClearProviders()
|
||||
.AddSerilog();
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using System.Net;
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Executable.Jobs;
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
using Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||
using Infrastructure.Verticals.DownloadClient.Transmission;
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
|
||||
namespace Executable.DependencyInjection;
|
||||
|
||||
public static class MainDI
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddLogging(builder => builder.ClearProviders().AddConsole())
|
||||
.AddHttpClients()
|
||||
.AddConfiguration(configuration)
|
||||
.AddMemoryCache()
|
||||
.AddServices()
|
||||
.AddQuartzServices(configuration);
|
||||
|
||||
private static IServiceCollection AddHttpClients(this IServiceCollection services)
|
||||
{
|
||||
// add default HttpClient
|
||||
services.AddHttpClient();
|
||||
|
||||
// add Deluge HttpClient
|
||||
services
|
||||
.AddHttpClient(nameof(DelugeService), x =>
|
||||
{
|
||||
x.Timeout = TimeSpan.FromSeconds(5);
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(_ =>
|
||||
{
|
||||
return new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = true,
|
||||
UseCookies = true,
|
||||
CookieContainer = new CookieContainer(),
|
||||
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Common.Helpers;
|
||||
using Executable.Jobs;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.Jobs;
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
using Quartz;
|
||||
using Quartz.Spi;
|
||||
|
||||
namespace Executable.DependencyInjection;
|
||||
|
||||
public static class QuartzDI
|
||||
{
|
||||
public static IServiceCollection AddQuartzServices(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddQuartz(q =>
|
||||
{
|
||||
TriggersConfig? config = configuration
|
||||
.GetRequiredSection(TriggersConfig.SectionName)
|
||||
.Get<TriggersConfig>();
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
throw new NullReferenceException("triggers configuration is null");
|
||||
}
|
||||
|
||||
q.AddJobs(configuration, config);
|
||||
})
|
||||
.AddQuartzHostedService(opt =>
|
||||
{
|
||||
opt.WaitForJobsToComplete = true;
|
||||
});
|
||||
|
||||
private static void AddJobs(
|
||||
this IServiceCollectionQuartzConfigurator q,
|
||||
IConfiguration configuration,
|
||||
TriggersConfig triggersConfig
|
||||
)
|
||||
{
|
||||
ContentBlockerConfig? contentBlockerConfig = configuration
|
||||
.GetRequiredSection(ContentBlockerConfig.SectionName)
|
||||
.Get<ContentBlockerConfig>();
|
||||
|
||||
q.AddJob<ContentBlocker>(contentBlockerConfig, triggersConfig.ContentBlocker);
|
||||
|
||||
QueueCleanerConfig? queueCleanerConfig = configuration
|
||||
.GetRequiredSection(QueueCleanerConfig.SectionName)
|
||||
.Get<QueueCleanerConfig>();
|
||||
|
||||
if (contentBlockerConfig?.Enabled is true && queueCleanerConfig is { Enabled: true, RunSequentially: true })
|
||||
{
|
||||
q.AddJob<QueueCleaner>(queueCleanerConfig, string.Empty);
|
||||
q.AddJobListener(new JobChainingListener(nameof(QueueCleaner)));
|
||||
}
|
||||
else
|
||||
{
|
||||
q.AddJob<QueueCleaner>(queueCleanerConfig, triggersConfig.QueueCleaner);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddJob<T>(
|
||||
this IServiceCollectionQuartzConfigurator q,
|
||||
IJobConfig? config,
|
||||
string trigger
|
||||
) where T: GenericHandler
|
||||
{
|
||||
string typeName = typeof(T).Name;
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
throw new NullReferenceException($"{typeName} configuration is null");
|
||||
}
|
||||
|
||||
if (!config.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool hasTrigger = trigger.Length > 0;
|
||||
|
||||
q.AddJob<GenericJob<T>>(opts =>
|
||||
{
|
||||
opts.WithIdentity(typeName);
|
||||
|
||||
if (!hasTrigger)
|
||||
{
|
||||
// jobs with no triggers need to be stored durably
|
||||
opts.StoreDurably();
|
||||
}
|
||||
});
|
||||
|
||||
// skip empty triggers
|
||||
if (!hasTrigger)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IOperableTrigger triggerObj = (IOperableTrigger)TriggerBuilder.Create()
|
||||
.WithIdentity("ExampleTrigger")
|
||||
.StartNow()
|
||||
.WithCronSchedule(trigger)
|
||||
.Build();
|
||||
|
||||
IReadOnlyList<DateTimeOffset> nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2);
|
||||
TimeSpan triggerValue = nextFireTimes[1] - nextFireTimes[0];
|
||||
|
||||
if (triggerValue > Constants.TriggerMaxLimit)
|
||||
{
|
||||
throw new Exception($"{trigger} should have a fire time of maximum 1 hour");
|
||||
}
|
||||
|
||||
if (triggerValue > StaticConfiguration.TriggerValue)
|
||||
{
|
||||
StaticConfiguration.TriggerValue = triggerValue;
|
||||
}
|
||||
|
||||
q.AddTrigger(opts =>
|
||||
{
|
||||
opts.ForJob(typeName)
|
||||
.WithIdentity($"{typeName}-trigger")
|
||||
.WithCronSchedule(trigger, x =>x.WithMisfireHandlingInstructionDoNothing())
|
||||
.StartNow();
|
||||
});
|
||||
|
||||
// Startup trigger
|
||||
q.AddTrigger(opts =>
|
||||
{
|
||||
opts.ForJob(typeName)
|
||||
.WithIdentity($"{typeName}-startup-trigger")
|
||||
.StartNow();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
using Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||
using Infrastructure.Verticals.DownloadClient.Transmission;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Infrastructure.Verticals.QueueCleaner;
|
||||
|
||||
namespace Executable.DependencyInjection;
|
||||
|
||||
public static class ServicesDI
|
||||
{
|
||||
public static IServiceCollection AddServices(this IServiceCollection services) =>
|
||||
services
|
||||
.AddTransient<SonarrClient>()
|
||||
.AddTransient<RadarrClient>()
|
||||
.AddTransient<QueueCleaner>()
|
||||
.AddTransient<ContentBlocker>()
|
||||
.AddTransient<FilenameEvaluator>()
|
||||
.AddTransient<QBitService>()
|
||||
.AddTransient<DelugeService>()
|
||||
.AddTransient<TransmissionService>()
|
||||
.AddTransient<ArrQueueIterator>()
|
||||
.AddTransient<DownloadServiceFactory>()
|
||||
.AddSingleton<BlocklistProvider>()
|
||||
.AddSingleton<Striker>();
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>cleanuperr</AssemblyName>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Version Condition="'$(Version)' == ''">0.0.1</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-Executable-6108b2ba-f035-47bc-addf-aaf5e20da4b8</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||
<PackageReference Include="Quartz" Version="3.13.1" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.13.1" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.13.1" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Common\Common.csproj" />
|
||||
<ProjectReference Include="..\Infrastructure\Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,33 +0,0 @@
|
||||
using Infrastructure.Verticals.Jobs;
|
||||
using Quartz;
|
||||
using Serilog.Context;
|
||||
|
||||
namespace Executable.Jobs;
|
||||
|
||||
[DisallowConcurrentExecution]
|
||||
public sealed class GenericJob<T> : IJob
|
||||
where T : GenericHandler
|
||||
{
|
||||
private readonly ILogger<GenericJob<T>> _logger;
|
||||
private readonly T _handler;
|
||||
|
||||
public GenericJob(ILogger<GenericJob<T>> logger, T handler)
|
||||
{
|
||||
_logger = logger;
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
using var _ = LogContext.PushProperty("JobName", typeof(T).Name);
|
||||
|
||||
try
|
||||
{
|
||||
await _handler.ExecuteAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "{name} failed", typeof(T).Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using System.Reflection;
|
||||
using Executable.DependencyInjection;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
builder.Logging.AddLogging(builder.Configuration);
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
var logger = host.Services.GetRequiredService<ILogger<Program>>();
|
||||
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
|
||||
logger.LogInformation(
|
||||
version is null
|
||||
? "cleanuperr version not detected"
|
||||
: $"cleanuperr v{version.Major}.{version.Minor}.{version.Build}"
|
||||
);
|
||||
|
||||
host.Run();
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": "Debug",
|
||||
"Enhanced": true,
|
||||
"File": {
|
||||
"Enabled": false,
|
||||
"Path": ""
|
||||
}
|
||||
},
|
||||
"Triggers": {
|
||||
"QueueCleaner": "0/10 * * * * ?",
|
||||
"ContentBlocker": "0/10 * * * * ?"
|
||||
},
|
||||
"ContentBlocker": {
|
||||
"Enabled": true,
|
||||
"Blacklist": {
|
||||
"Enabled": false,
|
||||
"Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist"
|
||||
},
|
||||
"Whitelist": {
|
||||
"Enabled": false,
|
||||
"Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/whitelist"
|
||||
}
|
||||
},
|
||||
"QueueCleaner": {
|
||||
"Enabled": true,
|
||||
"RunSequentially": true,
|
||||
"IMPORT_FAILED_MAX_STRIKES": 5,
|
||||
"STALLED_MAX_STRIKES": 5
|
||||
},
|
||||
"DOWNLOAD_CLIENT": "qbittorrent",
|
||||
"qBittorrent": {
|
||||
"Url": "http://localhost:8080",
|
||||
"Username": "test",
|
||||
"Password": "testing"
|
||||
},
|
||||
"Deluge": {
|
||||
"Url": "http://localhost:8112",
|
||||
"Password": "testing"
|
||||
},
|
||||
"Transmission": {
|
||||
"Url": "http://localhost:9091",
|
||||
"Username": "test",
|
||||
"Password": "testing"
|
||||
},
|
||||
"Sonarr": {
|
||||
"Enabled": true,
|
||||
"SearchType": "Episode",
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:8989",
|
||||
"ApiKey": "96736c3eb3144936b8f1d62d27be8cee"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Radarr": {
|
||||
"Enabled": true,
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:7878",
|
||||
"ApiKey": "705b553732ab4167ab23909305d60600"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": "Information",
|
||||
"Enhanced": true,
|
||||
"File": {
|
||||
"Enabled": false,
|
||||
"Path": ""
|
||||
}
|
||||
},
|
||||
"Triggers": {
|
||||
"QueueCleaner": "0 0/5 * * * ?",
|
||||
"ContentBlocker": "0 0/5 * * * ?"
|
||||
},
|
||||
"ContentBlocker": {
|
||||
"Enabled": false,
|
||||
"Blacklist": {
|
||||
"Enabled": false,
|
||||
"Path": ""
|
||||
},
|
||||
"Whitelist": {
|
||||
"Enabled": false,
|
||||
"Path": ""
|
||||
}
|
||||
},
|
||||
"QueueCleaner": {
|
||||
"Enabled": true,
|
||||
"RunSequentially": true,
|
||||
"IMPORT_FAILED_MAX_STRIKES": 5,
|
||||
"STALLED_MAX_STRIKES": 5
|
||||
},
|
||||
"DOWNLOAD_CLIENT": "qbittorrent",
|
||||
"qBittorrent": {
|
||||
"Url": "http://localhost:8080",
|
||||
"Username": "",
|
||||
"Password": ""
|
||||
},
|
||||
"Deluge": {
|
||||
"Url": "http://localhost:8112",
|
||||
"Password": "testing"
|
||||
},
|
||||
"Transmission": {
|
||||
"Url": "http://localhost:9091",
|
||||
"Username": "test",
|
||||
"Password": "testing"
|
||||
},
|
||||
"Sonarr": {
|
||||
"Enabled": true,
|
||||
"SearchType": "Episode",
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:8989",
|
||||
"ApiKey": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"Radarr": {
|
||||
"Enabled": false,
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:7878",
|
||||
"ApiKey": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Common\Common.csproj" />
|
||||
<ProjectReference Include="..\Domain\Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FLM.Transmission" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||
<PackageReference Include="QBittorrent.Client" Version="1.9.24285.1" />
|
||||
<PackageReference Include="Quartz" Version="3.13.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,137 +0,0 @@
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.Logging;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Domain.Enums;
|
||||
using Domain.Models.Arr;
|
||||
using Domain.Models.Arr.Queue;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Infrastructure.Verticals.Arr;
|
||||
|
||||
public abstract class ArrClient
|
||||
{
|
||||
protected readonly ILogger<ArrClient> _logger;
|
||||
protected readonly HttpClient _httpClient;
|
||||
protected readonly LoggingConfig _loggingConfig;
|
||||
protected readonly QueueCleanerConfig _queueCleanerConfig;
|
||||
protected readonly Striker _striker;
|
||||
|
||||
protected ArrClient(
|
||||
ILogger<ArrClient> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<LoggingConfig> loggingConfig,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
Striker striker
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_striker = striker;
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_loggingConfig = loggingConfig.Value;
|
||||
_queueCleanerConfig = queueCleanerConfig.Value;
|
||||
_striker = striker;
|
||||
}
|
||||
|
||||
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
|
||||
{
|
||||
Uri uri = new(arrInstance.Url, GetQueueUrlPath(page));
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("queue list failed | {uri}", uri);
|
||||
throw;
|
||||
}
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
QueueListResponse? queueResponse = JsonConvert.DeserializeObject<QueueListResponse>(responseBody);
|
||||
|
||||
if (queueResponse is null)
|
||||
{
|
||||
throw new Exception($"unrecognized queue list response | {uri} | {responseBody}");
|
||||
}
|
||||
|
||||
return queueResponse;
|
||||
}
|
||||
|
||||
public virtual bool ShouldRemoveFromQueue(QueueRecord record)
|
||||
{
|
||||
bool hasWarn() => record.TrackedDownloadStatus
|
||||
.Equals("warning", StringComparison.InvariantCultureIgnoreCase);
|
||||
bool isImportBlocked() => record.TrackedDownloadState
|
||||
.Equals("importBlocked", StringComparison.InvariantCultureIgnoreCase);
|
||||
bool isImportPending() => record.TrackedDownloadState
|
||||
.Equals("importPending", StringComparison.InvariantCultureIgnoreCase);
|
||||
|
||||
if (hasWarn() && (isImportBlocked() || isImportPending()))
|
||||
{
|
||||
return _striker.StrikeAndCheckLimit(
|
||||
record.DownloadId,
|
||||
record.Title,
|
||||
_queueCleanerConfig.ImportFailedMaxStrikes,
|
||||
StrikeType.ImportFailed
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public virtual async Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord queueRecord)
|
||||
{
|
||||
Uri uri = new(arrInstance.Url, $"/api/v3/queue/{queueRecord.Id}?removeFromClient=true&blocklist=true&skipRedownload=true&changeCategory=false");
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Delete, uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
_logger.LogInformation("queue item deleted | {url} | {title}", arrInstance.Url, queueRecord.Title);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("queue delete failed | {uri} | {title}", uri, queueRecord.Title);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public abstract Task RefreshItemsAsync(ArrInstance arrInstance, ArrConfig config, HashSet<SearchItem>? items);
|
||||
|
||||
public virtual bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (string.IsNullOrEmpty(record.DownloadId))
|
||||
{
|
||||
_logger.LogDebug("skip | download id is null for {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (record.DownloadId.Equals(record.Title, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
_logger.LogDebug("skip | item is not ready yet | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected abstract string GetQueueUrlPath(int page);
|
||||
|
||||
protected virtual void SetApiKey(HttpRequestMessage request, string apiKey)
|
||||
{
|
||||
request.Headers.Add("x-api-key", apiKey);
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using Common.Configuration.ContentBlocker;
|
||||
using Domain.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.ContentBlocker;
|
||||
|
||||
public sealed class BlocklistProvider
|
||||
{
|
||||
private readonly ILogger<BlocklistProvider> _logger;
|
||||
private readonly ContentBlockerConfig _config;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public BlocklistType BlocklistType { get; }
|
||||
|
||||
public ConcurrentBag<string> Patterns { get; } = [];
|
||||
|
||||
public ConcurrentBag<Regex> Regexes { get; } = [];
|
||||
|
||||
public BlocklistProvider(
|
||||
ILogger<BlocklistProvider> logger,
|
||||
IOptions<ContentBlockerConfig> config,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_config = config.Value;
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
|
||||
_config.Validate();
|
||||
|
||||
if (_config.Blacklist?.Enabled is true)
|
||||
{
|
||||
BlocklistType = BlocklistType.Blacklist;
|
||||
}
|
||||
|
||||
if (_config.Whitelist?.Enabled is true)
|
||||
{
|
||||
BlocklistType = BlocklistType.Whitelist;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LoadBlocklistAsync()
|
||||
{
|
||||
if (Patterns.Count > 0 || Regexes.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("blocklist already loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await LoadPatternsAndRegexesAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("failed to load {type}", BlocklistType.ToString());
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadPatternsAndRegexesAsync()
|
||||
{
|
||||
string[] patterns;
|
||||
|
||||
if (BlocklistType is BlocklistType.Blacklist)
|
||||
{
|
||||
patterns = await ReadContentAsync(_config.Blacklist.Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
patterns = await ReadContentAsync(_config.Whitelist.Path);
|
||||
}
|
||||
|
||||
long startTime = Stopwatch.GetTimestamp();
|
||||
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
|
||||
const string regexId = "regex:";
|
||||
|
||||
Parallel.ForEach(patterns, options, pattern =>
|
||||
{
|
||||
if (!pattern.StartsWith(regexId))
|
||||
{
|
||||
Patterns.Add(pattern);
|
||||
return;
|
||||
}
|
||||
|
||||
pattern = pattern[regexId.Length..];
|
||||
|
||||
try
|
||||
{
|
||||
Regex regex = new(pattern, RegexOptions.Compiled);
|
||||
Regexes.Add(regex);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
_logger.LogWarning("invalid regex | {pattern}", pattern);
|
||||
}
|
||||
});
|
||||
|
||||
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
|
||||
|
||||
_logger.LogDebug("loaded {count} patterns", Patterns.Count);
|
||||
_logger.LogDebug("loaded {count} regexes", Regexes.Count);
|
||||
_logger.LogDebug("blocklist loaded in {elapsed} ms", elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
private async Task<string[]> ReadContentAsync(string path)
|
||||
{
|
||||
if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
{
|
||||
// http(s) url
|
||||
return await ReadFromUrlAsync(path);
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
// local file path
|
||||
return await File.ReadAllLinesAsync(path);
|
||||
}
|
||||
|
||||
throw new ArgumentException($"blocklist not found | {path}");
|
||||
}
|
||||
|
||||
private async Task<string[]> ReadFromUrlAsync(string url)
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return (await response.Content.ReadAsStringAsync())
|
||||
.Split(['\r','\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.Arr;
|
||||
using Domain.Enums;
|
||||
using Domain.Models.Arr.Queue;
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Infrastructure.Verticals.Jobs;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.ContentBlocker;
|
||||
|
||||
public sealed class ContentBlocker : GenericHandler
|
||||
{
|
||||
private readonly BlocklistProvider _blocklistProvider;
|
||||
|
||||
public ContentBlocker(
|
||||
ILogger<ContentBlocker> logger,
|
||||
IOptions<SonarrConfig> sonarrConfig,
|
||||
IOptions<RadarrConfig> radarrConfig,
|
||||
SonarrClient sonarrClient,
|
||||
RadarrClient radarrClient,
|
||||
ArrQueueIterator arrArrQueueIterator,
|
||||
BlocklistProvider blocklistProvider,
|
||||
DownloadServiceFactory downloadServiceFactory
|
||||
) : base(logger, sonarrConfig.Value, radarrConfig.Value, sonarrClient, radarrClient, arrArrQueueIterator, downloadServiceFactory)
|
||||
{
|
||||
_blocklistProvider = blocklistProvider;
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
await _blocklistProvider.LoadBlocklistAsync();
|
||||
await base.ExecuteAsync();
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||
{
|
||||
ArrClient arrClient = GetClient(instanceType);
|
||||
|
||||
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
|
||||
{
|
||||
foreach (QueueRecord record in items)
|
||||
{
|
||||
if (record.Protocol is not "torrent")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(record.DownloadId))
|
||||
{
|
||||
_logger.LogDebug("skip | download id is null for {title}", record.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug("searching unwanted files for {title}", record.Title);
|
||||
await _downloadService.BlockUnwantedFilesAsync(record.DownloadId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
using Domain.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Infrastructure.Verticals.ContentBlocker;
|
||||
|
||||
public sealed class FilenameEvaluator
|
||||
{
|
||||
private readonly ILogger<FilenameEvaluator> _logger;
|
||||
private readonly BlocklistProvider _blocklistProvider;
|
||||
|
||||
public FilenameEvaluator(ILogger<FilenameEvaluator> logger, BlocklistProvider blocklistProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_blocklistProvider = blocklistProvider;
|
||||
}
|
||||
|
||||
// TODO create unit tests
|
||||
public bool IsValid(string filename)
|
||||
{
|
||||
return IsValidAgainstPatterns(filename) && IsValidAgainstRegexes(filename);
|
||||
}
|
||||
|
||||
private bool IsValidAgainstPatterns(string filename)
|
||||
{
|
||||
if (_blocklistProvider.Patterns.Count is 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return _blocklistProvider.BlocklistType switch
|
||||
{
|
||||
BlocklistType.Blacklist => !_blocklistProvider.Patterns.Any(pattern => MatchesPattern(filename, pattern)),
|
||||
BlocklistType.Whitelist => _blocklistProvider.Patterns.Any(pattern => MatchesPattern(filename, pattern)),
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsValidAgainstRegexes(string filename)
|
||||
{
|
||||
if (_blocklistProvider.Regexes.Count is 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return _blocklistProvider.BlocklistType switch
|
||||
{
|
||||
BlocklistType.Blacklist => !_blocklistProvider.Regexes.Any(regex => regex.IsMatch(filename)),
|
||||
BlocklistType.Whitelist => _blocklistProvider.Regexes.Any(regex => regex.IsMatch(filename)),
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(string filename, string pattern)
|
||||
{
|
||||
bool hasStartWildcard = pattern.StartsWith('*');
|
||||
bool hasEndWildcard = pattern.EndsWith('*');
|
||||
|
||||
if (hasStartWildcard && hasEndWildcard)
|
||||
{
|
||||
return filename.Contains(
|
||||
pattern.Substring(1, pattern.Length - 2),
|
||||
StringComparison.InvariantCultureIgnoreCase
|
||||
);
|
||||
}
|
||||
|
||||
if (hasStartWildcard)
|
||||
{
|
||||
return filename.EndsWith(pattern.Substring(1), StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
|
||||
if (hasEndWildcard)
|
||||
{
|
||||
return filename.StartsWith(
|
||||
pattern.Substring(0, pattern.Length - 1),
|
||||
StringComparison.InvariantCultureIgnoreCase
|
||||
);
|
||||
}
|
||||
|
||||
return filename == pattern;
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Domain.Models.Deluge.Response;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
|
||||
public sealed class DelugeService : DownloadServiceBase
|
||||
{
|
||||
private readonly DelugeClient _client;
|
||||
|
||||
public DelugeService(
|
||||
ILogger<DelugeService> logger,
|
||||
IOptions<DelugeConfig> config,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
FilenameEvaluator filenameEvaluator,
|
||||
Striker striker
|
||||
) : base(logger, queueCleanerConfig, filenameEvaluator, striker)
|
||||
{
|
||||
config.Value.Validate();
|
||||
_client = new (config, httpClientFactory);
|
||||
}
|
||||
|
||||
public override async Task LoginAsync()
|
||||
{
|
||||
await _client.LoginAsync();
|
||||
}
|
||||
|
||||
public override async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
|
||||
{
|
||||
hash = hash.ToLowerInvariant();
|
||||
|
||||
DelugeContents? contents = null;
|
||||
|
||||
TorrentStatus? status = await GetTorrentStatus(hash);
|
||||
|
||||
if (status?.Hash is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
contents = await _client.GetTorrentFiles(hash);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
|
||||
}
|
||||
|
||||
bool shouldRemove = contents?.Contents?.Count > 0;
|
||||
|
||||
ProcessFiles(contents.Contents, (_, file) =>
|
||||
{
|
||||
if (file.Priority > 0)
|
||||
{
|
||||
shouldRemove = false;
|
||||
}
|
||||
});
|
||||
|
||||
return shouldRemove || IsItemStuckAndShouldRemove(status);
|
||||
}
|
||||
|
||||
public override async Task BlockUnwantedFilesAsync(string hash)
|
||||
{
|
||||
hash = hash.ToLowerInvariant();
|
||||
|
||||
TorrentStatus? status = await GetTorrentStatus(hash);
|
||||
|
||||
if (status?.Hash is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return;
|
||||
}
|
||||
|
||||
DelugeContents? contents = null;
|
||||
|
||||
try
|
||||
{
|
||||
contents = await _client.GetTorrentFiles(hash);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash);
|
||||
}
|
||||
|
||||
if (contents is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<int, int> priorities = [];
|
||||
bool hasPriorityUpdates = false;
|
||||
|
||||
ProcessFiles(contents.Contents, (name, file) =>
|
||||
{
|
||||
int priority = file.Priority;
|
||||
|
||||
if (file.Priority is not 0 && !_filenameEvaluator.IsValid(name))
|
||||
{
|
||||
priority = 0;
|
||||
hasPriorityUpdates = true;
|
||||
_logger.LogInformation("unwanted file found | {file}", file.Path);
|
||||
}
|
||||
|
||||
priorities.Add(file.Index, priority);
|
||||
});
|
||||
|
||||
if (!hasPriorityUpdates)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("changing priorities | torrent {hash}", hash);
|
||||
|
||||
List<int> sortedPriorities = priorities
|
||||
.OrderBy(x => x.Key)
|
||||
.Select(x => x.Value)
|
||||
.ToList();
|
||||
|
||||
await _client.ChangeFilesPriority(hash, sortedPriorities);
|
||||
}
|
||||
|
||||
private bool IsItemStuckAndShouldRemove(TorrentStatus status)
|
||||
{
|
||||
if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (status.Eta > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return StrikeAndCheckLimit(status.Hash!, status.Name!);
|
||||
}
|
||||
|
||||
private async Task<TorrentStatus?> GetTorrentStatus(string hash)
|
||||
{
|
||||
return await _client.SendRequest<TorrentStatus?>(
|
||||
"web.get_torrent_status",
|
||||
hash,
|
||||
new[] { "hash", "state", "name", "eta" }
|
||||
);
|
||||
}
|
||||
|
||||
private static void ProcessFiles(Dictionary<string, DelugeFileOrDirectory> contents, Action<string, DelugeFileOrDirectory> processFile)
|
||||
{
|
||||
foreach (var (name, data) in contents)
|
||||
{
|
||||
switch (data.Type)
|
||||
{
|
||||
case "file":
|
||||
processFile(name, data);
|
||||
break;
|
||||
case "dir" when data.Contents is not null:
|
||||
// Recurse into subdirectories
|
||||
ProcessFiles(data.Contents, processFile);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Domain.Enums;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient;
|
||||
|
||||
public abstract class DownloadServiceBase : IDownloadService
|
||||
{
|
||||
protected readonly ILogger<DownloadServiceBase> _logger;
|
||||
protected readonly QueueCleanerConfig _queueCleanerConfig;
|
||||
protected readonly FilenameEvaluator _filenameEvaluator;
|
||||
protected readonly Striker _striker;
|
||||
|
||||
protected DownloadServiceBase(
|
||||
ILogger<DownloadServiceBase> logger,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
FilenameEvaluator filenameEvaluator,
|
||||
Striker striker
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_queueCleanerConfig = queueCleanerConfig.Value;
|
||||
_filenameEvaluator = filenameEvaluator;
|
||||
_striker = striker;
|
||||
}
|
||||
|
||||
public abstract void Dispose();
|
||||
|
||||
public abstract Task LoginAsync();
|
||||
|
||||
public abstract Task<bool> ShouldRemoveFromArrQueueAsync(string hash);
|
||||
|
||||
public abstract Task BlockUnwantedFilesAsync(string hash);
|
||||
|
||||
protected bool StrikeAndCheckLimit(string hash, string itemName)
|
||||
{
|
||||
return _striker.StrikeAndCheckLimit(hash, itemName, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using Common.Configuration;
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Infrastructure.Verticals.DownloadClient.Deluge;
|
||||
using Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||
using Infrastructure.Verticals.DownloadClient.Transmission;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient;
|
||||
|
||||
public sealed class DownloadServiceFactory
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly Domain.Enums.DownloadClient _downloadClient;
|
||||
|
||||
public DownloadServiceFactory(IServiceProvider serviceProvider, IConfiguration configuration)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_downloadClient = (Domain.Enums.DownloadClient)Enum.Parse(
|
||||
typeof(Domain.Enums.DownloadClient),
|
||||
configuration[EnvironmentVariables.DownloadClient] ?? Domain.Enums.DownloadClient.QBittorrent.ToString(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
public IDownloadService CreateDownloadClient() =>
|
||||
_downloadClient switch
|
||||
{
|
||||
Domain.Enums.DownloadClient.QBittorrent => _serviceProvider.GetRequiredService<QBitService>(),
|
||||
Domain.Enums.DownloadClient.Deluge => _serviceProvider.GetRequiredService<DelugeService>(),
|
||||
Domain.Enums.DownloadClient.Transmission => _serviceProvider.GetRequiredService<TransmissionService>(),
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
};
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Infrastructure.Verticals.DownloadClient;
|
||||
|
||||
public interface IDownloadService : IDisposable
|
||||
{
|
||||
public Task LoginAsync();
|
||||
|
||||
public Task<bool> ShouldRemoveFromArrQueueAsync(string hash);
|
||||
|
||||
public Task BlockUnwantedFilesAsync(string hash);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.QBittorrent;
|
||||
|
||||
public sealed class QBitService : DownloadServiceBase
|
||||
{
|
||||
private readonly QBitConfig _config;
|
||||
private readonly QBittorrentClient _client;
|
||||
|
||||
public QBitService(
|
||||
ILogger<QBitService> logger,
|
||||
IOptions<QBitConfig> config,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
FilenameEvaluator filenameEvaluator,
|
||||
Striker striker
|
||||
) : base(logger, queueCleanerConfig, filenameEvaluator, striker)
|
||||
{
|
||||
_config = config.Value;
|
||||
_config.Validate();
|
||||
_client = new(_config.Url);
|
||||
}
|
||||
|
||||
public override async Task LoginAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_config.Username) && string.IsNullOrEmpty(_config.Password))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _client.LoginAsync(_config.Username, _config.Password);
|
||||
}
|
||||
|
||||
public override async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
|
||||
{
|
||||
TorrentInfo? torrent = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] }))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (torrent is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return false;
|
||||
}
|
||||
|
||||
// if all files were blocked by qBittorrent
|
||||
if (torrent is { CompletionOn: not null, Downloaded: null or 0 })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
|
||||
|
||||
// if all files are marked as skip
|
||||
if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return IsItemStuckAndShouldRemove(torrent);
|
||||
}
|
||||
|
||||
public override async Task BlockUnwantedFilesAsync(string hash)
|
||||
{
|
||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(hash);
|
||||
|
||||
if (files is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (TorrentContent file in files)
|
||||
{
|
||||
if (!file.Index.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.Priority is TorrentContentPriority.Skip || _filenameEvaluator.IsValid(file.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("unwanted file found | {file}", file.Name);
|
||||
await _client.SetFilePriorityAsync(hash, file.Index.Value, TorrentContentPriority.Skip);
|
||||
}
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
|
||||
private bool IsItemStuckAndShouldRemove(TorrentInfo torrent)
|
||||
{
|
||||
if (torrent.State is not TorrentState.StalledDownload and not TorrentState.FetchingMetadata
|
||||
and not TorrentState.ForcedFetchingMetadata)
|
||||
{
|
||||
// ignore other states
|
||||
return false;
|
||||
}
|
||||
|
||||
return StrikeAndCheckLimit(torrent.Hash, torrent.Name);
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
using Common.Configuration.DownloadClient;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Infrastructure.Verticals.ContentBlocker;
|
||||
using Infrastructure.Verticals.ItemStriker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Transmission.API.RPC;
|
||||
using Transmission.API.RPC.Arguments;
|
||||
using Transmission.API.RPC.Entity;
|
||||
|
||||
namespace Infrastructure.Verticals.DownloadClient.Transmission;
|
||||
|
||||
public sealed class TransmissionService : DownloadServiceBase
|
||||
{
|
||||
private readonly TransmissionConfig _config;
|
||||
private readonly Client _client;
|
||||
private TorrentInfo[]? _torrentsCache;
|
||||
|
||||
public TransmissionService(
|
||||
ILogger<TransmissionService> logger,
|
||||
IOptions<TransmissionConfig> config,
|
||||
IOptions<QueueCleanerConfig> queueCleanerConfig,
|
||||
FilenameEvaluator filenameEvaluator,
|
||||
Striker striker
|
||||
) : base(logger, queueCleanerConfig, filenameEvaluator, striker)
|
||||
{
|
||||
_config = config.Value;
|
||||
_config.Validate();
|
||||
_client = new(
|
||||
new Uri(_config.Url, "/transmission/rpc").ToString(),
|
||||
login: _config.Username,
|
||||
password: _config.Password
|
||||
);
|
||||
}
|
||||
|
||||
public override async Task LoginAsync()
|
||||
{
|
||||
await _client.GetSessionInformationAsync();
|
||||
}
|
||||
|
||||
public override async Task<bool> ShouldRemoveFromArrQueueAsync(string hash)
|
||||
{
|
||||
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
||||
|
||||
if (torrent is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the download client", hash);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool shouldRemove = torrent.FileStats?.Length > 0;
|
||||
|
||||
foreach (TransmissionTorrentFileStats? stats in torrent.FileStats ?? [])
|
||||
{
|
||||
if (!stats.Wanted.HasValue)
|
||||
{
|
||||
// if any files stats are missing, do not remove
|
||||
shouldRemove = false;
|
||||
}
|
||||
|
||||
if (stats.Wanted.HasValue && stats.Wanted.Value)
|
||||
{
|
||||
// if any files are wanted, do not remove
|
||||
shouldRemove = false;
|
||||
}
|
||||
}
|
||||
|
||||
// remove if all files are unwanted
|
||||
return shouldRemove || IsItemStuckAndShouldRemove(torrent);
|
||||
}
|
||||
|
||||
public override async Task BlockUnwantedFilesAsync(string hash)
|
||||
{
|
||||
TorrentInfo? torrent = await GetTorrentAsync(hash);
|
||||
|
||||
if (torrent?.FileStats is null || torrent.Files is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<long> unwantedFiles = [];
|
||||
|
||||
for (int i = 0; i < torrent.Files.Length; i++)
|
||||
{
|
||||
if (torrent.FileStats?[i].Wanted == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!torrent.FileStats[i].Wanted.Value || _filenameEvaluator.IsValid(torrent.Files[i].Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("unwanted file found | {file}", torrent.Files[i].Name);
|
||||
unwantedFiles.Add(i);
|
||||
}
|
||||
|
||||
if (unwantedFiles.Count is 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("changing priorities | torrent {hash}", hash);
|
||||
|
||||
await _client.TorrentSetAsync(new TorrentSettings
|
||||
{
|
||||
Ids = [ torrent.Id ],
|
||||
FilesUnwanted = unwantedFiles.ToArray(),
|
||||
});
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private bool IsItemStuckAndShouldRemove(TorrentInfo torrent)
|
||||
{
|
||||
if (torrent.Status is not 4)
|
||||
{
|
||||
// not in downloading state
|
||||
return false;
|
||||
}
|
||||
|
||||
if (torrent.Eta > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return StrikeAndCheckLimit(torrent.HashString!, torrent.Name!);
|
||||
}
|
||||
|
||||
private async Task<TorrentInfo?> GetTorrentAsync(string hash)
|
||||
{
|
||||
TorrentInfo? torrent = _torrentsCache?
|
||||
.FirstOrDefault(x => x.HashString.Equals(hash, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (_torrentsCache is null || torrent is null)
|
||||
{
|
||||
string[] fields = [
|
||||
TorrentFields.FILES,
|
||||
TorrentFields.FILE_STATS,
|
||||
TorrentFields.HASH_STRING,
|
||||
TorrentFields.ID,
|
||||
TorrentFields.ETA,
|
||||
TorrentFields.NAME,
|
||||
TorrentFields.STATUS
|
||||
];
|
||||
|
||||
// refresh cache
|
||||
_torrentsCache = (await _client.TorrentGetAsync(fields))
|
||||
?.Torrents;
|
||||
}
|
||||
|
||||
if (_torrentsCache?.Length is null or 0)
|
||||
{
|
||||
_logger.LogDebug("could not list torrents | {url}", _config.Url);
|
||||
}
|
||||
|
||||
torrent = _torrentsCache?.FirstOrDefault(x => x.HashString.Equals(hash, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (torrent is null)
|
||||
{
|
||||
_logger.LogDebug("could not find torrent | {hash} | {url}", hash, _config.Url);
|
||||
}
|
||||
|
||||
return torrent;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using Common.Helpers;
|
||||
using Domain.Enums;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Infrastructure.Verticals.ItemStriker;
|
||||
|
||||
public class Striker
|
||||
{
|
||||
private readonly ILogger<Striker> _logger;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly MemoryCacheEntryOptions _cacheOptions;
|
||||
|
||||
public Striker(ILogger<Striker> logger, IMemoryCache cache)
|
||||
{
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_cacheOptions = new MemoryCacheEntryOptions()
|
||||
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
|
||||
}
|
||||
|
||||
public bool StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType)
|
||||
{
|
||||
if (maxStrikes is 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string key = $"{strikeType.ToString()}_{hash}";
|
||||
|
||||
if (!_cache.TryGetValue(key, out int? strikeCount))
|
||||
{
|
||||
strikeCount = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
++strikeCount;
|
||||
}
|
||||
|
||||
_logger.LogDebug("item on strike number {strike} | reason {reason} | {name}", strikeCount, strikeType.ToString(), itemName);
|
||||
_cache.Set(key, strikeCount, _cacheOptions);
|
||||
|
||||
if (strikeCount < maxStrikes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strikeCount > maxStrikes)
|
||||
{
|
||||
_logger.LogWarning("blocked item keeps coming back | {name}", itemName);
|
||||
_logger.LogWarning("be sure to enable \"Reject Blocklisted Torrent Hashes While Grabbing\" on your indexers to reject blocked items");
|
||||
}
|
||||
|
||||
_logger.LogInformation("removing item with max strikes | reason {reason} | {name}", strikeType.ToString(), itemName);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
using Common.Configuration.Arr;
|
||||
using Domain.Enums;
|
||||
using Domain.Models.Arr;
|
||||
using Domain.Models.Arr.Queue;
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Infrastructure.Verticals.Jobs;
|
||||
|
||||
public abstract class GenericHandler : IDisposable
|
||||
{
|
||||
protected readonly ILogger<GenericHandler> _logger;
|
||||
protected readonly SonarrConfig _sonarrConfig;
|
||||
protected readonly RadarrConfig _radarrConfig;
|
||||
protected readonly SonarrClient _sonarrClient;
|
||||
protected readonly RadarrClient _radarrClient;
|
||||
protected readonly ArrQueueIterator _arrArrQueueIterator;
|
||||
protected readonly IDownloadService _downloadService;
|
||||
|
||||
protected GenericHandler(
|
||||
ILogger<GenericHandler> logger,
|
||||
SonarrConfig sonarrConfig,
|
||||
RadarrConfig radarrConfig,
|
||||
SonarrClient sonarrClient,
|
||||
RadarrClient radarrClient,
|
||||
ArrQueueIterator arrArrQueueIterator,
|
||||
DownloadServiceFactory downloadServiceFactory
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_sonarrConfig = sonarrConfig;
|
||||
_radarrConfig = radarrConfig;
|
||||
_sonarrClient = sonarrClient;
|
||||
_radarrClient = radarrClient;
|
||||
_arrArrQueueIterator = arrArrQueueIterator;
|
||||
_downloadService = downloadServiceFactory.CreateDownloadClient();
|
||||
}
|
||||
|
||||
public virtual async Task ExecuteAsync()
|
||||
{
|
||||
await _downloadService.LoginAsync();
|
||||
|
||||
await ProcessArrConfigAsync(_sonarrConfig, InstanceType.Sonarr);
|
||||
await ProcessArrConfigAsync(_radarrConfig, InstanceType.Radarr);
|
||||
}
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
_downloadService.Dispose();
|
||||
}
|
||||
|
||||
protected abstract Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType);
|
||||
|
||||
private async Task ProcessArrConfigAsync(ArrConfig config, InstanceType instanceType)
|
||||
{
|
||||
if (!config.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (ArrInstance arrInstance in config.Instances)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessInstanceAsync(arrInstance, instanceType);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "failed to clean {type} instance | {url}", instanceType, arrInstance.Url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected ArrClient GetClient(InstanceType type) =>
|
||||
type switch
|
||||
{
|
||||
InstanceType.Sonarr => _sonarrClient,
|
||||
InstanceType.Radarr => _radarrClient,
|
||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||
};
|
||||
|
||||
protected ArrConfig GetConfig(InstanceType type) =>
|
||||
type switch
|
||||
{
|
||||
InstanceType.Sonarr => _sonarrConfig,
|
||||
InstanceType.Radarr => _radarrConfig,
|
||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||
};
|
||||
|
||||
protected SearchItem GetRecordSearchItem(InstanceType type, QueueRecord record, bool isPack = false)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Episode && !isPack => new SonarrSearchItem
|
||||
{
|
||||
Id = record.EpisodeId,
|
||||
SeriesId = record.SeriesId,
|
||||
SearchType = SonarrSearchType.Episode
|
||||
},
|
||||
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Episode && isPack => new SonarrSearchItem
|
||||
{
|
||||
Id = record.SeasonNumber,
|
||||
SeriesId = record.SeriesId,
|
||||
SearchType = SonarrSearchType.Season
|
||||
},
|
||||
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Season => new SonarrSearchItem
|
||||
{
|
||||
Id = record.SeasonNumber,
|
||||
SeriesId = record.SeriesId,
|
||||
SearchType = SonarrSearchType.Series
|
||||
},
|
||||
InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Series => new SonarrSearchItem
|
||||
{
|
||||
Id = record.SeriesId,
|
||||
},
|
||||
InstanceType.Radarr => new SearchItem
|
||||
{
|
||||
Id = record.MovieId,
|
||||
},
|
||||
_ => throw new NotImplementedException($"instance type {type} is not yet supported")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using Quartz;
|
||||
|
||||
namespace Infrastructure.Verticals.Jobs;
|
||||
|
||||
public class JobChainingListener : IJobListener
|
||||
{
|
||||
private readonly string _nextJobName;
|
||||
|
||||
public JobChainingListener(string nextJobName)
|
||||
{
|
||||
_nextJobName = nextJobName;
|
||||
}
|
||||
|
||||
public string Name => nameof(JobChainingListener);
|
||||
|
||||
public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_nextJobName) || context.JobDetail.Key.Name == _nextJobName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IScheduler scheduler = context.Scheduler;
|
||||
JobKey nextJobKey = new(_nextJobName);
|
||||
|
||||
if (await scheduler.CheckExists(nextJobKey, cancellationToken))
|
||||
{
|
||||
await scheduler.TriggerJob(nextJobKey, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using Common.Configuration.Arr;
|
||||
using Common.Configuration.QueueCleaner;
|
||||
using Domain.Enums;
|
||||
using Domain.Models.Arr;
|
||||
using Domain.Models.Arr.Queue;
|
||||
using Infrastructure.Verticals.Arr;
|
||||
using Infrastructure.Verticals.DownloadClient;
|
||||
using Infrastructure.Verticals.Jobs;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Infrastructure.Verticals.QueueCleaner;
|
||||
|
||||
public sealed class QueueCleaner : GenericHandler
|
||||
{
|
||||
public QueueCleaner(
|
||||
ILogger<QueueCleaner> logger,
|
||||
IOptions<SonarrConfig> sonarrConfig,
|
||||
IOptions<RadarrConfig> radarrConfig,
|
||||
SonarrClient sonarrClient,
|
||||
RadarrClient radarrClient,
|
||||
ArrQueueIterator arrArrQueueIterator,
|
||||
DownloadServiceFactory downloadServiceFactory
|
||||
) : base(logger, sonarrConfig.Value, radarrConfig.Value, sonarrClient, radarrClient, arrArrQueueIterator, downloadServiceFactory)
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
|
||||
{
|
||||
HashSet<SearchItem> itemsToBeRefreshed = [];
|
||||
ArrClient arrClient = GetClient(instanceType);
|
||||
ArrConfig arrConfig = GetConfig(instanceType);
|
||||
|
||||
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
|
||||
{
|
||||
var groups = items
|
||||
.GroupBy(x => x.DownloadId)
|
||||
.ToList();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (group.Any(x => !arrClient.IsRecordValid(x)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
QueueRecord record = group.First();
|
||||
|
||||
if (record.Protocol is not "torrent")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!arrClient.IsRecordValid(record))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!arrClient.ShouldRemoveFromQueue(record) && !await _downloadService.ShouldRemoveFromArrQueueAsync(record.DownloadId))
|
||||
{
|
||||
_logger.LogInformation("skip | {title}", record.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
itemsToBeRefreshed.Add(GetRecordSearchItem(instanceType, record, group.Count() > 1));
|
||||
|
||||
await arrClient.DeleteQueueItemAsync(instance, record);
|
||||
}
|
||||
});
|
||||
|
||||
await arrClient.RefreshItemsAsync(instance, arrConfig, itemsToBeRefreshed);
|
||||
}
|
||||
}
|
||||
16
code/Makefile
Normal file
16
code/Makefile
Normal file
@@ -0,0 +1,16 @@
|
||||
.DEFAULT_GOAL := no-default
|
||||
|
||||
no-default:
|
||||
$(error You must specify a make target)
|
||||
|
||||
migrate-data:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-data name=YourMigrationName)
|
||||
endif
|
||||
dotnet ef migrations add $(name) --context DataContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Data
|
||||
|
||||
migrate-events:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-events name=YourMigrationName)
|
||||
endif
|
||||
dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
|
||||
46
code/backend/Cleanuparr.Api/Cleanuparr.Api.csproj
Normal file
46
code/backend/Cleanuparr.Api/Cleanuparr.Api.csproj
Normal file
@@ -0,0 +1,46 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>Cleanuparr</AssemblyName>
|
||||
<Version Condition="'$(Version)' == ''">0.0.1</Version>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<EnableMacOSCodeSign>false</EnableMacOSCodeSign>
|
||||
<CodeSignOnCopy>false</CodeSignOnCopy>
|
||||
<_CodeSignDuringBuild>false</_CodeSignDuringBuild>
|
||||
<CodesignDisableTimestamp>true</CodesignDisableTimestamp>
|
||||
<CodesignKeychain></CodesignKeychain>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="wwwroot\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MassTransit" Version="8.5.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
|
||||
<PackageReference Include="Quartz" Version="3.15.1" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
|
||||
<PackageReference Include="Serilog" Version="4.3.0" />
|
||||
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api")]
|
||||
public class ApiDocumentationController : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public IActionResult RedirectToSwagger()
|
||||
{
|
||||
return Redirect("/api/swagger");
|
||||
}
|
||||
}
|
||||
236
code/backend/Cleanuparr.Api/Controllers/EventsController.cs
Normal file
236
code/backend/Cleanuparr.Api/Controllers/EventsController.cs
Normal file
@@ -0,0 +1,236 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class EventsController : ControllerBase
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
|
||||
public EventsController(EventsContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets events with pagination and filtering
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResult<AppEvent>>> GetEvents(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 100,
|
||||
[FromQuery] string? severity = null,
|
||||
[FromQuery] string? eventType = null,
|
||||
[FromQuery] DateTime? fromDate = null,
|
||||
[FromQuery] DateTime? toDate = null,
|
||||
[FromQuery] string? search = null)
|
||||
{
|
||||
// Validate pagination parameters
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 100;
|
||||
if (pageSize > 1000) pageSize = 1000; // Cap at 1000 for performance
|
||||
|
||||
var query = _context.Events.AsQueryable();
|
||||
|
||||
// Apply filters
|
||||
if (!string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
if (Enum.TryParse<EventSeverity>(severity, true, out var severityEnum))
|
||||
query = query.Where(e => e.Severity == severityEnum);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(eventType))
|
||||
{
|
||||
if (Enum.TryParse<EventType>(eventType, true, out var eventTypeEnum))
|
||||
query = query.Where(e => e.EventType == eventTypeEnum);
|
||||
}
|
||||
|
||||
// Apply date range filters
|
||||
if (fromDate.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.Timestamp >= fromDate.Value);
|
||||
}
|
||||
|
||||
if (toDate.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.Timestamp <= toDate.Value);
|
||||
}
|
||||
|
||||
// Apply search filter if provided
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
string pattern = EventsContext.GetLikePattern(search);
|
||||
query = query.Where(e =>
|
||||
EF.Functions.Like(e.Message, pattern) ||
|
||||
EF.Functions.Like(e.Data, pattern) ||
|
||||
EF.Functions.Like(e.TrackingId.ToString(), pattern)
|
||||
);
|
||||
}
|
||||
|
||||
// Count total matching records for pagination
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
// Calculate pagination
|
||||
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||
var skip = (page - 1) * pageSize;
|
||||
|
||||
// Get paginated data
|
||||
var events = await query
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Skip(skip)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
// Return paginated result
|
||||
var result = new PaginatedResult<AppEvent>
|
||||
{
|
||||
Items = events,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
TotalPages = totalPages
|
||||
};
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific event by ID
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<AppEvent>> GetEvent(Guid id)
|
||||
{
|
||||
var eventEntity = await _context.Events.FindAsync(id);
|
||||
|
||||
if (eventEntity == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(eventEntity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets events by tracking ID
|
||||
/// </summary>
|
||||
[HttpGet("tracking/{trackingId}")]
|
||||
public async Task<ActionResult<List<AppEvent>>> GetEventsByTracking(Guid trackingId)
|
||||
{
|
||||
var events = await _context.Events
|
||||
.Where(e => e.TrackingId == trackingId)
|
||||
.OrderBy(e => e.Timestamp)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(events);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets event statistics
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult<object>> GetEventStats()
|
||||
{
|
||||
var stats = new
|
||||
{
|
||||
TotalEvents = await _context.Events.CountAsync(),
|
||||
EventsBySeverity = await _context.Events
|
||||
.GroupBy(e => e.Severity)
|
||||
.Select(g => new { Severity = g.Key.ToString(), Count = g.Count() })
|
||||
.ToListAsync(),
|
||||
EventsByType = await _context.Events
|
||||
.GroupBy(e => e.EventType)
|
||||
.Select(g => new { EventType = g.Key.ToString(), Count = g.Count() })
|
||||
.OrderByDescending(x => x.Count)
|
||||
.Take(10)
|
||||
.ToListAsync(),
|
||||
RecentEventsCount = await _context.Events
|
||||
.Where(e => e.Timestamp > DateTime.UtcNow.AddHours(-24))
|
||||
.CountAsync()
|
||||
};
|
||||
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually triggers cleanup of old events
|
||||
/// </summary>
|
||||
[HttpPost("cleanup")]
|
||||
public async Task<ActionResult<object>> CleanupOldEvents([FromQuery] int retentionDays = 30)
|
||||
{
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
|
||||
|
||||
await _context.Events
|
||||
.Where(e => e.Timestamp < cutoffDate)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets unique event types
|
||||
/// </summary>
|
||||
[HttpGet("types")]
|
||||
public async Task<ActionResult<List<string>>> GetEventTypes()
|
||||
{
|
||||
var types = Enum.GetNames(typeof(EventType)).ToList();
|
||||
return Ok(types);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets unique severities
|
||||
/// </summary>
|
||||
[HttpGet("severities")]
|
||||
public async Task<ActionResult<List<string>>> GetSeverities()
|
||||
{
|
||||
var severities = Enum.GetNames(typeof(EventSeverity)).ToList();
|
||||
return Ok(severities);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a paginated result set
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of items in the result</typeparam>
|
||||
public class PaginatedResult<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// The items in the current page
|
||||
/// </summary>
|
||||
public List<T> Items { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Current page number (1-based)
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of items per page
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of items across all pages
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of pages
|
||||
/// </summary>
|
||||
public int TotalPages { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether there is a previous page
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool HasPrevious => Page > 1;
|
||||
|
||||
/// <summary>
|
||||
/// Whether there is a next page
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool HasNext => Page < TotalPages;
|
||||
}
|
||||
103
code/backend/Cleanuparr.Api/Controllers/HealthCheckController.cs
Normal file
103
code/backend/Cleanuparr.Api/Controllers/HealthCheckController.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for checking the health of download clients
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/health")]
|
||||
public class HealthCheckController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<HealthCheckController> _logger;
|
||||
private readonly IHealthCheckService _healthCheckService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HealthCheckController"/> class
|
||||
/// </summary>
|
||||
public HealthCheckController(
|
||||
ILogger<HealthCheckController> logger,
|
||||
IHealthCheckService healthCheckService)
|
||||
{
|
||||
_logger = logger;
|
||||
_healthCheckService = healthCheckService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the health status of all download clients
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public IActionResult GetAllHealth()
|
||||
{
|
||||
try
|
||||
{
|
||||
var healthStatuses = _healthCheckService.GetAllClientHealth();
|
||||
return Ok(healthStatuses);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving client health statuses");
|
||||
return StatusCode(500, new { Error = "An error occurred while retrieving client health statuses" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the health status of a specific download client
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}")]
|
||||
public IActionResult GetClientHealth(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var healthStatus = _healthCheckService.GetClientHealth(id);
|
||||
if (healthStatus == null)
|
||||
{
|
||||
return NotFound(new { Message = $"Health status for client with ID '{id}' not found" });
|
||||
}
|
||||
|
||||
return Ok(healthStatus);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving health status for client {id}", id);
|
||||
return StatusCode(500, new { Error = "An error occurred while retrieving the client health status" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers a health check for all download clients
|
||||
/// </summary>
|
||||
[HttpPost("check")]
|
||||
public async Task<IActionResult> CheckAllHealth()
|
||||
{
|
||||
try
|
||||
{
|
||||
var results = await _healthCheckService.CheckAllClientsHealthAsync();
|
||||
return Ok(results);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking health for all clients");
|
||||
return StatusCode(500, new { Error = "An error occurred while checking client health" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers a health check for a specific download client
|
||||
/// </summary>
|
||||
[HttpPost("check/{id:guid}")]
|
||||
public async Task<IActionResult> CheckClientHealth(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _healthCheckService.CheckClientHealthAsync(id);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking health for client {id}", id);
|
||||
return StatusCode(500, new { Error = "An error occurred while checking client health" });
|
||||
}
|
||||
}
|
||||
}
|
||||
125
code/backend/Cleanuparr.Api/Controllers/HealthController.cs
Normal file
125
code/backend/Cleanuparr.Api/Controllers/HealthController.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Health check endpoints for Docker and Kubernetes
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class HealthController : ControllerBase
|
||||
{
|
||||
private readonly HealthCheckService _healthCheckService;
|
||||
private readonly ILogger<HealthController> _logger;
|
||||
|
||||
public HealthController(HealthCheckService healthCheckService, ILogger<HealthController> logger)
|
||||
{
|
||||
_healthCheckService = healthCheckService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Basic liveness probe - checks if the application is running
|
||||
/// Used by Docker HEALTHCHECK and Kubernetes liveness probes
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[Route("/health")]
|
||||
public async Task<IActionResult> GetHealth()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _healthCheckService.CheckHealthAsync(
|
||||
registration => registration.Tags.Contains("liveness"));
|
||||
|
||||
return result.Status == HealthStatus.Healthy
|
||||
? Ok(new { status = "healthy", timestamp = DateTime.UtcNow })
|
||||
: StatusCode(503, new { status = "unhealthy", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Health check failed");
|
||||
return StatusCode(503, new { status = "unhealthy", error = "Health check failed", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Readiness probe - checks if the application is ready to serve traffic
|
||||
/// Used by Kubernetes readiness probes
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[Route("/health/ready")]
|
||||
public async Task<IActionResult> GetReadiness()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _healthCheckService.CheckHealthAsync(
|
||||
registration => registration.Tags.Contains("readiness"));
|
||||
|
||||
if (result.Status == HealthStatus.Healthy)
|
||||
{
|
||||
return Ok(new { status = "ready", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
// For readiness, we consider degraded as not ready
|
||||
return StatusCode(503, new {
|
||||
status = "not_ready",
|
||||
timestamp = DateTime.UtcNow,
|
||||
details = result.Entries.Where(e => e.Value.Status != HealthStatus.Healthy)
|
||||
.ToDictionary(e => e.Key, e => new {
|
||||
status = e.Value.Status.ToString().ToLowerInvariant(),
|
||||
description = e.Value.Description
|
||||
})
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Readiness check failed");
|
||||
return StatusCode(503, new { status = "not_ready", error = "Readiness check failed", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed health status - for monitoring and debugging
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[Route("/health/detailed")]
|
||||
public async Task<IActionResult> GetDetailedHealth()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _healthCheckService.CheckHealthAsync();
|
||||
|
||||
var response = new
|
||||
{
|
||||
status = result.Status.ToString().ToLowerInvariant(),
|
||||
timestamp = DateTime.UtcNow,
|
||||
totalDuration = result.TotalDuration.TotalMilliseconds,
|
||||
entries = result.Entries.ToDictionary(
|
||||
e => e.Key,
|
||||
e => new
|
||||
{
|
||||
status = e.Value.Status.ToString().ToLowerInvariant(),
|
||||
description = e.Value.Description,
|
||||
duration = e.Value.Duration.TotalMilliseconds,
|
||||
tags = e.Value.Tags,
|
||||
data = e.Value.Data,
|
||||
exception = e.Value.Exception?.Message
|
||||
})
|
||||
};
|
||||
|
||||
return result.Status == HealthStatus.Healthy
|
||||
? Ok(response)
|
||||
: StatusCode(503, response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Detailed health check failed");
|
||||
return StatusCode(503, new {
|
||||
status = "unhealthy",
|
||||
error = "Detailed health check failed",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
123
code/backend/Cleanuparr.Api/Controllers/JobsController.cs
Normal file
123
code/backend/Cleanuparr.Api/Controllers/JobsController.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using Cleanuparr.Api.Models;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class JobsController : ControllerBase
|
||||
{
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly ILogger<JobsController> _logger;
|
||||
|
||||
public JobsController(IJobManagementService jobManagementService, ILogger<JobsController> logger)
|
||||
{
|
||||
_jobManagementService = jobManagementService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAllJobs()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _jobManagementService.GetAllJobs();
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting all jobs");
|
||||
return StatusCode(500, "An error occurred while retrieving jobs");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{jobType}")]
|
||||
public async Task<IActionResult> GetJob(JobType jobType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jobInfo = await _jobManagementService.GetJob(jobType);
|
||||
|
||||
if (jobInfo.Status == "Not Found")
|
||||
{
|
||||
return NotFound($"Job '{jobType}' not found");
|
||||
}
|
||||
return Ok(jobInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while retrieving job '{jobType}'");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{jobType}/start")]
|
||||
public async Task<IActionResult> StartJob(JobType jobType, [FromBody] ScheduleRequest scheduleRequest = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the schedule from the request body if provided
|
||||
JobSchedule jobSchedule = scheduleRequest.Schedule;
|
||||
|
||||
var result = await _jobManagementService.StartJob(jobType, jobSchedule);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to start job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' started successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error starting job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while starting job '{jobType}'");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{jobType}/trigger")]
|
||||
public async Task<IActionResult> TriggerJob(JobType jobType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _jobManagementService.TriggerJobOnce(jobType);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to trigger job '{jobType}' - job may not exist or be configured");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' triggered successfully for one-time execution" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error triggering job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while triggering job '{jobType}'");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{jobType}/schedule")]
|
||||
public async Task<IActionResult> UpdateJobSchedule(JobType jobType, [FromBody] ScheduleRequest scheduleRequest)
|
||||
{
|
||||
if (scheduleRequest?.Schedule == null)
|
||||
{
|
||||
return BadRequest("Schedule is required");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _jobManagementService.UpdateJobSchedule(jobType, scheduleRequest.Schedule);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to update schedule for job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' schedule updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating job {jobType} schedule", jobType);
|
||||
return StatusCode(500, $"An error occurred while updating schedule for job '{jobType}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ManualEventsController : ControllerBase
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
|
||||
public ManualEventsController(EventsContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets manual events with pagination and filtering
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResult<ManualEvent>>> GetManualEvents(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 100,
|
||||
[FromQuery] bool? isResolved = null,
|
||||
[FromQuery] string? severity = null,
|
||||
[FromQuery] DateTime? fromDate = null,
|
||||
[FromQuery] DateTime? toDate = null,
|
||||
[FromQuery] string? search = null)
|
||||
{
|
||||
// Validate pagination parameters
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 100;
|
||||
if (pageSize > 1000) pageSize = 1000; // Cap at 1000 for performance
|
||||
|
||||
var query = _context.ManualEvents.AsQueryable();
|
||||
|
||||
// Apply filters
|
||||
if (isResolved.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.IsResolved == isResolved.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
if (Enum.TryParse<EventSeverity>(severity, true, out var severityEnum))
|
||||
query = query.Where(e => e.Severity == severityEnum);
|
||||
}
|
||||
|
||||
// Apply date range filters
|
||||
if (fromDate.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.Timestamp >= fromDate.Value);
|
||||
}
|
||||
|
||||
if (toDate.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.Timestamp <= toDate.Value);
|
||||
}
|
||||
|
||||
// Apply search filter if provided
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
string pattern = EventsContext.GetLikePattern(search);
|
||||
query = query.Where(e =>
|
||||
EF.Functions.Like(e.Message, pattern) ||
|
||||
EF.Functions.Like(e.Data, pattern)
|
||||
);
|
||||
}
|
||||
|
||||
// Count total matching records for pagination
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
// Calculate pagination
|
||||
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||
var skip = (page - 1) * pageSize;
|
||||
|
||||
// Get paginated data
|
||||
var events = await query
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Skip(skip)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
// Return paginated result
|
||||
var result = new PaginatedResult<ManualEvent>
|
||||
{
|
||||
Items = events,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
TotalPages = totalPages
|
||||
};
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific manual event by ID
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<ManualEvent>> GetManualEvent(Guid id)
|
||||
{
|
||||
var eventEntity = await _context.ManualEvents.FindAsync(id);
|
||||
|
||||
if (eventEntity == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(eventEntity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a manual event as resolved
|
||||
/// </summary>
|
||||
[HttpPost("{id}/resolve")]
|
||||
public async Task<ActionResult> ResolveManualEvent(Guid id)
|
||||
{
|
||||
var eventEntity = await _context.ManualEvents.FindAsync(id);
|
||||
|
||||
if (eventEntity == null)
|
||||
return NotFound();
|
||||
|
||||
eventEntity.IsResolved = true;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets manual event statistics
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult<object>> GetManualEventStats()
|
||||
{
|
||||
var stats = new
|
||||
{
|
||||
TotalEvents = await _context.ManualEvents.CountAsync(),
|
||||
UnresolvedEvents = await _context.ManualEvents.CountAsync(e => !e.IsResolved),
|
||||
ResolvedEvents = await _context.ManualEvents.CountAsync(e => e.IsResolved),
|
||||
EventsBySeverity = await _context.ManualEvents
|
||||
.GroupBy(e => e.Severity)
|
||||
.Select(g => new { Severity = g.Key.ToString(), Count = g.Count() })
|
||||
.ToListAsync(),
|
||||
UnresolvedBySeverity = await _context.ManualEvents
|
||||
.Where(e => !e.IsResolved)
|
||||
.GroupBy(e => e.Severity)
|
||||
.Select(g => new { Severity = g.Key.ToString(), Count = g.Count() })
|
||||
.ToListAsync()
|
||||
};
|
||||
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets unique severities for manual events
|
||||
/// </summary>
|
||||
[HttpGet("severities")]
|
||||
public async Task<ActionResult<List<string>>> GetSeverities()
|
||||
{
|
||||
var severities = Enum.GetNames(typeof(EventSeverity)).ToList();
|
||||
return Ok(severities);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually triggers cleanup of old resolved events
|
||||
/// </summary>
|
||||
[HttpPost("cleanup")]
|
||||
public async Task<ActionResult<object>> CleanupOldResolvedEvents([FromQuery] int retentionDays = 30)
|
||||
{
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
|
||||
|
||||
var deletedCount = await _context.ManualEvents
|
||||
.Where(e => e.IsResolved && e.Timestamp < cutoffDate)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
return Ok(new { DeletedCount = deletedCount });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// Queue rules endpoints have moved to Cleanuparr.Api.Features.QueueCleaner.Controllers
|
||||
276
code/backend/Cleanuparr.Api/Controllers/StatusController.cs
Normal file
276
code/backend/Cleanuparr.Api/Controllers/StatusController.cs
Normal file
@@ -0,0 +1,276 @@
|
||||
using System.Diagnostics;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class StatusController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<StatusController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
|
||||
public StatusController(
|
||||
ILogger<StatusController> logger,
|
||||
DataContext dataContext,
|
||||
IArrClientFactory arrClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_arrClientFactory = arrClientFactory;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetSystemStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
var process = Process.GetCurrentProcess();
|
||||
|
||||
// Get configuration
|
||||
var downloadClients = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
var sonarrConfig = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Sonarr);
|
||||
var radarrConfig = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Radarr);
|
||||
var lidarrConfig = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Lidarr);
|
||||
var readarrConfig = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Readarr);
|
||||
|
||||
var status = new
|
||||
{
|
||||
Application = new
|
||||
{
|
||||
Version = GetType().Assembly.GetName().Version?.ToString() ?? "Unknown",
|
||||
process.StartTime,
|
||||
UpTime = DateTime.Now - process.StartTime,
|
||||
MemoryUsageMB = Math.Round(process.WorkingSet64 / 1024.0 / 1024.0, 2),
|
||||
ProcessorTime = process.TotalProcessorTime
|
||||
},
|
||||
DownloadClient = new
|
||||
{
|
||||
// TODO
|
||||
},
|
||||
MediaManagers = new
|
||||
{
|
||||
Sonarr = new
|
||||
{
|
||||
InstanceCount = sonarrConfig.Instances.Count
|
||||
},
|
||||
Radarr = new
|
||||
{
|
||||
InstanceCount = radarrConfig.Instances.Count
|
||||
},
|
||||
Lidarr = new
|
||||
{
|
||||
InstanceCount = lidarrConfig.Instances.Count
|
||||
},
|
||||
Readarr = new
|
||||
{
|
||||
InstanceCount = readarrConfig.Instances.Count
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return Ok(status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving system status");
|
||||
return StatusCode(500, "An error occurred while retrieving system status");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("download-client")]
|
||||
public async Task<IActionResult> GetDownloadClientStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
var downloadClients = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
var result = new Dictionary<string, object>();
|
||||
|
||||
// Check for configured clients
|
||||
if (downloadClients.Count > 0)
|
||||
{
|
||||
var clientsStatus = new List<object>();
|
||||
foreach (var client in downloadClients)
|
||||
{
|
||||
clientsStatus.Add(new
|
||||
{
|
||||
client.Id,
|
||||
client.Name,
|
||||
Type = client.TypeName,
|
||||
client.Host,
|
||||
client.Enabled,
|
||||
IsConnected = client.Enabled, // We can't check connection status without implementing test methods
|
||||
});
|
||||
}
|
||||
|
||||
result["Clients"] = clientsStatus;
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving download client status");
|
||||
return StatusCode(500, "An error occurred while retrieving download client status");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("arrs")]
|
||||
public async Task<IActionResult> GetMediaManagersStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
var status = new Dictionary<string, object>();
|
||||
|
||||
// Get configurations
|
||||
var enabledSonarrInstances = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.Where(x => x.Type == InstanceType.Sonarr)
|
||||
.SelectMany(x => x.Instances)
|
||||
.Where(x => x.Enabled)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
var enabledRadarrInstances = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.Where(x => x.Type == InstanceType.Radarr)
|
||||
.SelectMany(x => x.Instances)
|
||||
.Where(x => x.Enabled)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
var enabledLidarrInstances = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.Where(x => x.Type == InstanceType.Lidarr)
|
||||
.SelectMany(x => x.Instances)
|
||||
.Where(x => x.Enabled)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();;
|
||||
|
||||
|
||||
// Check Sonarr instances
|
||||
var sonarrStatus = new List<object>();
|
||||
|
||||
foreach (var instance in enabledSonarrInstances)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr);
|
||||
await sonarrClient.HealthCheckAsync(instance);
|
||||
|
||||
sonarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = true,
|
||||
Message = "Successfully connected"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sonarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = false,
|
||||
Message = $"Connection failed: {ex.Message}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
status["Sonarr"] = sonarrStatus;
|
||||
|
||||
// Check Radarr instances
|
||||
var radarrStatus = new List<object>();
|
||||
|
||||
foreach (var instance in enabledRadarrInstances)
|
||||
{
|
||||
try
|
||||
{
|
||||
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr);
|
||||
await radarrClient.HealthCheckAsync(instance);
|
||||
|
||||
radarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = true,
|
||||
Message = "Successfully connected"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
radarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = false,
|
||||
Message = $"Connection failed: {ex.Message}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
status["Radarr"] = radarrStatus;
|
||||
|
||||
// Check Lidarr instances
|
||||
var lidarrStatus = new List<object>();
|
||||
|
||||
foreach (var instance in enabledLidarrInstances)
|
||||
{
|
||||
try
|
||||
{
|
||||
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr);
|
||||
await lidarrClient.HealthCheckAsync(instance);
|
||||
|
||||
lidarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = true,
|
||||
Message = "Successfully connected"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lidarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = false,
|
||||
Message = $"Connection failed: {ex.Message}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
status["Lidarr"] = lidarrStatus;
|
||||
|
||||
return Ok(status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving media managers status");
|
||||
return StatusCode(500, "An error occurred while retrieving media managers status");
|
||||
}
|
||||
}
|
||||
}
|
||||
151
code/backend/Cleanuparr.Api/DependencyInjection/ApiDI.cs
Normal file
151
code/backend/Cleanuparr.Api/DependencyInjection/ApiDI.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using System.Text;
|
||||
using Cleanuparr.Api.Middleware;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
public static class ApiDI
|
||||
{
|
||||
public static IServiceCollection AddApiServices(this IServiceCollection services)
|
||||
{
|
||||
services.Configure<JsonOptions>(options =>
|
||||
{
|
||||
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
});
|
||||
|
||||
// Make JsonSerializerOptions available for injection
|
||||
services.AddSingleton(sp =>
|
||||
sp.GetRequiredService<IOptions<JsonOptions>>().Value.SerializerOptions);
|
||||
|
||||
// Add API-specific services
|
||||
services
|
||||
.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
});
|
||||
services.AddEndpointsApiExplorer();
|
||||
|
||||
// Add SignalR for real-time updates
|
||||
services
|
||||
.AddSignalR()
|
||||
.AddJsonProtocol(options =>
|
||||
{
|
||||
options.PayloadSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
|
||||
// Add health status broadcaster
|
||||
services.AddHostedService<HealthStatusBroadcaster>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static WebApplication ConfigureApi(this WebApplication app)
|
||||
{
|
||||
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||
|
||||
// Enable compression
|
||||
app.UseResponseCompression();
|
||||
|
||||
// Serve static files with caching
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
OnPrepareResponse = _ => {}
|
||||
});
|
||||
|
||||
// Add the global exception handling middleware first
|
||||
app.UseMiddleware<ExceptionMiddleware>();
|
||||
|
||||
app.UseCors("Any");
|
||||
app.UseRouting();
|
||||
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
// Custom SPA fallback to inject base path
|
||||
app.MapFallback(async context =>
|
||||
{
|
||||
var basePath = app.Configuration.GetValue<string>("BASE_PATH") ?? "/";
|
||||
|
||||
// Normalize the base path (remove trailing slash if not root)
|
||||
if (basePath != "/" && basePath.EndsWith("/"))
|
||||
{
|
||||
basePath = basePath.TrimEnd('/');
|
||||
}
|
||||
|
||||
var webRoot = app.Environment.WebRootPath ?? Path.Combine(app.Environment.ContentRootPath, "wwwroot");
|
||||
var indexPath = Path.Combine(webRoot, "index.html");
|
||||
|
||||
if (!File.Exists(indexPath))
|
||||
{
|
||||
context.Response.StatusCode = 404;
|
||||
await context.Response.WriteAsync("index.html not found");
|
||||
return;
|
||||
}
|
||||
|
||||
var indexContent = await File.ReadAllTextAsync(indexPath);
|
||||
|
||||
// Inject the base path into the HTML
|
||||
var scriptInjection = $@"
|
||||
<script>
|
||||
window['_server_base_path'] = '{basePath}';
|
||||
</script>";
|
||||
|
||||
// Insert the script right before the existing script tag
|
||||
indexContent = indexContent.Replace(
|
||||
" <script>",
|
||||
scriptInjection + "\n <script>"
|
||||
);
|
||||
|
||||
context.Response.ContentType = "text/html";
|
||||
await context.Response.WriteAsync(indexContent, Encoding.UTF8);
|
||||
});
|
||||
|
||||
// Map SignalR hubs
|
||||
app.MapHub<HealthStatusHub>("/api/hubs/health");
|
||||
app.MapHub<AppHub>("/api/hubs/app");
|
||||
|
||||
app.MapGet("/manifest.webmanifest", (HttpContext context) =>
|
||||
{
|
||||
var basePath = context.Request.PathBase.HasValue
|
||||
? context.Request.PathBase.Value
|
||||
: "/";
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
name = "Cleanuparr",
|
||||
short_name = "Cleanuparr",
|
||||
start_url = basePath,
|
||||
display = "standalone",
|
||||
background_color = "#ffffff",
|
||||
theme_color = "#ffffff",
|
||||
icons = new[]
|
||||
{
|
||||
new {
|
||||
src = "icons/icon-192x192.png",
|
||||
sizes = "192x192",
|
||||
type = "image/png"
|
||||
},
|
||||
new {
|
||||
src = "icons/icon-512x512.png",
|
||||
sizes = "512x512",
|
||||
type = "image/png"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return Results.Json(manifest, contentType: "application/manifest+json");
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
16
code/backend/Cleanuparr.Api/DependencyInjection/LoggingDI.cs
Normal file
16
code/backend/Cleanuparr.Api/DependencyInjection/LoggingDI.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Serilog;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
public static class LoggingDI
|
||||
{
|
||||
public static ILoggingBuilder AddLogging(this ILoggingBuilder builder)
|
||||
{
|
||||
Log.Logger = LoggingConfigManager
|
||||
.CreateLoggerConfiguration()
|
||||
.CreateLogger();
|
||||
|
||||
return builder.ClearProviders().AddSerilog();
|
||||
}
|
||||
}
|
||||
115
code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs
Normal file
115
code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
public static class MainDI
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddHttpClients(configuration)
|
||||
.AddSingleton<MemoryCache>()
|
||||
.AddSingleton<IMemoryCache>(serviceProvider => serviceProvider.GetRequiredService<MemoryCache>())
|
||||
.AddServices()
|
||||
.AddHealthServices()
|
||||
.AddQuartzServices(configuration)
|
||||
.AddNotifications()
|
||||
.AddMassTransit(config =>
|
||||
{
|
||||
config.DisableUsageTelemetry();
|
||||
|
||||
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
|
||||
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadHunterConsumer<SeriesSearchItem>>();
|
||||
|
||||
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowTimeStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
|
||||
|
||||
config.UsingInMemory((context, cfg) =>
|
||||
{
|
||||
cfg.ConfigureJsonSerializerOptions(options =>
|
||||
{
|
||||
options.PropertyNameCaseInsensitive = true;
|
||||
options.Converters.Add(new JsonStringEnumConverter());
|
||||
options.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
cfg.ReceiveEndpoint("download-remover-queue", e =>
|
||||
{
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer<SeriesSearchItem>>(context);
|
||||
e.ConcurrentMessageLimit = 2;
|
||||
e.PrefetchCount = 2;
|
||||
});
|
||||
|
||||
cfg.ReceiveEndpoint("download-hunter-queue", e =>
|
||||
{
|
||||
e.ConfigureConsumer<DownloadHunterConsumer<SearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadHunterConsumer<SeriesSearchItem>>(context);
|
||||
e.ConcurrentMessageLimit = 1;
|
||||
e.PrefetchCount = 1;
|
||||
});
|
||||
|
||||
cfg.ReceiveEndpoint("notification-queue", e =>
|
||||
{
|
||||
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowTimeStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);
|
||||
e.ConcurrentMessageLimit = 1;
|
||||
e.PrefetchCount = 1;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
private static IServiceCollection AddHttpClients(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
// Add the dynamic HTTP client system - this replaces all the previous static configurations
|
||||
services.AddDynamicHttpClients();
|
||||
|
||||
// Add the dynamic HTTP client provider that uses the new system
|
||||
services.AddSingleton<IDynamicHttpClientProvider, DynamicHttpClientProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds health check services to the service collection
|
||||
/// </summary>
|
||||
private static IServiceCollection AddHealthServices(this IServiceCollection services) =>
|
||||
services
|
||||
// Register the existing health check service for download clients
|
||||
.AddSingleton<IHealthCheckService, HealthCheckService>()
|
||||
|
||||
// Register the background service for periodic health checks
|
||||
.AddHostedService<HealthCheckBackgroundService>()
|
||||
|
||||
// Add ASP.NET Core health checks
|
||||
.AddHealthChecks()
|
||||
.AddCheck<ApplicationHealthCheck>("application", tags: ["liveness"])
|
||||
.AddCheck<DatabaseHealthCheck>("database", tags: ["readiness"])
|
||||
.AddCheck<FileSystemHealthCheck>("filesystem", tags: ["readiness"])
|
||||
.AddCheck<DownloadClientsHealthCheck>("download_clients", tags: ["readiness"])
|
||||
.Services;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
public static class NotificationsDI
|
||||
{
|
||||
public static IServiceCollection AddNotifications(this IServiceCollection services) =>
|
||||
services
|
||||
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
|
||||
.AddScoped<IAppriseProxy, AppriseProxy>()
|
||||
.AddScoped<INtfyProxy, NtfyProxy>()
|
||||
.AddScoped<IPushoverProxy, PushoverProxy>()
|
||||
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
|
||||
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
|
||||
.AddScoped<NotificationProviderFactory>()
|
||||
.AddScoped<INotificationPublisher, NotificationPublisher>()
|
||||
.AddScoped<NotificationService>();
|
||||
}
|
||||
20
code/backend/Cleanuparr.Api/DependencyInjection/QuartzDI.cs
Normal file
20
code/backend/Cleanuparr.Api/DependencyInjection/QuartzDI.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Cleanuparr.Api.Jobs;
|
||||
using Quartz;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
public static class QuartzDI
|
||||
{
|
||||
public static IServiceCollection AddQuartzServices(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddQuartz()
|
||||
.AddQuartzHostedService(opt =>
|
||||
{
|
||||
opt.WaitForJobsToComplete = true;
|
||||
})
|
||||
// Register BackgroundJobManager as a hosted service
|
||||
.AddSingleton<BackgroundJobManager>()
|
||||
.AddHostedService(provider => provider.GetRequiredService<BackgroundJobManager>());
|
||||
|
||||
// Jobs are now managed by BackgroundJobManager
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
public static class ServicesDI
|
||||
{
|
||||
public static IServiceCollection AddServices(this IServiceCollection services) =>
|
||||
services
|
||||
.AddScoped<EventsContext>()
|
||||
.AddScoped<DataContext>()
|
||||
.AddScoped<IEventPublisher, EventPublisher>()
|
||||
.AddHostedService<EventCleanupService>()
|
||||
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
|
||||
.AddScoped<CertificateValidationService>()
|
||||
.AddScoped<ISonarrClient, SonarrClient>()
|
||||
.AddScoped<IRadarrClient, RadarrClient>()
|
||||
.AddScoped<ILidarrClient, LidarrClient>()
|
||||
.AddScoped<IReadarrClient, ReadarrClient>()
|
||||
.AddScoped<IWhisparrClient, WhisparrClient>()
|
||||
.AddScoped<IArrClientFactory, ArrClientFactory>()
|
||||
.AddScoped<QueueCleaner>()
|
||||
.AddScoped<BlacklistSynchronizer>()
|
||||
.AddScoped<MalwareBlocker>()
|
||||
.AddScoped<DownloadCleaner>()
|
||||
.AddScoped<IQueueItemRemover, QueueItemRemover>()
|
||||
.AddScoped<IDownloadHunter, DownloadHunter>()
|
||||
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
|
||||
.AddScoped<IHardLinkFileService, HardLinkFileService>()
|
||||
.AddScoped<IUnixHardLinkFileService, UnixHardLinkFileService>()
|
||||
.AddScoped<IWindowsHardLinkFileService, WindowsHardLinkFileService>()
|
||||
.AddScoped<IArrQueueIterator, ArrQueueIterator>()
|
||||
.AddScoped<IDownloadServiceFactory, DownloadServiceFactory>()
|
||||
.AddScoped<IStriker, Striker>()
|
||||
.AddScoped<FileReader>()
|
||||
.AddScoped<IRuleManager, RuleManager>()
|
||||
.AddScoped<IRuleEvaluator, RuleEvaluator>()
|
||||
.AddScoped<IRuleIntervalValidator, RuleIntervalValidator>()
|
||||
.AddSingleton<IJobManagementService, JobManagementService>()
|
||||
.AddSingleton<IBlocklistProvider, BlocklistProvider>()
|
||||
.AddSingleton(TimeProvider.System)
|
||||
.AddSingleton<AppStatusSnapshot>()
|
||||
.AddHostedService<AppStatusRefreshService>();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
public sealed record ArrInstanceRequest
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
[Required]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Url { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
public ArrInstance ToEntity(Guid configId) => new()
|
||||
{
|
||||
Enabled = Enabled,
|
||||
Name = Name,
|
||||
Url = new Uri(Url),
|
||||
ApiKey = ApiKey,
|
||||
ArrConfigId = configId,
|
||||
};
|
||||
|
||||
public void ApplyTo(ArrInstance instance)
|
||||
{
|
||||
instance.Enabled = Enabled;
|
||||
instance.Name = Name;
|
||||
instance.Url = new Uri(Url);
|
||||
instance.ApiKey = ApiKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
public sealed record TestArrInstanceRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Url { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
public ArrInstance ToTestInstance() => new()
|
||||
{
|
||||
Enabled = true,
|
||||
Name = "Test Instance",
|
||||
Url = new Uri(Url),
|
||||
ApiKey = ApiKey,
|
||||
ArrConfigId = Guid.Empty,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateArrConfigRequest
|
||||
{
|
||||
public short FailedImportMaxStrikes { get; init; } = -1;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user