mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-02-19 07:17:04 -05:00
Compare commits
177 Commits
dev
...
add_authen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18dc0bb7e4 | ||
|
|
dd38b576f7 | ||
|
|
94215cee00 | ||
|
|
197bd0d444 | ||
|
|
d20773ab7b | ||
|
|
f4e92a68ee | ||
|
|
18dc2813eb | ||
|
|
63ef979d0d | ||
|
|
a72f01fe4c | ||
|
|
9699e0fc29 | ||
|
|
0be7e125c9 | ||
|
|
49f0ce9969 | ||
|
|
4d8e27b01e | ||
|
|
d822f7ef32 | ||
|
|
3d7ed0e702 | ||
|
|
f514523de1 | ||
|
|
7160838ab4 | ||
|
|
cf495b5aac | ||
|
|
6388677244 | ||
|
|
9d46c0ae12 | ||
|
|
dad8dd9eee | ||
|
|
5ea3b5273f | ||
|
|
8864207b8e | ||
|
|
94acd9afa4 | ||
|
|
65d25a72a9 | ||
|
|
97eb2fce44 | ||
|
|
701829001c | ||
|
|
8aeeca111c | ||
|
|
c43936ce81 | ||
|
|
f35eb0c922 | ||
|
|
b2b0626b44 | ||
|
|
40f108d7ca | ||
|
|
6570f74b7e | ||
|
|
16f216cf84 | ||
|
|
69551edeff | ||
|
|
7192796e89 | ||
|
|
1d1ee7972f | ||
|
|
8bd6b86018 | ||
|
|
6abb542271 | ||
|
|
2aceae3078 | ||
|
|
65b200a68e | ||
|
|
de0c881944 | ||
|
|
d0ef01d79b | ||
|
|
9457236e99 | ||
|
|
d43b4fc1c4 | ||
|
|
e9750429eb | ||
|
|
b71b268b08 | ||
|
|
a708d22b27 | ||
|
|
a9a3b08ad6 | ||
|
|
1d1e8679e4 | ||
|
|
142d445ed0 | ||
|
|
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 |
9
.github/ISSUE_TEMPLATE/1-bug.yml
vendored
9
.github/ISSUE_TEMPLATE/1-bug.yml
vendored
@@ -7,6 +7,13 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for taking the time to improve Cleanuparr!
|
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
|
- type: checkboxes
|
||||||
id: init
|
id: init
|
||||||
attributes:
|
attributes:
|
||||||
@@ -14,7 +21,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: Reviewed the documentation.
|
- label: Reviewed the documentation.
|
||||||
required: true
|
required: true
|
||||||
- label: Ensured I am using ghcr.io/Cleanuparr/Cleanuparr docker repository.
|
- label: Ensured I am using ghcr.io/cleanuparr/cleanuparr docker repository.
|
||||||
required: true
|
required: true
|
||||||
- label: Ensured I am using the latest version.
|
- label: Ensured I am using the latest version.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
19
.github/ISSUE_TEMPLATE/2-feature.yml
vendored
19
.github/ISSUE_TEMPLATE/2-feature.yml
vendored
@@ -7,6 +7,25 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for taking the time to improve Cleanuparr!
|
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
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
9
.github/ISSUE_TEMPLATE/3-help.yml
vendored
9
.github/ISSUE_TEMPLATE/3-help.yml
vendored
@@ -7,6 +7,13 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
If you are experiencing unexpected behavior, please consider submitting a bug report instead.
|
If you are experiencing unexpected behavior, please consider submitting a bug report instead.
|
||||||
|
- type: checkboxes
|
||||||
|
id: 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
|
- type: checkboxes
|
||||||
id: init
|
id: init
|
||||||
attributes:
|
attributes:
|
||||||
@@ -14,7 +21,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: Reviewed the documentation.
|
- label: Reviewed the documentation.
|
||||||
required: true
|
required: true
|
||||||
- label: Ensured I am using ghcr.io/Cleanuparr/Cleanuparr docker repository.
|
- label: Ensured I am using ghcr.io/cleanuparr/cleanuparr docker repository.
|
||||||
required: true
|
required: true
|
||||||
- label: Ensured I am using the latest version.
|
- label: Ensured I am using the latest version.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
8
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,2 +1,8 @@
|
|||||||
blank_issues_enabled: false
|
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 }}
|
||||||
65
.github/workflows/build-docker.yml
vendored
65
.github/workflows/build-docker.yml
vendored
@@ -1,14 +1,26 @@
|
|||||||
name: Build Docker Images
|
name: Build Docker Images
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*.*.*"
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'code/**'
|
- 'code/**'
|
||||||
workflow_dispatch:
|
|
||||||
workflow_call:
|
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:
|
jobs:
|
||||||
build_app:
|
build_app:
|
||||||
@@ -27,15 +39,37 @@ jobs:
|
|||||||
timeout-minutes: 1
|
timeout-minutes: 1
|
||||||
run: |
|
run: |
|
||||||
githubHeadRef=${{ env.githubHeadRef }}
|
githubHeadRef=${{ env.githubHeadRef }}
|
||||||
|
inputVersion="${{ inputs.app_version }}"
|
||||||
latestDockerTag=""
|
latestDockerTag=""
|
||||||
versionDockerTag=""
|
versionDockerTag=""
|
||||||
|
majorVersionDockerTag=""
|
||||||
|
minorVersionDockerTag=""
|
||||||
version="0.0.1"
|
version="0.0.1"
|
||||||
|
|
||||||
if [[ "$githubRef" =~ ^"refs/tags/" ]]; then
|
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##*/}
|
branch=${githubRef##*/}
|
||||||
latestDockerTag="latest"
|
latestDockerTag="latest"
|
||||||
versionDockerTag=${branch#v}
|
versionDockerTag=${branch#v}
|
||||||
version=${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
|
else
|
||||||
# Determine if this run is for the main branch or another branch
|
# Determine if this run is for the main branch or another branch
|
||||||
if [[ -z "$githubHeadRef" ]]; then
|
if [[ -z "$githubHeadRef" ]]; then
|
||||||
@@ -53,11 +87,16 @@ jobs:
|
|||||||
githubTags=""
|
githubTags=""
|
||||||
|
|
||||||
if [ -n "$latestDockerTag" ]; then
|
if [ -n "$latestDockerTag" ]; then
|
||||||
githubTags="$githubTags,ghcr.io/cleanuparr:$latestDockerTag"
|
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$latestDockerTag"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$versionDockerTag" ]; then
|
if [ -n "$versionDockerTag" ]; then
|
||||||
githubTags="$githubTags,ghcr.io/cleanuparr:$versionDockerTag"
|
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
|
fi
|
||||||
|
|
||||||
# set env vars
|
# set env vars
|
||||||
@@ -102,6 +141,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push docker image
|
- name: Build and push docker image
|
||||||
|
id: docker-build
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
@@ -113,13 +153,14 @@ jobs:
|
|||||||
version=${{ env.versionDockerTag }}
|
version=${{ env.versionDockerTag }}
|
||||||
build-args: |
|
build-args: |
|
||||||
VERSION=${{ env.version }}
|
VERSION=${{ env.version }}
|
||||||
PACKAGES_USERNAME=${{ env.PACKAGES_USERNAME }}
|
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||||
outputs: |
|
|
||||||
type=image
|
|
||||||
platforms: |
|
platforms: |
|
||||||
linux/amd64
|
linux/amd64
|
||||||
linux/arm64
|
linux/arm64
|
||||||
push: true
|
push: ${{ github.event_name == 'pull_request' || inputs.push_docker == true }}
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.githubTags }}
|
${{ env.githubTags }}
|
||||||
|
# Enable BuildKit cache for faster builds
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
175
.github/workflows/build-executable.yml
vendored
175
.github/workflows/build-executable.yml
vendored
@@ -1,40 +1,55 @@
|
|||||||
name: Build Executables
|
name: Build Executables
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*.*.*"
|
|
||||||
workflow_dispatch:
|
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
app_version:
|
||||||
|
description: 'Application version'
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
# Build for each platform in parallel using matrix strategy
|
||||||
|
build-platform:
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
|
|
||||||
- name: Gate
|
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch' }}
|
|
||||||
run: |
|
|
||||||
echo "This workflow only runs on tag events or manual dispatch. Pipeline finished."
|
|
||||||
exit 0
|
|
||||||
|
|
||||||
- name: Set variables
|
- name: Set variables
|
||||||
run: |
|
run: |
|
||||||
repoFullName=${{ github.repository }}
|
|
||||||
ref=${{ github.ref }}
|
ref=${{ github.ref }}
|
||||||
|
|
||||||
# Handle both tag events and manual dispatch
|
# Use input version if provided, otherwise determine from ref
|
||||||
if [[ "$ref" =~ ^refs/tags/ ]]; then
|
if [[ -n "${{ inputs.app_version }}" ]]; then
|
||||||
|
appVersion="${{ inputs.app_version }}"
|
||||||
|
releaseVersion="v$appVersion"
|
||||||
|
elif [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||||
releaseVersion=${ref##refs/tags/}
|
releaseVersion=${ref##refs/tags/}
|
||||||
appVersion=${releaseVersion#v}
|
appVersion=${releaseVersion#v}
|
||||||
else
|
else
|
||||||
# For manual dispatch, use a default version
|
|
||||||
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
|
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
|
||||||
appVersion="0.0.1-dev"
|
appVersion="0.0.1-dev"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
repoFullName=${{ github.repository }}
|
||||||
|
repositoryName=${repoFullName#*/}
|
||||||
|
|
||||||
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
|
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
|
||||||
echo "githubRepositoryName=${repoFullName#*/}" >> $GITHUB_ENV
|
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
|
||||||
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
|
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
|
||||||
echo "appVersion=$appVersion" >> $GITHUB_ENV
|
echo "appVersion=$appVersion" >> $GITHUB_ENV
|
||||||
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
|
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
|
||||||
@@ -58,27 +73,28 @@ jobs:
|
|||||||
ref: ${{ github.ref_name }}
|
ref: ${{ github.ref_name }}
|
||||||
token: ${{ env.REPO_READONLY_PAT }}
|
token: ${{ env.REPO_READONLY_PAT }}
|
||||||
|
|
||||||
- name: Setup Node.js for frontend build
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '18'
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: code/frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Build frontend
|
|
||||||
run: |
|
|
||||||
cd code/frontend
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
- name: Setup dotnet
|
- name: Setup dotnet
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 9.0.x
|
dotnet-version: 10.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
|
- name: Install dependencies and restore
|
||||||
run: |
|
run: |
|
||||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
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
|
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
|
||||||
|
|
||||||
- name: Copy frontend to backend wwwroot
|
- name: Copy frontend to backend wwwroot
|
||||||
@@ -86,92 +102,25 @@ jobs:
|
|||||||
mkdir -p code/backend/${{ env.executableName }}/wwwroot
|
mkdir -p code/backend/${{ env.executableName }}/wwwroot
|
||||||
cp -r code/frontend/dist/ui/browser/* code/backend/${{ env.executableName }}/wwwroot/
|
cp -r code/frontend/dist/ui/browser/* code/backend/${{ env.executableName }}/wwwroot/
|
||||||
|
|
||||||
- name: Build win-x64
|
- name: Build ${{ matrix.platform }}
|
||||||
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
|
|
||||||
|
|
||||||
- name: Build linux-x64
|
|
||||||
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime linux-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
|
|
||||||
|
|
||||||
- name: Build linux-arm64
|
|
||||||
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime linux-arm64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
|
|
||||||
|
|
||||||
- name: Build osx-x64
|
|
||||||
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime osx-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
|
|
||||||
|
|
||||||
- name: Build osx-arm64
|
|
||||||
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime osx-arm64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
|
|
||||||
|
|
||||||
- name: Create sample configuration files
|
|
||||||
run: |
|
run: |
|
||||||
# Create a sample appsettings.json for each platform
|
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
|
||||||
cat > sample-config.json << 'EOF'
|
-c Release \
|
||||||
{
|
--runtime ${{ matrix.runtime }} \
|
||||||
"Logging": {
|
--self-contained \
|
||||||
"LogLevel": {
|
-o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }} \
|
||||||
"Default": "Information",
|
/p:PublishSingleFile=true \
|
||||||
"Microsoft.AspNetCore": "Warning"
|
/p:Version=${{ env.appVersion }} \
|
||||||
}
|
/p:DebugSymbols=false
|
||||||
},
|
|
||||||
"AllowedHosts": "*"
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Copy to each build directory
|
- name: Zip artifact
|
||||||
cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64/appsettings.json
|
|
||||||
cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64/appsettings.json
|
|
||||||
cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64/appsettings.json
|
|
||||||
cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64/appsettings.json
|
|
||||||
cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64/appsettings.json
|
|
||||||
|
|
||||||
- name: Zip win-x64
|
|
||||||
run: |
|
run: |
|
||||||
cd ./artifacts
|
cd ./artifacts
|
||||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64/
|
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}/
|
||||||
|
|
||||||
- name: Zip linux-x64
|
- name: Upload artifact
|
||||||
run: |
|
|
||||||
cd ./artifacts
|
|
||||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64/
|
|
||||||
|
|
||||||
- name: Zip linux-arm64
|
|
||||||
run: |
|
|
||||||
cd ./artifacts
|
|
||||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64/
|
|
||||||
|
|
||||||
- name: Zip osx-x64
|
|
||||||
run: |
|
|
||||||
cd ./artifacts
|
|
||||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64/
|
|
||||||
|
|
||||||
- name: Zip osx-arm64
|
|
||||||
run: |
|
|
||||||
cd ./artifacts
|
|
||||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64/
|
|
||||||
|
|
||||||
- name: Upload artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: cleanuparr-executables
|
name: executable-${{ matrix.platform }}
|
||||||
path: |
|
path: ./artifacts/*.zip
|
||||||
./artifacts/*.zip
|
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Release
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
id: release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
name: ${{ env.releaseVersion }}
|
|
||||||
tag_name: ${{ env.releaseVersion }}
|
|
||||||
repository: ${{ env.githubRepository }}
|
|
||||||
token: ${{ env.REPO_READONLY_PAT }}
|
|
||||||
make_latest: true
|
|
||||||
fail_on_unmatched_files: true
|
|
||||||
target_commitish: main
|
|
||||||
generate_release_notes: true
|
|
||||||
files: |
|
|
||||||
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64.zip
|
|
||||||
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64.zip
|
|
||||||
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64.zip
|
|
||||||
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64.zip
|
|
||||||
./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64.zip
|
|
||||||
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-arm-installer.yml
vendored
376
.github/workflows/build-macos-arm-installer.yml
vendored
@@ -1,376 +0,0 @@
|
|||||||
name: Build macOS ARM Installer
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*.*.*"
|
|
||||||
workflow_dispatch:
|
|
||||||
workflow_call:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-macos-arm-installer:
|
|
||||||
name: Build macOS ARM Installer
|
|
||||||
runs-on: macos-14 # ARM runner for Apple Silicon
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Set variables
|
|
||||||
run: |
|
|
||||||
repoFullName=${{ github.repository }}
|
|
||||||
ref=${{ github.ref }}
|
|
||||||
|
|
||||||
# Handle both tag events and manual dispatch
|
|
||||||
if [[ "$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: Setup Node.js for frontend build
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '18'
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: code/frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Build frontend
|
|
||||||
run: |
|
|
||||||
cd code/frontend
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
- 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 ARM 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 osx-arm64 \
|
|
||||||
--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>11.0</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 [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
|
||||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-arm64.pkg"
|
|
||||||
else
|
|
||||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-arm64-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-arm64-installer
|
|
||||||
path: '${{ env.pkgName }}'
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Release
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
name: ${{ env.releaseVersion }}
|
|
||||||
tag_name: ${{ env.releaseVersion }}
|
|
||||||
repository: ${{ env.githubRepository }}
|
|
||||||
token: ${{ env.REPO_READONLY_PAT }}
|
|
||||||
make_latest: true
|
|
||||||
files: |
|
|
||||||
${{ env.pkgName }}
|
|
||||||
@@ -1,19 +1,35 @@
|
|||||||
name: Build macOS Intel Installer
|
name: Build macOS Installers
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*.*.*"
|
|
||||||
workflow_dispatch:
|
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
app_version:
|
||||||
|
description: 'Application version'
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-macos-intel-installer:
|
build-macos-installer:
|
||||||
name: Build macOS Intel Installer
|
name: Build macOS ${{ matrix.arch }} Installer
|
||||||
runs-on: macos-13 # Intel runner
|
runs-on: ${{ matrix.runner }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: Intel
|
||||||
|
runner: macos-15-intel
|
||||||
|
runtime: osx-x64
|
||||||
|
min_os_version: "10.15"
|
||||||
|
artifact_suffix: intel
|
||||||
|
- arch: ARM
|
||||||
|
runner: macos-15
|
||||||
|
runtime: osx-arm64
|
||||||
|
min_os_version: "11.0"
|
||||||
|
artifact_suffix: arm64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Set variables
|
- name: Set variables
|
||||||
@@ -21,8 +37,11 @@ jobs:
|
|||||||
repoFullName=${{ github.repository }}
|
repoFullName=${{ github.repository }}
|
||||||
ref=${{ github.ref }}
|
ref=${{ github.ref }}
|
||||||
|
|
||||||
# Handle both tag events and manual dispatch
|
# Use input version if provided, otherwise determine from ref
|
||||||
if [[ "$ref" =~ ^refs/tags/ ]]; then
|
if [[ -n "${{ inputs.app_version }}" ]]; then
|
||||||
|
appVersion="${{ inputs.app_version }}"
|
||||||
|
releaseVersion="v$appVersion"
|
||||||
|
elif [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||||
releaseVersion=${ref##refs/tags/}
|
releaseVersion=${ref##refs/tags/}
|
||||||
appVersion=${releaseVersion#v}
|
appVersion=${releaseVersion#v}
|
||||||
else
|
else
|
||||||
@@ -58,30 +77,23 @@ jobs:
|
|||||||
token: ${{ env.REPO_READONLY_PAT }}
|
token: ${{ env.REPO_READONLY_PAT }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Node.js for frontend build
|
- name: Download frontend artifact
|
||||||
uses: actions/setup-node@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
name: frontend-dist
|
||||||
cache: 'npm'
|
path: code/frontend/dist/ui/browser
|
||||||
cache-dependency-path: code/frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Build frontend
|
|
||||||
run: |
|
|
||||||
cd code/frontend
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 9.0.x
|
dotnet-version: 10.0.x
|
||||||
|
|
||||||
- name: Restore .NET dependencies
|
- name: Restore .NET dependencies
|
||||||
run: |
|
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 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
|
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
|
||||||
|
|
||||||
- name: Build macOS Intel executable
|
- name: Build macOS ${{ matrix.arch }} executable
|
||||||
run: |
|
run: |
|
||||||
# Clean any existing output directory
|
# Clean any existing output directory
|
||||||
rm -rf dist
|
rm -rf dist
|
||||||
@@ -90,7 +102,7 @@ jobs:
|
|||||||
# Build to a temporary location
|
# Build to a temporary location
|
||||||
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
|
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
|
||||||
-c Release \
|
-c Release \
|
||||||
--runtime osx-x64 \
|
--runtime ${{ matrix.runtime }} \
|
||||||
--self-contained true \
|
--self-contained true \
|
||||||
-o dist/temp \
|
-o dist/temp \
|
||||||
/p:PublishSingleFile=true \
|
/p:PublishSingleFile=true \
|
||||||
@@ -228,7 +240,7 @@ jobs:
|
|||||||
<key>NSRequiresAquaSystemAppearance</key>
|
<key>NSRequiresAquaSystemAppearance</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>10.15</string>
|
<string>${{ matrix.min_os_version }}</string>
|
||||||
<key>LSApplicationCategoryType</key>
|
<key>LSApplicationCategoryType</key>
|
||||||
<string>public.app-category.productivity</string>
|
<string>public.app-category.productivity</string>
|
||||||
<key>NSSupportsAutomaticTermination</key>
|
<key>NSSupportsAutomaticTermination</key>
|
||||||
@@ -338,11 +350,11 @@ jobs:
|
|||||||
# Copy uninstall script to app bundle for user access
|
# Copy uninstall script to app bundle for user access
|
||||||
cp scripts/uninstall_cleanuparr.sh dist/Cleanuparr.app/Contents/Resources/
|
cp scripts/uninstall_cleanuparr.sh dist/Cleanuparr.app/Contents/Resources/
|
||||||
|
|
||||||
# Determine package name
|
# Determine package name - if app_version input was provided, it's a release build
|
||||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
if [[ -n "${{ inputs.app_version }}" ]] || [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-intel.pkg"
|
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}.pkg"
|
||||||
else
|
else
|
||||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-intel-dev.pkg"
|
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}-dev.pkg"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create PKG installer with better metadata
|
# Create PKG installer with better metadata
|
||||||
@@ -359,18 +371,6 @@ jobs:
|
|||||||
- name: Upload installer as artifact
|
- name: Upload installer as artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: Cleanuparr-macos-intel-installer
|
name: Cleanuparr-macos-${{ matrix.artifact_suffix }}-installer
|
||||||
path: '${{ env.pkgName }}'
|
path: '${{ env.pkgName }}'
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Release
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
name: ${{ env.releaseVersion }}
|
|
||||||
tag_name: ${{ env.releaseVersion }}
|
|
||||||
repository: ${{ env.githubRepository }}
|
|
||||||
token: ${{ env.REPO_READONLY_PAT }}
|
|
||||||
make_latest: true
|
|
||||||
files: |
|
|
||||||
${{ env.pkgName }}
|
|
||||||
62
.github/workflows/build-windows-installer.yml
vendored
62
.github/workflows/build-windows-installer.yml
vendored
@@ -1,11 +1,13 @@
|
|||||||
name: Build Windows Installer
|
name: Build Windows Installer
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*.*.*"
|
|
||||||
workflow_dispatch:
|
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
app_version:
|
||||||
|
description: 'Application version'
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-windows-installer:
|
build-windows-installer:
|
||||||
@@ -17,9 +19,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$repoFullName = "${{ github.repository }}"
|
$repoFullName = "${{ github.repository }}"
|
||||||
$ref = "${{ github.ref }}"
|
$ref = "${{ github.ref }}"
|
||||||
|
$inputVersion = "${{ inputs.app_version }}"
|
||||||
|
|
||||||
# Handle both tag events and manual dispatch
|
# Use input version if provided, otherwise determine from ref
|
||||||
if ($ref -match "^refs/tags/") {
|
if ($inputVersion -ne "") {
|
||||||
|
$appVersion = $inputVersion
|
||||||
|
$releaseVersion = "v$appVersion"
|
||||||
|
} elseif ($ref -match "^refs/tags/") {
|
||||||
$releaseVersion = $ref -replace "refs/tags/", ""
|
$releaseVersion = $ref -replace "refs/tags/", ""
|
||||||
$appVersion = $releaseVersion -replace "^v", ""
|
$appVersion = $releaseVersion -replace "^v", ""
|
||||||
} else {
|
} else {
|
||||||
@@ -34,8 +40,8 @@ jobs:
|
|||||||
echo "githubRepositoryName=$repositoryName" >> $env:GITHUB_ENV
|
echo "githubRepositoryName=$repositoryName" >> $env:GITHUB_ENV
|
||||||
echo "releaseVersion=$releaseVersion" >> $env:GITHUB_ENV
|
echo "releaseVersion=$releaseVersion" >> $env:GITHUB_ENV
|
||||||
echo "appVersion=$appVersion" >> $env:GITHUB_ENV
|
echo "appVersion=$appVersion" >> $env:GITHUB_ENV
|
||||||
echo "executableName=Cleanuparr.Api" >> $env:GITHUB_ENV
|
|
||||||
echo "APP_VERSION=$appVersion" >> $env:GITHUB_ENV
|
echo "APP_VERSION=$appVersion" >> $env:GITHUB_ENV
|
||||||
|
echo "executableName=Cleanuparr.Api" >> $env:GITHUB_ENV
|
||||||
|
|
||||||
- name: Get vault secrets
|
- name: Get vault secrets
|
||||||
uses: hashicorp/vault-action@v2
|
uses: hashicorp/vault-action@v2
|
||||||
@@ -55,23 +61,16 @@ jobs:
|
|||||||
ref: ${{ github.ref_name }}
|
ref: ${{ github.ref_name }}
|
||||||
token: ${{ env.REPO_READONLY_PAT }}
|
token: ${{ env.REPO_READONLY_PAT }}
|
||||||
|
|
||||||
- name: Setup Node.js for frontend build
|
- name: Download frontend artifact
|
||||||
uses: actions/setup-node@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
name: frontend-dist
|
||||||
cache: 'npm'
|
path: code/frontend/dist/ui/browser
|
||||||
cache-dependency-path: code/frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Build frontend
|
|
||||||
run: |
|
|
||||||
cd code/frontend
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 9.0.x
|
dotnet-version: 10.0.x
|
||||||
|
|
||||||
- name: Restore .NET dependencies
|
- name: Restore .NET dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -88,19 +87,6 @@ jobs:
|
|||||||
run: |
|
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
|
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o dist /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugType=None /p:DebugSymbols=false
|
||||||
|
|
||||||
- name: Create sample configuration
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
# Create config directory
|
|
||||||
New-Item -ItemType Directory -Force -Path "config"
|
|
||||||
|
|
||||||
$config = @{
|
|
||||||
"HTTP_PORTS" = 11011
|
|
||||||
"BASE_PATH" = "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
$config | ConvertTo-Json | Out-File -FilePath "config/cleanuparr.json" -Encoding UTF8
|
|
||||||
|
|
||||||
- name: Setup Inno Setup
|
- name: Setup Inno Setup
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
@@ -158,14 +144,4 @@ jobs:
|
|||||||
path: installer/${{ env.installerName }}
|
path: installer/${{ env.installerName }}
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Release
|
# Removed individual release step - handled by main release workflow
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
name: ${{ env.releaseVersion }}
|
|
||||||
tag_name: ${{ env.releaseVersion }}
|
|
||||||
repository: ${{ env.githubRepository }}
|
|
||||||
token: ${{ env.REPO_READONLY_PAT }}
|
|
||||||
make_latest: true
|
|
||||||
files: |
|
|
||||||
installer/${{ env.installerName }}
|
|
||||||
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
|
||||||
9
.github/workflows/docs.yml
vendored
9
.github/workflows/docs.yml
vendored
@@ -2,9 +2,9 @@ name: Deploy Docusaurus to GitHub Pages
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
tags:
|
||||||
paths:
|
- "v*.*.*"
|
||||||
- 'docs/**'
|
workflow_dispatch: {}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -22,11 +22,12 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
ref: main
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 24.x
|
||||||
cache: yarn
|
cache: yarn
|
||||||
cache-dependency-path: docs/yarn.lock
|
cache-dependency-path: docs/yarn.lock
|
||||||
|
|
||||||
|
|||||||
233
.github/workflows/release.yml
vendored
233
.github/workflows/release.yml
vendored
@@ -8,8 +8,32 @@ on:
|
|||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: 'Version to release (e.g., 1.0.0)'
|
description: 'Version to release (e.g., 1.0.0)'
|
||||||
|
required: true
|
||||||
|
runTests:
|
||||||
|
description: 'Run test suite'
|
||||||
|
type: boolean
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
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:
|
jobs:
|
||||||
# Validate release
|
# Validate release
|
||||||
@@ -32,14 +56,17 @@ jobs:
|
|||||||
release_version=${GITHUB_REF##refs/tags/}
|
release_version=${GITHUB_REF##refs/tags/}
|
||||||
app_version=${release_version#v}
|
app_version=${release_version#v}
|
||||||
is_tag=true
|
is_tag=true
|
||||||
elif [[ -n "${{ github.event.inputs.version }}" ]]; then
|
else
|
||||||
# Manual workflow with version
|
# Manual workflow with version
|
||||||
app_version="${{ github.event.inputs.version }}"
|
app_version="${{ github.event.inputs.version }}"
|
||||||
release_version="v$app_version"
|
|
||||||
is_tag=false
|
# Validate version format (x.x.x)
|
||||||
else
|
if ! [[ "$app_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
# Manual workflow without version
|
echo "Error: Version must be in format x.x.x (e.g., 1.0.0)"
|
||||||
app_version="0.0.1-dev-$(date +%Y%m%d-%H%M%S)"
|
echo "Provided version: $app_version"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
release_version="v$app_version"
|
release_version="v$app_version"
|
||||||
is_tag=false
|
is_tag=false
|
||||||
fi
|
fi
|
||||||
@@ -48,39 +75,108 @@ jobs:
|
|||||||
echo "release_version=$release_version" >> $GITHUB_OUTPUT
|
echo "release_version=$release_version" >> $GITHUB_OUTPUT
|
||||||
echo "is_tag=$is_tag" >> $GITHUB_OUTPUT
|
echo "is_tag=$is_tag" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
echo "🏷️ Release Version: $release_version"
|
echo "Release Version: $release_version"
|
||||||
echo "📱 App Version: $app_version"
|
echo "App Version: $app_version"
|
||||||
echo "🔖 Is Tag: $is_tag"
|
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 portable executables
|
||||||
build-executables:
|
build-executables:
|
||||||
needs: validate
|
needs: [validate, test, build-frontend]
|
||||||
uses: ./.github/workflows/build_executable.yml
|
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:
|
||||||
|
app_version: ${{ needs.validate.outputs.app_version }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
# Build Windows installer
|
# Build Windows installer
|
||||||
build-windows-installer:
|
build-windows-installer:
|
||||||
needs: validate
|
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
|
uses: ./.github/workflows/build-windows-installer.yml
|
||||||
|
with:
|
||||||
|
app_version: ${{ needs.validate.outputs.app_version }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
# Build macOS Intel installer
|
# Build macOS installers (Intel and ARM)
|
||||||
build-macos-intel:
|
build-macos:
|
||||||
needs: validate
|
needs: [validate, test, build-frontend]
|
||||||
uses: ./.github/workflows/build-macos-intel-installer.yml
|
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
|
secrets: inherit
|
||||||
|
|
||||||
# Build macOS ARM installer
|
# Build and push Docker image(s)
|
||||||
build-macos-arm:
|
build-docker:
|
||||||
needs: validate
|
needs: [validate, test]
|
||||||
uses: ./.github/workflows/build-macos-arm-installer.yml
|
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
|
secrets: inherit
|
||||||
|
|
||||||
# Create GitHub release
|
# Create GitHub release
|
||||||
create-release:
|
create-release:
|
||||||
needs: [validate, build-executables, build-windows-installer, build-macos-intel, build-macos-arm]
|
needs: [validate, build-executables, build-windows-installer, build-macos]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
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:
|
steps:
|
||||||
- name: Get vault secrets
|
- name: Get vault secrets
|
||||||
@@ -93,72 +189,99 @@ jobs:
|
|||||||
secrets:
|
secrets:
|
||||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download executable artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
pattern: executable-*
|
||||||
path: ./artifacts
|
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
|
- name: List downloaded artifacts
|
||||||
run: |
|
run: |
|
||||||
echo "📦 Downloaded artifacts:"
|
echo "Downloaded artifacts:"
|
||||||
find ./artifacts -type f -name "*.zip" -o -name "*.pkg" -o -name "*.exe" | sort
|
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
|
- name: Create release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
name: Cleanuparr ${{ needs.validate.outputs.release_version }}
|
name: ${{ needs.validate.outputs.release_version }}
|
||||||
tag_name: ${{ needs.validate.outputs.release_version }}
|
tag_name: ${{ needs.validate.outputs.release_version }}
|
||||||
token: ${{ env.REPO_READONLY_PAT }}
|
token: ${{ env.REPO_READONLY_PAT }}
|
||||||
make_latest: true
|
make_latest: true
|
||||||
|
target_commitish: main
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
prerelease: ${{ contains(needs.validate.outputs.app_version, '-') }}
|
|
||||||
files: |
|
files: |
|
||||||
./artifacts/**/*.zip
|
./artifacts/*.zip
|
||||||
./artifacts/**/*.pkg
|
./artifacts/*.pkg
|
||||||
./artifacts/**/*.exe
|
./artifacts/*.exe
|
||||||
|
|
||||||
# Summary job
|
# Summary job
|
||||||
summary:
|
summary:
|
||||||
needs: [validate, build-executables, build-windows-installer, build-macos-intel, build-macos-arm]
|
needs: [validate, test, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: always()
|
if: always()
|
||||||
|
|
||||||
steps:
|
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
|
- name: Build Summary
|
||||||
run: |
|
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 "## 🏗️ Cleanuparr Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "**Version**: ${{ needs.validate.outputs.release_version }}" >> $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 "**App Version**: ${{ needs.validate.outputs.app_version }}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "**Is Tag**: ${{ needs.validate.outputs.is_tag }}" >> $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 "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "### Build Results" >> $GITHUB_STEP_SUMMARY
|
echo "### Build Results" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
# Check job results
|
# Helper function to print job result
|
||||||
if [[ "${{ needs.build-executables.result }}" == "success" ]]; then
|
print_result() {
|
||||||
echo "✅ **Portable Executables**: Success" >> $GITHUB_STEP_SUMMARY
|
local name="$1"
|
||||||
else
|
local result="$2"
|
||||||
echo "❌ **Portable Executables**: ${{ needs.build-executables.result }}" >> $GITHUB_STEP_SUMMARY
|
case "$result" in
|
||||||
fi
|
success) echo "✅ **$name**: Success" >> $GITHUB_STEP_SUMMARY ;;
|
||||||
|
skipped) echo "⏭️ **$name**: Skipped" >> $GITHUB_STEP_SUMMARY ;;
|
||||||
|
*) echo "❌ **$name**: $result" >> $GITHUB_STEP_SUMMARY ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
if [[ "${{ needs.build-windows-installer.result }}" == "success" ]]; then
|
print_result "Tests" "${{ needs.test.result }}"
|
||||||
echo "✅ **Windows Installer**: Success" >> $GITHUB_STEP_SUMMARY
|
print_result "Frontend Build" "${{ needs.build-frontend.result }}"
|
||||||
else
|
print_result "Portable Executables" "${{ needs.build-executables.result }}"
|
||||||
echo "❌ **Windows Installer**: ${{ needs.build-windows-installer.result }}" >> $GITHUB_STEP_SUMMARY
|
print_result "Windows Installer" "${{ needs.build-windows-installer.result }}"
|
||||||
fi
|
print_result "macOS Installers (Intel & ARM)" "${{ needs.build-macos.result }}"
|
||||||
|
print_result "Docker Image Build" "${{ needs.build-docker.result }}"
|
||||||
if [[ "${{ needs.build-macos-intel.result }}" == "success" ]]; then
|
|
||||||
echo "✅ **macOS Intel Installer**: Success" >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "❌ **macOS Intel Installer**: ${{ needs.build-macos-intel.result }}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${{ needs.build-macos-arm.result }}" == "success" ]]; then
|
|
||||||
echo "✅ **macOS ARM Installer**: Success" >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "❌ **macOS ARM Installer**: ${{ needs.build-macos-arm.result }}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "🎉 **Build completed!**" >> $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: 10.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}"
|
||||||
350
CLAUDE.md
Normal file
350
CLAUDE.md
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
# Cleanuparr - Claude AI Rules
|
||||||
|
|
||||||
|
## 🚨 Critical Guidelines
|
||||||
|
|
||||||
|
**READ THIS FIRST:**
|
||||||
|
1. ⚠️ **DO NOT break existing functionality** - All features are critical and must continue to work
|
||||||
|
2. ❓ **When in doubt, ASK** - Always clarify before implementing uncertain changes
|
||||||
|
3. 📋 **Follow existing patterns** - Study the codebase style before making changes
|
||||||
|
4. 🆕 **Ask before introducing new patterns** - Use current coding standards or get approval first
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients like qBittorrent, Transmission, Deluge, and µTorrent. It provides malware protection, automated cleanup, and queue management for *arr applications.
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- Strike system for bad downloads
|
||||||
|
- Malware detection and blocking
|
||||||
|
- Automatic search triggering after removal
|
||||||
|
- Orphaned download cleanup with cross-seed support
|
||||||
|
- Support for multiple notification providers (Discord, etc.)
|
||||||
|
|
||||||
|
## Architecture & Tech Stack
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **.NET 10.0** (C#) with ASP.NET Core
|
||||||
|
- **Architecture**: Clean Architecture pattern
|
||||||
|
- `Cleanuparr.Domain` - Domain models and business logic
|
||||||
|
- `Cleanuparr.Application` - Application services and use cases
|
||||||
|
- `Cleanuparr.Infrastructure` - External integrations (*arr apps, download clients)
|
||||||
|
- `Cleanuparr.Persistence` - Data access with EF Core (SQLite)
|
||||||
|
- `Cleanuparr.Api` - REST API and web host
|
||||||
|
- `Cleanuparr.Shared` - Shared utilities
|
||||||
|
- **Database**: SQLite with Entity Framework Core 10.0
|
||||||
|
- Two separate contexts: `DataContext` and `EventsContext`
|
||||||
|
- **Key Libraries**:
|
||||||
|
- MassTransit (messaging)
|
||||||
|
- Quartz.NET (scheduling)
|
||||||
|
- Serilog (logging)
|
||||||
|
- SignalR (real-time communication)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Angular 21** with TypeScript 5.9 (standalone components, zoneless, OnPush)
|
||||||
|
- **UI**: Custom glassmorphism design system (no external UI frameworks)
|
||||||
|
- **Icons**: @ng-icons/core + @ng-icons/tabler-icons
|
||||||
|
- **Design System**: 3-layer SCSS (`_variables` → `_tokens` → `_themes`), dark/light themes
|
||||||
|
- **State Management**: @ngrx/signals (Angular signals-based)
|
||||||
|
- **Real-time Updates**: SignalR (@microsoft/signalr)
|
||||||
|
- **PWA**: Service Worker support enabled
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **Docusaurus** (TypeScript-based static site)
|
||||||
|
- Hosted at https://cleanuparr.github.io/Cleanuparr/
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
- **Docker** (primary distribution method)
|
||||||
|
- Standalone executables for Windows, macOS, and Linux
|
||||||
|
- Platform installers for Windows (.exe) and macOS (.pkg)
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- .NET 10.0 SDK
|
||||||
|
- Node.js 18+
|
||||||
|
- Git
|
||||||
|
- (Optional) Make for database migrations
|
||||||
|
- (Optional) JetBrains Rider or Visual Studio
|
||||||
|
|
||||||
|
### GitHub Packages Authentication
|
||||||
|
Cleanuparr uses GitHub Packages for NuGet dependencies. Configure access:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
You need a GitHub PAT with `read:packages` permission.
|
||||||
|
|
||||||
|
### Running the Backend
|
||||||
|
```bash
|
||||||
|
cd code/backend
|
||||||
|
dotnet build Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||||
|
dotnet run --project Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||||
|
```
|
||||||
|
API runs at http://localhost:5000
|
||||||
|
|
||||||
|
### Running the Frontend
|
||||||
|
```bash
|
||||||
|
cd code/frontend
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
UI runs at http://localhost:4200
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
```bash
|
||||||
|
cd code/backend
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Documentation
|
||||||
|
```bash
|
||||||
|
cd docs
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
Docs run at http://localhost:3000
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Cleanuparr/
|
||||||
|
├── code/
|
||||||
|
│ ├── backend/
|
||||||
|
│ │ ├── Cleanuparr.Api/ # API entry point
|
||||||
|
│ │ ├── Cleanuparr.Application/ # Business logic layer
|
||||||
|
│ │ ├── Cleanuparr.Domain/ # Domain models
|
||||||
|
│ │ ├── Cleanuparr.Infrastructure/ # External integrations
|
||||||
|
│ │ ├── Cleanuparr.Persistence/ # Database & EF Core
|
||||||
|
│ │ ├── Cleanuparr.Shared/ # Shared utilities
|
||||||
|
│ │ └── *.Tests/ # Unit tests
|
||||||
|
│ ├── frontend/ # Angular 21 application
|
||||||
|
│ ├── ui/ # Built frontend assets
|
||||||
|
│ ├── Dockerfile # Multi-stage Docker build
|
||||||
|
│ ├── entrypoint.sh # Docker entrypoint
|
||||||
|
│ └── Makefile # Build & migration helpers
|
||||||
|
├── docs/ # Docusaurus documentation
|
||||||
|
├── Logo/ # Branding assets
|
||||||
|
├── .github/workflows/ # CI/CD pipelines
|
||||||
|
├── blacklist # Default malware patterns
|
||||||
|
├── blacklist_permissive # Alternative blacklist
|
||||||
|
├── whitelist # Safe file patterns
|
||||||
|
└── CONTRIBUTING.md # Contribution guidelines
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Standards & Conventions
|
||||||
|
|
||||||
|
**IMPORTANT:** Always study existing code in the relevant area before making changes. Match the existing style exactly.
|
||||||
|
|
||||||
|
### Backend (C#)
|
||||||
|
- Follow [Microsoft C# Coding Conventions](https://docs.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
|
||||||
|
- Use nullable reference types (`<Nullable>enable</Nullable>`)
|
||||||
|
- Add XML documentation comments for public APIs
|
||||||
|
- Write unit tests for business logic
|
||||||
|
- Use meaningful names - avoid abbreviations unless widely understood
|
||||||
|
- Keep services focused - single responsibility principle
|
||||||
|
- **Study existing service implementations before creating new ones**
|
||||||
|
|
||||||
|
### Frontend (TypeScript/Angular)
|
||||||
|
- Follow [Angular Style Guide](https://angular.io/guide/styleguide)
|
||||||
|
- Use TypeScript strict mode
|
||||||
|
- All components must be **standalone** (no NgModules) with **ChangeDetectionStrategy.OnPush**
|
||||||
|
- Use `input()` / `output()` function APIs (not `@Input()` / `@Output()` decorators)
|
||||||
|
- Use Angular **signals** for reactive state (`signal()`, `computed()`, `effect()`)
|
||||||
|
- Follow the 3-layer SCSS design system (`_variables` → `_tokens` → `_themes`) for styling
|
||||||
|
- Component naming: `{feature}.component.ts`
|
||||||
|
- Service naming: `{feature}.service.ts`
|
||||||
|
- **Look at similar existing components before creating new ones**
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Write unit tests for new features and bug fixes
|
||||||
|
- Use descriptive test names that explain what is being tested
|
||||||
|
- Backend: xUnit or NUnit conventions
|
||||||
|
- Frontend: Jasmine/Karma
|
||||||
|
- **Test that existing functionality still works after changes**
|
||||||
|
|
||||||
|
### Git Commit Messages
|
||||||
|
- Use clear, descriptive messages in imperative mood
|
||||||
|
- Examples: "Add Discord notification support", "Fix memory leak in download client polling"
|
||||||
|
- Reference issue numbers when applicable: "Fix #123: Handle null response from Radarr API"
|
||||||
|
|
||||||
|
### Discovering Issues
|
||||||
|
If you encounter potential gotchas, common mistakes, or areas that need special attention during development:
|
||||||
|
- **Flag them to the maintainer immediately**
|
||||||
|
- Document them if confirmed
|
||||||
|
- Consider if they should be added to this guide
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
|
||||||
|
Cleanuparr uses two separate database contexts:
|
||||||
|
- **DataContext**: Main application data
|
||||||
|
- **EventsContext**: Event logging and audit trail
|
||||||
|
|
||||||
|
### Creating Migrations
|
||||||
|
From the `code` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Data migrations
|
||||||
|
make migrate-data name=YourMigrationName
|
||||||
|
|
||||||
|
# Events migrations
|
||||||
|
make migrate-events name=YourMigrationName
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
make migrate-data name=AddDownloadClientConfig
|
||||||
|
make migrate-events name=AddStrikeEvents
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Development Workflows
|
||||||
|
|
||||||
|
### Adding a New *arr Application Integration
|
||||||
|
1. Add integration in `Cleanuparr.Infrastructure/Arr/`
|
||||||
|
2. Update domain models in `Cleanuparr.Domain/`
|
||||||
|
3. Create/update services in `Cleanuparr.Application/`
|
||||||
|
4. Add API endpoints in `Cleanuparr.Api/`
|
||||||
|
5. Update frontend in `code/frontend/src/app/`
|
||||||
|
6. Document in `docs/docs/`
|
||||||
|
|
||||||
|
### Adding a New Download Client
|
||||||
|
1. Add client implementation in `Cleanuparr.Infrastructure/DownloadClients/`
|
||||||
|
2. Follow existing patterns (qBittorrent, Transmission, etc.)
|
||||||
|
3. Add configuration models to `Cleanuparr.Domain/`
|
||||||
|
4. Update API and frontend as above
|
||||||
|
|
||||||
|
### Adding a New Notification Provider
|
||||||
|
1. Add provider in `Cleanuparr.Infrastructure/Notifications/`
|
||||||
|
2. Update configuration models
|
||||||
|
3. Add UI configuration in frontend
|
||||||
|
4. Test with actual service
|
||||||
|
|
||||||
|
## Important Files
|
||||||
|
|
||||||
|
### Configuration Files
|
||||||
|
- `code/backend/Cleanuparr.Api/appsettings.json` - Backend configuration
|
||||||
|
- `code/frontend/angular.json` - Angular build configuration
|
||||||
|
- `code/Dockerfile` - Docker multi-stage build
|
||||||
|
- `docs/docusaurus.config.ts` - Documentation site config
|
||||||
|
|
||||||
|
### CI/CD Workflows
|
||||||
|
- `.github/workflows/test.yml` - Run tests
|
||||||
|
- `.github/workflows/build-docker.yml` - Build Docker images
|
||||||
|
- `.github/workflows/build-executable.yml` - Build standalone executables
|
||||||
|
- `.github/workflows/release.yml` - Create releases
|
||||||
|
- `.github/workflows/docs.yml` - Deploy documentation
|
||||||
|
|
||||||
|
### Malware Protection
|
||||||
|
- `blacklist` - Default malware file patterns (strict)
|
||||||
|
- `blacklist_permissive` - Less strict patterns
|
||||||
|
- `whitelist` - Known safe file extensions
|
||||||
|
- `whitelist_with_subtitles` - Includes subtitle formats
|
||||||
|
|
||||||
|
## Contributing Guidelines
|
||||||
|
|
||||||
|
### Before Starting Work
|
||||||
|
1. **Announce your intent** - Comment on an issue or create a new one
|
||||||
|
2. **Wait for approval** from maintainers
|
||||||
|
3. Fork the repository and create a feature branch
|
||||||
|
4. Make your changes following code standards
|
||||||
|
5. Test thoroughly (both manual and automated tests)
|
||||||
|
6. Submit a PR with clear description and testing notes
|
||||||
|
|
||||||
|
### Pull Request Requirements
|
||||||
|
- Link to related issue
|
||||||
|
- Clear description of changes
|
||||||
|
- Evidence of testing
|
||||||
|
- Updated documentation if needed
|
||||||
|
- No breaking changes without discussion
|
||||||
|
|
||||||
|
## Docker Development
|
||||||
|
|
||||||
|
### Build Local Docker Image
|
||||||
|
```bash
|
||||||
|
cd code
|
||||||
|
docker build \
|
||||||
|
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||||
|
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||||
|
-t cleanuparr:local \
|
||||||
|
-f Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Architecture Build
|
||||||
|
```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 .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
When running via Docker:
|
||||||
|
- `PORT` - API port (default: 11011)
|
||||||
|
- `PUID` - User ID for file permissions
|
||||||
|
- `PGID` - Group ID for file permissions
|
||||||
|
- `TZ` - Timezone (e.g., `America/New_York`)
|
||||||
|
|
||||||
|
## Security & Safety
|
||||||
|
|
||||||
|
- Never commit sensitive data (API keys, tokens, passwords)
|
||||||
|
- All *arr and download client credentials are stored encrypted
|
||||||
|
- The malware detection system uses pattern matching on file extensions and names
|
||||||
|
- Always validate user input on both frontend and backend
|
||||||
|
- Follow OWASP guidelines for web application security
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- **Documentation**: https://cleanuparr.github.io/Cleanuparr/
|
||||||
|
- **Discord**: https://discord.gg/SCtMCgtsc4
|
||||||
|
- **GitHub Issues**: https://github.com/Cleanuparr/Cleanuparr/issues
|
||||||
|
- **Releases**: https://github.com/Cleanuparr/Cleanuparr/releases
|
||||||
|
|
||||||
|
## Working with Claude - IMPORTANT
|
||||||
|
|
||||||
|
### Core Principles
|
||||||
|
1. **When in doubt, ASK** - Don't assume, clarify with the maintainer first
|
||||||
|
2. **Don't break existing functionality** - Everything is important and needs to work
|
||||||
|
3. **Follow existing coding style** - Study the codebase patterns before making changes
|
||||||
|
4. **Use current coding standards** - If you want to introduce something new, ask first
|
||||||
|
|
||||||
|
### When Modifying Code
|
||||||
|
- **ALWAYS read existing files before suggesting changes**
|
||||||
|
- Understand the current architecture and patterns
|
||||||
|
- Prefer editing existing files over creating new ones
|
||||||
|
- Follow the established conventions in the codebase exactly
|
||||||
|
- Test changes locally when possible
|
||||||
|
- **If you're unsure about an approach, ask before implementing**
|
||||||
|
|
||||||
|
### When Adding Features
|
||||||
|
- Review similar existing features first to understand patterns
|
||||||
|
- Maintain consistency with existing UI/UX patterns
|
||||||
|
- Update both backend and frontend together
|
||||||
|
- Add/update documentation
|
||||||
|
- Consider backwards compatibility
|
||||||
|
- **Ask about architectural decisions before implementing new patterns**
|
||||||
|
|
||||||
|
### When Fixing Bugs
|
||||||
|
- Understand the root cause before proposing a fix
|
||||||
|
- **Be careful not to break other functionality** - test related areas
|
||||||
|
- Add tests to prevent regression
|
||||||
|
- Update relevant documentation if behavior changes
|
||||||
|
- Consider if other parts of the codebase might have similar issues
|
||||||
|
- **Flag any potential gotchas or issues you discover**
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The project uses **Clean Architecture** - respect layer boundaries
|
||||||
|
- Database migrations require both contexts - don't forget EventsContext
|
||||||
|
- Frontend uses a **custom glassmorphism design system** - don't introduce external UI frameworks (no PrimeNG, Material, etc.)
|
||||||
|
- All frontend components are **standalone** with **OnPush** change detection
|
||||||
|
- All downloads from *arr apps are processed through a **strike system**
|
||||||
|
- The malware blocker is a critical security feature - changes require careful testing
|
||||||
|
- Cross-seed integration allows keeping torrents that are actively seeding
|
||||||
|
- Real-time updates use **SignalR** - maintain websocket patterns when adding features
|
||||||
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 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||||
|
- [Node.js 18+](https://nodejs.org/)
|
||||||
|
- [Git](https://git-scm.com/)
|
||||||
|
- (Optional) [Make](https://www.gnu.org/software/make/) for database migrations
|
||||||
|
- (Optional) IDE: [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio](https://visualstudio.microsoft.com/)
|
||||||
|
|
||||||
|
### Repository Setup
|
||||||
|
|
||||||
|
1. Fork the repository on GitHub
|
||||||
|
2. Clone your fork locally:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/YOUR_USERNAME/Cleanuparr.git
|
||||||
|
cd Cleanuparr
|
||||||
|
```
|
||||||
|
3. Add the upstream repository:
|
||||||
|
```bash
|
||||||
|
git remote add upstream https://github.com/Cleanuparr/Cleanuparr.git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Development
|
||||||
|
|
||||||
|
### Initial Setup
|
||||||
|
|
||||||
|
#### 1. Create a GitHub Personal Access Token (PAT)
|
||||||
|
|
||||||
|
Cleanuparr uses GitHub Packages for NuGet dependencies. You'll need a PAT with `read:packages` permission:
|
||||||
|
|
||||||
|
1. Go to [GitHub Settings > Developer Settings > Personal Access Tokens > Tokens (classic)](https://github.com/settings/tokens)
|
||||||
|
2. Click "Generate new token" → "Generate new token (classic)"
|
||||||
|
3. Give it a descriptive name (e.g., "Cleanuparr NuGet Access")
|
||||||
|
4. Set an expiration (recommend 90 days or longer for development)
|
||||||
|
5. Select only the `read:packages` scope
|
||||||
|
6. Click "Generate token" and copy it
|
||||||
|
|
||||||
|
#### 2. Configure NuGet Source
|
||||||
|
|
||||||
|
Add the Cleanuparr NuGet repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet nuget add source \
|
||||||
|
--username YOUR_GITHUB_USERNAME \
|
||||||
|
--password YOUR_GITHUB_PAT \
|
||||||
|
--store-password-in-clear-text \
|
||||||
|
--name Cleanuparr \
|
||||||
|
https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `YOUR_GITHUB_USERNAME` and `YOUR_GITHUB_PAT` with your GitHub username and the PAT you created.
|
||||||
|
|
||||||
|
### Running the Backend
|
||||||
|
|
||||||
|
#### Option 1: Using .NET CLI
|
||||||
|
|
||||||
|
Navigate to the backend directory:
|
||||||
|
```bash
|
||||||
|
cd code/backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the application:
|
||||||
|
```bash
|
||||||
|
dotnet build Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the application:
|
||||||
|
```bash
|
||||||
|
dotnet run --project Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tests:
|
||||||
|
```bash
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at http://localhost:5000
|
||||||
|
|
||||||
|
#### Option 2: Using an IDE
|
||||||
|
|
||||||
|
For JetBrains Rider or Visual Studio:
|
||||||
|
1. Open the solution file: `code/backend/cleanuparr.sln`
|
||||||
|
2. Set `Cleanuparr.Api` as the startup project
|
||||||
|
3. Press `F5` to start the application
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
Cleanuparr uses two separate database contexts: `DataContext` and `EventsContext`.
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
|
||||||
|
Install Make if not already installed:
|
||||||
|
- Windows: Install via [Chocolatey](https://chocolatey.org/) (`choco install make`) or use [WSL](https://docs.microsoft.com/windows/wsl/)
|
||||||
|
- macOS: Install via Homebrew (`brew install make`)
|
||||||
|
- Linux: Usually pre-installed, or install via package manager (`apt install make`, `yum install make`, etc.)
|
||||||
|
|
||||||
|
#### Creating Migrations
|
||||||
|
|
||||||
|
From the `code` directory:
|
||||||
|
|
||||||
|
For data migrations (DataContext):
|
||||||
|
```bash
|
||||||
|
make migrate-data name=YourMigrationName
|
||||||
|
```
|
||||||
|
|
||||||
|
For events migrations (EventsContext):
|
||||||
|
```bash
|
||||||
|
make migrate-events name=YourMigrationName
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
make migrate-data name=AddUserPreferences
|
||||||
|
make migrate-events name=AddAuditLogEvents
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Development
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. Navigate to the frontend directory:
|
||||||
|
```bash
|
||||||
|
cd code/frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the development server:
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The UI will be available at http://localhost:4200
|
||||||
|
|
||||||
|
## Documentation Development
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. Navigate to the docs directory:
|
||||||
|
```bash
|
||||||
|
cd docs
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the development server:
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The documentation site will be available at http://localhost:3000
|
||||||
|
|
||||||
|
## Building with Docker
|
||||||
|
|
||||||
|
### Building a Local Docker Image
|
||||||
|
|
||||||
|
To build the Docker image locally for testing:
|
||||||
|
|
||||||
|
1. Navigate to the `code` directory:
|
||||||
|
```bash
|
||||||
|
cd code
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Build the image:
|
||||||
|
```bash
|
||||||
|
docker build \
|
||||||
|
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||||
|
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||||
|
-t cleanuparr:local \
|
||||||
|
-f Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `YOUR_GITHUB_USERNAME` and `YOUR_GITHUB_PAT` with your credentials.
|
||||||
|
|
||||||
|
3. Run the container:
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name cleanuparr-dev \
|
||||||
|
-p 11011:11011 \
|
||||||
|
-v /path/to/config:/config \
|
||||||
|
-e PUID=1000 \
|
||||||
|
-e PGID=1000 \
|
||||||
|
-e TZ=Etc/UTC \
|
||||||
|
cleanuparr:local
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Access the application at http://localhost:11011
|
||||||
|
|
||||||
|
### Building for Multiple Architectures
|
||||||
|
|
||||||
|
Use Docker Buildx for multi-platform builds:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||||
|
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||||
|
-t cleanuparr:local \
|
||||||
|
-f Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Standards
|
||||||
|
|
||||||
|
### Backend (.NET/C#)
|
||||||
|
- Follow existing conventions and [Microsoft C# Coding Conventions](https://docs.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
|
||||||
|
- Use meaningful variable and method names
|
||||||
|
- Add XML documentation comments for public APIs
|
||||||
|
- Write unit tests whenever possible
|
||||||
|
|
||||||
|
### Frontend (Angular/TypeScript)
|
||||||
|
- Follow existing conventions and the [Angular Style Guide](https://angular.io/guide/styleguide)
|
||||||
|
- Use TypeScript strict mode
|
||||||
|
- Write unit tests whenever possible
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Use clear, concise language
|
||||||
|
- Include code examples where appropriate
|
||||||
|
- Update relevant documentation when adding/changing features
|
||||||
|
- Check for spelling and grammar
|
||||||
|
|
||||||
|
## Submitting Your Contribution
|
||||||
|
|
||||||
|
### 1. Create a Feature Branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b feature/your-feature-name
|
||||||
|
# or
|
||||||
|
git checkout -b fix/your-bug-fix-name
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Make Your Changes
|
||||||
|
|
||||||
|
- Write clean, well-documented code
|
||||||
|
- Follow the code standards outlined above
|
||||||
|
- **Test your changes thoroughly!**
|
||||||
|
|
||||||
|
### 3. Commit Your Changes
|
||||||
|
|
||||||
|
Write clear, descriptive commit messages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Add feature: brief description of your changes"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Keep Your Branch Updated
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch upstream
|
||||||
|
git rebase upstream/main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Push to Your Fork
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin feature/your-feature-name
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Create a Pull Request
|
||||||
|
|
||||||
|
1. Go to the [Cleanuparr repository](https://github.com/Cleanuparr/Cleanuparr)
|
||||||
|
2. Click "New Pull Request"
|
||||||
|
3. Select your fork and branch
|
||||||
|
4. Fill out the PR template with:
|
||||||
|
- A descriptive title (e.g., "Add support for Prowlarr integration" or "Fix memory leak in download client polling")
|
||||||
|
- Description of changes
|
||||||
|
- Related issue number
|
||||||
|
- Testing performed
|
||||||
|
- Screenshots (if applicable)
|
||||||
|
|
||||||
|
### 7. Code Review Process
|
||||||
|
|
||||||
|
- Maintainers will review your PR
|
||||||
|
- Address any feedback or requested changes
|
||||||
|
- Once approved, your PR will be merged
|
||||||
|
|
||||||
|
## Other Ways to Contribute
|
||||||
|
|
||||||
|
### Help Test New Features
|
||||||
|
|
||||||
|
We're always looking for testers to help validate new features before they are released. If you'd like to help test upcoming changes:
|
||||||
|
|
||||||
|
1. Join our [Discord community](https://discord.gg/SCtMCgtsc4)
|
||||||
|
2. Let us know you're interested in testing
|
||||||
|
3. We'll provide you with pre-release builds and testing instructions
|
||||||
|
|
||||||
|
Your feedback helps us catch issues early and deliver better releases.
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
- Discord: Join our [Discord community](https://discord.gg/SCtMCgtsc4) for real-time help
|
||||||
|
- Issues: Check existing [GitHub issues](https://github.com/Cleanuparr/Cleanuparr/issues) or create a new one
|
||||||
|
- Documentation: Review the [complete documentation](https://cleanuparr.github.io/Cleanuparr/)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
By contributing to Cleanuparr, you agree that your contributions will be licensed under the same license as the project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Thanks for contributing to Cleanuparr!
|
||||||
3
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
|
||||||
102
README.md
102
README.md
@@ -2,6 +2,11 @@ _Love this project? Give it a ⭐️ and let others know!_
|
|||||||
|
|
||||||
# <img width="24px" src="./Logo/256.png" alt="Cleanuparr"></img> Cleanuparr
|
# <img width="24px" src="./Logo/256.png" alt="Cleanuparr"></img> Cleanuparr
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
[](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml)
|
||||||
|
|
||||||
|
|
||||||
[](https://discord.gg/SCtMCgtsc4)
|
[](https://discord.gg/SCtMCgtsc4)
|
||||||
|
|
||||||
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.
|
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.
|
||||||
@@ -12,34 +17,89 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
|
|||||||
> **Features:**
|
> **Features:**
|
||||||
> - Strike system to mark bad downloads.
|
> - Strike system to mark bad downloads.
|
||||||
> - Remove and block downloads that reached a maximum number of strikes.
|
> - Remove and block downloads that reached a maximum number of strikes.
|
||||||
> - Remove and block downloads that are **failing to be imported** by the arrs. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/queue-cleaner/import-failed)
|
> - Remove and block downloads that are **failing to be imported** by the arrs.
|
||||||
> - Remove and block downloads that are **stalled** or in **metadata downloading** state. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/queue-cleaner/stalled)
|
> - Remove and block downloads that are **stalled** or in **metadata downloading** state.
|
||||||
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/queue-cleaner/slow)
|
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**.
|
||||||
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Content Blocker**. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/content-blocker/general)
|
> - 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.
|
> - Automatically trigger a search for downloads removed from the arrs.
|
||||||
> - Clean up downloads that have been **seeding** for a certain amount of time. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/download-cleaner/seeding)
|
> - 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). [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/download-cleaner/hardlinks)
|
> - 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. [configuration](https://cleanuparr.github.io/cleanuparr/docs/category/notifications)
|
> - Notify on strike or download removal.
|
||||||
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
|
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
|
||||||
|
|
||||||
Cleanuparr supports both qBittorrent's built-in exclusion features and its own blocklist-based system. Binaries for all platforms are provided, along with Docker images for easy deployment.
|
## Sponsored by GitAds
|
||||||
|
[](https://gitads.dev/v1/ad-track?source=cleanuparr/cleanuparr@github)
|
||||||
|
|
||||||
## Quick Start
|
## Screenshots
|
||||||
|
|
||||||
> [!NOTE]
|
https://cleanuparr.github.io/Cleanuparr/docs/screenshots
|
||||||
>
|
|
||||||
> 1. **Docker (Recommended)**
|
|
||||||
> Pull the Docker image from `ghcr.io/Cleanuparr/Cleanuparr:latest`.
|
|
||||||
>
|
|
||||||
> 2. **Unraid (for Unraid users)**
|
|
||||||
> Use the Unraid Community App.
|
|
||||||
>
|
|
||||||
> 3. **Manual Installation (if you're not using Docker)**
|
|
||||||
> Go to [Windows](#windows), [Linux](#linux) or [MacOS](#macos).
|
|
||||||
|
|
||||||
# Docs
|
## 🎯 Supported Applications
|
||||||
|
|
||||||
Docs can be found [here](https://Cleanuparr.github.io/Cleanuparr/).
|
### *Arr Applications (latest version)
|
||||||
|
- **Sonarr**
|
||||||
|
- **Radarr**
|
||||||
|
- **Lidarr**
|
||||||
|
- **Readarr**
|
||||||
|
- **Whisparr v2**
|
||||||
|
|
||||||
|
### Download Clients (latest version)
|
||||||
|
- **qBittorrent**
|
||||||
|
- **Transmission**
|
||||||
|
- **Deluge**
|
||||||
|
- **µTorrent**
|
||||||
|
|
||||||
|
### Platforms
|
||||||
|
- **Docker**
|
||||||
|
- **Windows**
|
||||||
|
- **macOS**
|
||||||
|
- **Linux**
|
||||||
|
- **Unraid**
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name cleanuparr \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 11011:11011 \
|
||||||
|
-v /path/to/config:/config \
|
||||||
|
-e PORT=11011 \
|
||||||
|
-e PUID=1000 \
|
||||||
|
-e PGID=1000 \
|
||||||
|
-e TZ=Etc/UTC \
|
||||||
|
ghcr.io/cleanuparr/cleanuparr:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
For Docker Compose, health checks, and other installation methods, see 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/).
|
||||||
|
|
||||||
|
### 🌐 Access the Web Interface
|
||||||
|
|
||||||
|
After installation, open your browser and navigate to:
|
||||||
|
```
|
||||||
|
http://localhost:11011
|
||||||
|
```
|
||||||
|
|
||||||
|
**Next Steps:** Check out the [📖 Complete Documentation](https://cleanuparr.github.io/Cleanuparr/) for detailed configuration guides and setup instructions.
|
||||||
|
|
||||||
|
## 📖 Documentation & Support
|
||||||
|
|
||||||
|
- **📚 [Complete Documentation](https://cleanuparr.github.io/Cleanuparr/)** - Installation guides, configuration, and troubleshooting
|
||||||
|
- **⚙️ [Configuration Guide](https://cleanuparr.github.io/Cleanuparr/docs/category/configuration)** - Set up download clients, *arr apps, and features
|
||||||
|
- **🔧 [Setup Scenarios](https://cleanuparr.github.io/Cleanuparr/docs/category/setup-scenarios)** - Common use cases and examples
|
||||||
|
- **💬 [Discord Community](https://discord.gg/SCtMCgtsc4)** - Get help and discuss with other users
|
||||||
|
- **🔗 [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases)** - Download binaries and view changelog
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
We welcome contributions from the community! Whether it's bug fixes, new features, documentation improvements, or testing, your help is appreciated.
|
||||||
|
|
||||||
|
**Before contributing:** Please read our [Contributing Guide](CONTRIBUTING.md) and announce your intent to work on an issue before starting.
|
||||||
|
|
||||||
|
- **[Contributing Guide](CONTRIBUTING.md)** - Learn how to set up your development environment and submit contributions
|
||||||
|
- **[Report Issues](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Found a bug? Let us know!
|
||||||
|
- **[Feature Requests](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Share your ideas for new features
|
||||||
|
- **[Help Test Features](https://discord.gg/SCtMCgtsc4)** - Join Discord to test pre-release features and provide feedback
|
||||||
|
|
||||||
# <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>
|
# <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>
|
||||||
|
|
||||||
|
|||||||
346
blacklist
346
blacklist
@@ -1,12 +1,27 @@
|
|||||||
*(sample).*
|
*(sample).*
|
||||||
*.0xe
|
*sample.avchd
|
||||||
|
*sample.avi
|
||||||
|
*sample.mkv
|
||||||
|
*sample.mov
|
||||||
|
*sample.mp4
|
||||||
|
*sample.webm
|
||||||
|
*sample.wmv
|
||||||
|
*.000
|
||||||
*.001
|
*.001
|
||||||
|
*.002
|
||||||
|
*.004
|
||||||
|
*.0xe
|
||||||
*.73k
|
*.73k
|
||||||
*.73p
|
*.73p
|
||||||
*.7z
|
*.7z
|
||||||
|
*.7z.001
|
||||||
|
*.7z.002
|
||||||
*.89k
|
*.89k
|
||||||
*.89z
|
*.89z
|
||||||
*.8ck
|
*.8ck
|
||||||
|
*.a00
|
||||||
|
*.a01
|
||||||
|
*.a02
|
||||||
*.a7r
|
*.a7r
|
||||||
*.ac
|
*.ac
|
||||||
*.acc
|
*.acc
|
||||||
@@ -22,8 +37,11 @@
|
|||||||
*.ahk
|
*.ahk
|
||||||
*.ai
|
*.ai
|
||||||
*.aif
|
*.aif
|
||||||
|
*.ain
|
||||||
*.air
|
*.air
|
||||||
*.alz
|
*.alz
|
||||||
|
*.ana
|
||||||
|
*.apex
|
||||||
*.api
|
*.api
|
||||||
*.apk
|
*.apk
|
||||||
*.app
|
*.app
|
||||||
@@ -31,15 +49,28 @@
|
|||||||
*.applescript
|
*.applescript
|
||||||
*.application
|
*.application
|
||||||
*.appx
|
*.appx
|
||||||
|
*.apz
|
||||||
|
*.ar
|
||||||
*.arc
|
*.arc
|
||||||
|
*.archiver
|
||||||
|
*.arduboy
|
||||||
|
*.arh
|
||||||
|
*.ari
|
||||||
*.arj
|
*.arj
|
||||||
|
*.ark
|
||||||
*.arscript
|
*.arscript
|
||||||
*.asb
|
*.asb
|
||||||
|
*.asice
|
||||||
*.asp
|
*.asp
|
||||||
*.aspx
|
*.aspx
|
||||||
*.aspx-exe
|
*.aspx-exe
|
||||||
*.atmx
|
*.atmx
|
||||||
|
*.ayt
|
||||||
*.azw2
|
*.azw2
|
||||||
|
*.b1
|
||||||
|
*.b6z
|
||||||
|
*.b64
|
||||||
|
*.ba
|
||||||
*.ba_
|
*.ba_
|
||||||
*.bak
|
*.bak
|
||||||
*.bas
|
*.bas
|
||||||
@@ -47,26 +78,48 @@
|
|||||||
*.bat
|
*.bat
|
||||||
*.bdjo
|
*.bdjo
|
||||||
*.bdmv
|
*.bdmv
|
||||||
|
*.bdoc
|
||||||
*.beam
|
*.beam
|
||||||
|
*.bh
|
||||||
*.bin
|
*.bin
|
||||||
*.bmp
|
*.bmp
|
||||||
*.bms
|
*.bms
|
||||||
|
*.bndl
|
||||||
*.bns
|
*.bns
|
||||||
|
*.boo
|
||||||
*.bsa
|
*.bsa
|
||||||
*.btm
|
*.btm
|
||||||
|
*.bundle
|
||||||
|
*.bz
|
||||||
*.bz2
|
*.bz2
|
||||||
|
*.bza
|
||||||
|
*.bzabw
|
||||||
|
*.bzip
|
||||||
|
*.bzip2
|
||||||
*.c
|
*.c
|
||||||
|
*.c00
|
||||||
|
*.c01
|
||||||
|
*.c02
|
||||||
|
*.c10
|
||||||
*.cab
|
*.cab
|
||||||
*.caction
|
*.caction
|
||||||
|
*.car
|
||||||
|
*.cb7
|
||||||
|
*.cba
|
||||||
|
*.cbr
|
||||||
|
*.cbt
|
||||||
|
*.cbz
|
||||||
*.cci
|
*.cci
|
||||||
*.cda
|
*.cda
|
||||||
*.cdb
|
*.cdb
|
||||||
|
*.cdz
|
||||||
*.cel
|
*.cel
|
||||||
*.celx
|
*.celx
|
||||||
*.cfs
|
*.cfs
|
||||||
*.cgi
|
*.cgi
|
||||||
*.cheat
|
*.cheat
|
||||||
*.chm
|
*.chm
|
||||||
|
*.cit
|
||||||
*.ckpt
|
*.ckpt
|
||||||
*.cla
|
*.cla
|
||||||
*.class
|
*.class
|
||||||
@@ -76,9 +129,15 @@
|
|||||||
*.coffee
|
*.coffee
|
||||||
*.com
|
*.com
|
||||||
*.command
|
*.command
|
||||||
|
*.comppkg.hauptwerk.rar
|
||||||
|
*.comppkg_hauptwerk_rar
|
||||||
|
*.conda
|
||||||
*.conf
|
*.conf
|
||||||
*.config
|
*.config
|
||||||
|
*.cp9
|
||||||
|
*.cpgz
|
||||||
*.cpl
|
*.cpl
|
||||||
|
*.cpt
|
||||||
*.crt
|
*.crt
|
||||||
*.cs
|
*.cs
|
||||||
*.csh
|
*.csh
|
||||||
@@ -86,17 +145,27 @@
|
|||||||
*.csproj
|
*.csproj
|
||||||
*.css
|
*.css
|
||||||
*.csv
|
*.csv
|
||||||
|
*.ctx
|
||||||
|
*.ctz
|
||||||
*.cue
|
*.cue
|
||||||
*.cur
|
*.cur
|
||||||
|
*.cxarchive
|
||||||
*.cyw
|
*.cyw
|
||||||
|
*.czip
|
||||||
*.daemon
|
*.daemon
|
||||||
|
*.daf
|
||||||
|
*.dar
|
||||||
*.dat
|
*.dat
|
||||||
*.data-00000-of-00001
|
*.data-00000-of-00001
|
||||||
*.db
|
*.db
|
||||||
|
*.dd
|
||||||
*.deamon
|
*.deamon
|
||||||
*.deb
|
*.deb
|
||||||
*.dek
|
*.dek
|
||||||
|
*.dgc
|
||||||
|
*.dist
|
||||||
*.diz
|
*.diz
|
||||||
|
*.dl_
|
||||||
*.dld
|
*.dld
|
||||||
*.dll
|
*.dll
|
||||||
*.dmc
|
*.dmc
|
||||||
@@ -113,19 +182,27 @@
|
|||||||
*.dw
|
*.dw
|
||||||
*.dword
|
*.dword
|
||||||
*.dxl
|
*.dxl
|
||||||
|
*.dz
|
||||||
*.e_e
|
*.e_e
|
||||||
*.ear
|
*.ear
|
||||||
*.ebacmd
|
*.ebacmd
|
||||||
*.ebm
|
*.ebm
|
||||||
*.ebs
|
*.ebs
|
||||||
*.ebs2
|
*.ebs2
|
||||||
|
*.ecar
|
||||||
*.ecf
|
*.ecf
|
||||||
|
*.ecs
|
||||||
|
*.ecsbx
|
||||||
|
*.edz
|
||||||
|
*.efw
|
||||||
|
*.egg
|
||||||
*.eham
|
*.eham
|
||||||
*.elf
|
*.elf
|
||||||
*.elf-so
|
*.elf-so
|
||||||
*.email
|
*.email
|
||||||
*.emu
|
*.emu
|
||||||
*.epk
|
*.epk
|
||||||
|
*.epi
|
||||||
*.es
|
*.es
|
||||||
*.esh
|
*.esh
|
||||||
*.etc
|
*.etc
|
||||||
@@ -141,36 +218,62 @@
|
|||||||
*.exz
|
*.exz
|
||||||
*.ezs
|
*.ezs
|
||||||
*.ezt
|
*.ezt
|
||||||
|
*.f
|
||||||
|
*.f3z
|
||||||
*.fas
|
*.fas
|
||||||
*.fba
|
*.fba
|
||||||
|
*.fcx
|
||||||
*.fky
|
*.fky
|
||||||
*.flac
|
*.flac
|
||||||
*.flatpak
|
*.flatpak
|
||||||
*.flv
|
*.flv
|
||||||
|
*.fp8
|
||||||
*.fpi
|
*.fpi
|
||||||
*.frs
|
*.frs
|
||||||
*.fxp
|
*.fxp
|
||||||
|
*.fzpz
|
||||||
*.gadget
|
*.gadget
|
||||||
|
*.gar
|
||||||
*.gat
|
*.gat
|
||||||
|
*.gca
|
||||||
*.gif
|
*.gif
|
||||||
*.gifv
|
*.gifv
|
||||||
*.gm9
|
*.gm9
|
||||||
|
*.gmz
|
||||||
*.gpe
|
*.gpe
|
||||||
*.gpu
|
*.gpu
|
||||||
*.gs
|
*.gs
|
||||||
*.gz
|
*.gz
|
||||||
|
*.gz2
|
||||||
|
*.gza
|
||||||
|
*.gzi
|
||||||
|
*.gzip
|
||||||
*.h5
|
*.h5
|
||||||
|
*.ha
|
||||||
*.ham
|
*.ham
|
||||||
|
*.hbc
|
||||||
|
*.hbc2
|
||||||
|
*.hbe
|
||||||
*.hex
|
*.hex
|
||||||
|
*.hki
|
||||||
|
*.hki1
|
||||||
|
*.hki2
|
||||||
|
*.hki3
|
||||||
*.hlp
|
*.hlp
|
||||||
*.hms
|
*.hms
|
||||||
*.hpf
|
*.hpf
|
||||||
|
*.hpk
|
||||||
|
*.hpkg
|
||||||
*.hta
|
*.hta
|
||||||
*.hta-psh
|
*.hta-psh
|
||||||
*.htaccess
|
*.htaccess
|
||||||
*.htm
|
*.htm
|
||||||
*.html
|
*.html
|
||||||
|
*.htmi
|
||||||
|
*.hyp
|
||||||
|
*.iadproj
|
||||||
*.icd
|
*.icd
|
||||||
|
*.ice
|
||||||
*.icns
|
*.icns
|
||||||
*.ico
|
*.ico
|
||||||
*.idx
|
*.idx
|
||||||
@@ -183,17 +286,27 @@
|
|||||||
*.ins
|
*.ins
|
||||||
*.ipa
|
*.ipa
|
||||||
*.ipf
|
*.ipf
|
||||||
|
*.ipg
|
||||||
*.ipk
|
*.ipk
|
||||||
*.ipsw
|
*.ipsw
|
||||||
*.iqylink
|
*.iqylink
|
||||||
|
*.ish
|
||||||
*.iso
|
*.iso
|
||||||
*.isp
|
*.isp
|
||||||
*.isu
|
*.isu
|
||||||
|
*.isx
|
||||||
*.ita
|
*.ita
|
||||||
|
*.ize
|
||||||
*.izh
|
*.izh
|
||||||
*.izma ace
|
*.izma ace
|
||||||
|
*.j
|
||||||
*.jar
|
*.jar
|
||||||
|
*.jar.pack
|
||||||
*.java
|
*.java
|
||||||
|
*.jex
|
||||||
|
*.jgz
|
||||||
|
*.jhh
|
||||||
|
*.jic
|
||||||
*.jpeg
|
*.jpeg
|
||||||
*.jpg
|
*.jpg
|
||||||
*.js
|
*.js
|
||||||
@@ -202,27 +315,51 @@
|
|||||||
*.jse
|
*.jse
|
||||||
*.jsf
|
*.jsf
|
||||||
*.json
|
*.json
|
||||||
|
*.jsonlz4
|
||||||
*.jsp
|
*.jsp
|
||||||
*.jsx
|
*.jsx
|
||||||
|
*.kextraction
|
||||||
|
*.kgb
|
||||||
*.kix
|
*.kix
|
||||||
*.ksh
|
*.ksh
|
||||||
|
*.ksp
|
||||||
|
*.kwgt
|
||||||
*.kx
|
*.kx
|
||||||
|
*.kz
|
||||||
|
*.layout
|
||||||
|
*.lbr
|
||||||
*.lck
|
*.lck
|
||||||
*.ldb
|
*.ldb
|
||||||
|
*.lemon
|
||||||
|
*.lha
|
||||||
|
*.lhzd
|
||||||
*.lib
|
*.lib
|
||||||
|
*.libzip
|
||||||
*.link
|
*.link
|
||||||
*.lnk
|
*.lnk
|
||||||
*.lo
|
*.lo
|
||||||
*.lock
|
*.lock
|
||||||
*.log
|
*.log
|
||||||
*.loop-vbs
|
*.loop-vbs
|
||||||
|
*.lpkg
|
||||||
|
*.lqr
|
||||||
*.ls
|
*.ls
|
||||||
|
*.lz
|
||||||
|
*.lz4
|
||||||
|
*.lzh
|
||||||
|
*.lzm
|
||||||
|
*.lzma
|
||||||
|
*.lzo
|
||||||
|
*.lzr
|
||||||
|
*.lzx
|
||||||
*.m3u
|
*.m3u
|
||||||
*.m4a
|
*.m4a
|
||||||
*.mac
|
*.mac
|
||||||
*.macho
|
*.macho
|
||||||
*.mamc
|
*.mamc
|
||||||
*.manifest
|
*.manifest
|
||||||
|
*.mar
|
||||||
|
*.mbz
|
||||||
*.mcr
|
*.mcr
|
||||||
*.md
|
*.md
|
||||||
*.mda
|
*.mda
|
||||||
@@ -233,22 +370,29 @@
|
|||||||
*.mdt
|
*.mdt
|
||||||
*.mel
|
*.mel
|
||||||
*.mem
|
*.mem
|
||||||
|
*.memo
|
||||||
*.meta
|
*.meta
|
||||||
*.mgm
|
*.mgm
|
||||||
*.mhm
|
*.mhm
|
||||||
*.mht
|
*.mht
|
||||||
*.mhtml
|
*.mhtml
|
||||||
*.mid
|
*.mid
|
||||||
|
*.mint
|
||||||
*.mio
|
*.mio
|
||||||
*.mlappinstall
|
*.mlappinstall
|
||||||
|
*.mlproj
|
||||||
*.mlx
|
*.mlx
|
||||||
*.mm
|
*.mm
|
||||||
*.mobileconfig
|
*.mobileconfig
|
||||||
*.model
|
*.model
|
||||||
*.moo
|
*.moo
|
||||||
|
*.mou
|
||||||
|
*.movpkg
|
||||||
|
*.mozlz4
|
||||||
*.mp3
|
*.mp3
|
||||||
*.mpa
|
*.mpa
|
||||||
*.mpk
|
*.mpk
|
||||||
|
*.mpkg
|
||||||
*.mpls
|
*.mpls
|
||||||
*.mrc
|
*.mrc
|
||||||
*.mrp
|
*.mrp
|
||||||
@@ -267,41 +411,79 @@
|
|||||||
*.msp
|
*.msp
|
||||||
*.mst
|
*.mst
|
||||||
*.msu
|
*.msu
|
||||||
|
*.mxc
|
||||||
*.mxe
|
*.mxe
|
||||||
|
*.mzp
|
||||||
*.n
|
*.n
|
||||||
|
*.nar
|
||||||
*.ncl
|
*.ncl
|
||||||
*.net
|
*.net
|
||||||
|
*.nex
|
||||||
*.nexe
|
*.nexe
|
||||||
*.nfo
|
*.nfo
|
||||||
|
*.npk
|
||||||
*.nrg
|
*.nrg
|
||||||
*.num
|
*.num
|
||||||
|
*.nz
|
||||||
*.nzb.bz2
|
*.nzb.bz2
|
||||||
*.nzb.gz
|
*.nzb.gz
|
||||||
*.nzbs
|
*.nzbs
|
||||||
|
*.oar
|
||||||
*.ocx
|
*.ocx
|
||||||
|
*.odlgz
|
||||||
*.odt
|
*.odt
|
||||||
|
*.opk
|
||||||
*.ore
|
*.ore
|
||||||
|
*.osf
|
||||||
*.ost
|
*.ost
|
||||||
*.osx
|
*.osx
|
||||||
*.osx-app
|
*.osx-app
|
||||||
*.otm
|
*.otm
|
||||||
*.out
|
*.out
|
||||||
*.ova
|
*.ova
|
||||||
|
*.oz
|
||||||
*.p
|
*.p
|
||||||
|
*.p01
|
||||||
|
*.p19
|
||||||
|
*.p7z
|
||||||
|
*.pa
|
||||||
|
*.pack.gz
|
||||||
|
*.package
|
||||||
|
*.pae
|
||||||
*.paf
|
*.paf
|
||||||
*.pak
|
*.pak
|
||||||
|
*.paq6
|
||||||
|
*.paq7
|
||||||
|
*.paq8
|
||||||
|
*.paq8f
|
||||||
|
*.paq8l
|
||||||
|
*.paq8p
|
||||||
|
*.par
|
||||||
|
*.par2
|
||||||
|
*.pax
|
||||||
*.pb
|
*.pb
|
||||||
|
*.pbi
|
||||||
*.pcd
|
*.pcd
|
||||||
|
*.pcv
|
||||||
*.pdb
|
*.pdb
|
||||||
*.pdf
|
*.pdf
|
||||||
*.pea
|
*.pea
|
||||||
*.perl
|
*.perl
|
||||||
|
*.pet
|
||||||
*.pex
|
*.pex
|
||||||
|
*.pf
|
||||||
*.phar
|
*.phar
|
||||||
*.php
|
*.php
|
||||||
*.php5
|
*.php5
|
||||||
*.pif
|
*.pif
|
||||||
|
*.pim
|
||||||
|
*.pima
|
||||||
|
*.pit
|
||||||
|
*.piz
|
||||||
*.pkg
|
*.pkg
|
||||||
|
*.pkg.tar.xz
|
||||||
|
*.pkg.tar.zst
|
||||||
|
*.pkz
|
||||||
*.pl
|
*.pl
|
||||||
*.plsc
|
*.plsc
|
||||||
*.plx
|
*.plx
|
||||||
@@ -319,6 +501,7 @@
|
|||||||
*.pptx
|
*.pptx
|
||||||
*.prc
|
*.prc
|
||||||
*.prg
|
*.prg
|
||||||
|
*.prs
|
||||||
*.ps
|
*.ps
|
||||||
*.ps1
|
*.ps1
|
||||||
*.ps1xml
|
*.ps1xml
|
||||||
@@ -334,9 +517,16 @@
|
|||||||
*.psh-reflection
|
*.psh-reflection
|
||||||
*.psm1
|
*.psm1
|
||||||
*.pst
|
*.pst
|
||||||
|
*.psz
|
||||||
*.pt
|
*.pt
|
||||||
|
*.pup
|
||||||
|
*.puz
|
||||||
*.pvd
|
*.pvd
|
||||||
|
*.pvmp
|
||||||
|
*.pvmz
|
||||||
|
*.pwa
|
||||||
*.pwc
|
*.pwc
|
||||||
|
*.pxl
|
||||||
*.pxo
|
*.pxo
|
||||||
*.py
|
*.py
|
||||||
*.pyc
|
*.pyc
|
||||||
@@ -344,8 +534,20 @@
|
|||||||
*.pyo
|
*.pyo
|
||||||
*.python
|
*.python
|
||||||
*.pyz
|
*.pyz
|
||||||
|
*.q
|
||||||
|
*.qda
|
||||||
*.qit
|
*.qit
|
||||||
*.qpx
|
*.qpx
|
||||||
|
*.r0
|
||||||
|
*.r00
|
||||||
|
*.r01
|
||||||
|
*.r02
|
||||||
|
*.r03
|
||||||
|
*.r04
|
||||||
|
*.r1
|
||||||
|
*.r2
|
||||||
|
*.r21
|
||||||
|
*.r30
|
||||||
*.ram
|
*.ram
|
||||||
*.rar
|
*.rar
|
||||||
*.raw
|
*.raw
|
||||||
@@ -356,22 +558,35 @@
|
|||||||
*.reg
|
*.reg
|
||||||
*.resources
|
*.resources
|
||||||
*.resx
|
*.resx
|
||||||
|
*.rev
|
||||||
*.rfs
|
*.rfs
|
||||||
*.rfu
|
*.rfu
|
||||||
*.rgs
|
*.rgs
|
||||||
|
*.rk
|
||||||
*.rm
|
*.rm
|
||||||
|
*.rnc
|
||||||
*.rox
|
*.rox
|
||||||
|
*.rp9
|
||||||
*.rpg
|
*.rpg
|
||||||
*.rpj
|
*.rpj
|
||||||
*.rpm
|
*.rpm
|
||||||
|
*.rss
|
||||||
*.ruby
|
*.ruby
|
||||||
*.run
|
*.run
|
||||||
*.rxe
|
*.rxe
|
||||||
|
*.rz
|
||||||
|
*.s00
|
||||||
|
*.s01
|
||||||
|
*.s02
|
||||||
|
*.s09
|
||||||
*.s2a
|
*.s2a
|
||||||
|
*.s7z
|
||||||
*.sample
|
*.sample
|
||||||
*.sapk
|
*.sapk
|
||||||
|
*.sar
|
||||||
*.savedmodel
|
*.savedmodel
|
||||||
*.sbs
|
*.sbs
|
||||||
|
*.sbx
|
||||||
*.sca
|
*.sca
|
||||||
*.scar
|
*.scar
|
||||||
*.scb
|
*.scb
|
||||||
@@ -381,42 +596,86 @@
|
|||||||
*.scr
|
*.scr
|
||||||
*.script
|
*.script
|
||||||
*.sct
|
*.sct
|
||||||
|
*.sdc
|
||||||
|
*.sdn
|
||||||
|
*.sdoc
|
||||||
|
*.sdocx
|
||||||
|
*.sea
|
||||||
*.seed
|
*.seed
|
||||||
|
*.sen
|
||||||
*.server
|
*.server
|
||||||
*.service
|
*.service
|
||||||
|
*.sfg
|
||||||
|
*.sfm
|
||||||
|
*.sfs
|
||||||
*.sfv
|
*.sfv
|
||||||
|
*.sfx
|
||||||
*.sh
|
*.sh
|
||||||
|
*.shar
|
||||||
*.shb
|
*.shb
|
||||||
*.shell
|
*.shell
|
||||||
|
*.shk
|
||||||
*.shortcut
|
*.shortcut
|
||||||
|
*.shr
|
||||||
*.shs
|
*.shs
|
||||||
*.shtml
|
*.shtml
|
||||||
|
*.sifz
|
||||||
|
*.sipa
|
||||||
*.sit
|
*.sit
|
||||||
*.sitx
|
*.sitx
|
||||||
*.sk
|
*.sk
|
||||||
*.sldm
|
*.sldm
|
||||||
*.sln
|
*.sln
|
||||||
*.smm
|
*.smm
|
||||||
|
*.smpf
|
||||||
*.snap
|
*.snap
|
||||||
|
*.snagitstamps
|
||||||
|
*.snappy
|
||||||
|
*.snb
|
||||||
*.snd
|
*.snd
|
||||||
|
*.snz
|
||||||
|
*.spa
|
||||||
|
*.spd
|
||||||
|
*.spl
|
||||||
|
*.spm
|
||||||
*.spr
|
*.spr
|
||||||
|
*.spt
|
||||||
*.sql
|
*.sql
|
||||||
|
*.sqf
|
||||||
*.sqx
|
*.sqx
|
||||||
|
*.sqz
|
||||||
*.srec
|
*.srec
|
||||||
|
*.srep
|
||||||
*.srt
|
*.srt
|
||||||
*.ssm
|
*.ssm
|
||||||
|
*.stg
|
||||||
|
*.stkdoodlz
|
||||||
|
*.stproj
|
||||||
*.sts
|
*.sts
|
||||||
*.sub
|
*.sub
|
||||||
*.svg
|
*.svg
|
||||||
*.swf
|
*.swf
|
||||||
|
*.sy_
|
||||||
*.sys
|
*.sys
|
||||||
*.tar
|
*.tar
|
||||||
|
*.tar.bz2
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
*.tar.gz2
|
||||||
|
*.tar.lz
|
||||||
|
*.tar.lzma
|
||||||
|
*.tar.xz
|
||||||
|
*.tar.z
|
||||||
|
*.tar.zip
|
||||||
|
*.taz
|
||||||
*.tbl
|
*.tbl
|
||||||
*.tbz
|
*.tbz
|
||||||
|
*.tbz2
|
||||||
*.tcp
|
*.tcp
|
||||||
|
*.tcx
|
||||||
*.text
|
*.text
|
||||||
*.tf
|
*.tf
|
||||||
|
*.tg
|
||||||
|
*.tgs
|
||||||
*.tgz
|
*.tgz
|
||||||
*.thm
|
*.thm
|
||||||
*.thmx
|
*.thmx
|
||||||
@@ -425,19 +684,35 @@
|
|||||||
*.tif
|
*.tif
|
||||||
*.tiff
|
*.tiff
|
||||||
*.tipa
|
*.tipa
|
||||||
|
*.tlz
|
||||||
|
*.tlzma
|
||||||
*.tmp
|
*.tmp
|
||||||
*.tms
|
*.tms
|
||||||
*.toast
|
*.toast
|
||||||
*.torrent
|
*.torrent
|
||||||
*.tpk
|
*.tpk
|
||||||
|
*.tpsr
|
||||||
|
*.trs
|
||||||
*.txt
|
*.txt
|
||||||
|
*.tx_
|
||||||
|
*.txz
|
||||||
|
*.tz
|
||||||
|
*.tzst
|
||||||
*.u3p
|
*.u3p
|
||||||
|
*.ubz
|
||||||
|
*.uc2
|
||||||
*.udf
|
*.udf
|
||||||
|
*.ufdr
|
||||||
|
*.ufs.uzip
|
||||||
|
*.uha
|
||||||
*.upk
|
*.upk
|
||||||
*.upx
|
*.upx
|
||||||
*.url
|
*.url
|
||||||
|
*.uue
|
||||||
*.uvm
|
*.uvm
|
||||||
*.uw8
|
*.uw8
|
||||||
|
*.uzed
|
||||||
|
*.uzip
|
||||||
*.vb
|
*.vb
|
||||||
*.vba
|
*.vba
|
||||||
*.vba-exe
|
*.vba-exe
|
||||||
@@ -449,26 +724,46 @@
|
|||||||
*.vbscript
|
*.vbscript
|
||||||
*.vcd
|
*.vcd
|
||||||
*.vdo
|
*.vdo
|
||||||
|
*.vem
|
||||||
*.vexe
|
*.vexe
|
||||||
|
*.vfs
|
||||||
*.vhd
|
*.vhd
|
||||||
*.vhdx
|
*.vhdx
|
||||||
|
*.vib
|
||||||
|
*.vip
|
||||||
*.vlx
|
*.vlx
|
||||||
*.vm
|
*.vm
|
||||||
|
*.vmcz
|
||||||
*.vmdk
|
*.vmdk
|
||||||
|
*.vms
|
||||||
*.vob
|
*.vob
|
||||||
*.vocab
|
*.vocab
|
||||||
|
*.voca
|
||||||
|
*.vpk
|
||||||
*.vpm
|
*.vpm
|
||||||
|
*.vrpackage
|
||||||
|
*.vsi
|
||||||
|
*.vwi
|
||||||
*.vxp
|
*.vxp
|
||||||
|
*.wa
|
||||||
|
*.wacz
|
||||||
|
*.waff
|
||||||
*.war
|
*.war
|
||||||
|
*.wastickers
|
||||||
*.wav
|
*.wav
|
||||||
*.wbk
|
*.wbk
|
||||||
*.wcm
|
*.wcm
|
||||||
|
*.wdz
|
||||||
*.webm
|
*.webm
|
||||||
|
*.whl
|
||||||
|
*.wick
|
||||||
*.widget
|
*.widget
|
||||||
*.wim
|
*.wim
|
||||||
*.wiz
|
*.wiz
|
||||||
|
*.wlb
|
||||||
*.wma
|
*.wma
|
||||||
*.workflow
|
*.workflow
|
||||||
|
*.wot
|
||||||
*.wpk
|
*.wpk
|
||||||
*.wpl
|
*.wpl
|
||||||
*.wpm
|
*.wpm
|
||||||
@@ -477,14 +772,26 @@
|
|||||||
*.wsc
|
*.wsc
|
||||||
*.wsf
|
*.wsf
|
||||||
*.wsh
|
*.wsh
|
||||||
|
*.wux
|
||||||
*.x86
|
*.x86
|
||||||
*.x86_64
|
*.x86_64
|
||||||
*.xaml
|
*.xaml
|
||||||
*.xap
|
*.xap
|
||||||
|
*.xapk
|
||||||
|
*.xar
|
||||||
*.xbap
|
*.xbap
|
||||||
*.xbe
|
*.xbe
|
||||||
|
*.xcf.bz2
|
||||||
|
*.xcf.gz
|
||||||
|
*.xcf.xz
|
||||||
|
*.xcfbz2
|
||||||
|
*.xcfgz
|
||||||
|
*.xcfxz
|
||||||
*.xex
|
*.xex
|
||||||
|
*.xez
|
||||||
|
*.xfp
|
||||||
*.xig
|
*.xig
|
||||||
|
*.xip
|
||||||
*.xla
|
*.xla
|
||||||
*.xlam
|
*.xlam
|
||||||
*.xll
|
*.xll
|
||||||
@@ -497,24 +804,47 @@
|
|||||||
*.xltb
|
*.xltb
|
||||||
*.xltm
|
*.xltm
|
||||||
*.xlw
|
*.xlw
|
||||||
|
*.xmcdz
|
||||||
*.xml
|
*.xml
|
||||||
|
*.xoj
|
||||||
|
*.xopp
|
||||||
*.xqt
|
*.xqt
|
||||||
*.xrt
|
*.xrt
|
||||||
|
*.xx
|
||||||
*.xys
|
*.xys
|
||||||
*.xz
|
*.xz
|
||||||
|
*.xzm
|
||||||
|
*.y
|
||||||
|
*.yc
|
||||||
*.ygh
|
*.ygh
|
||||||
|
*.yz1
|
||||||
*.z
|
*.z
|
||||||
|
*.z00
|
||||||
|
*.z01
|
||||||
|
*.z02
|
||||||
|
*.z03
|
||||||
|
*.z04
|
||||||
|
*.zabw
|
||||||
|
*.zap
|
||||||
|
*.zed
|
||||||
|
*.zfsendtotarget
|
||||||
|
*.zhelp
|
||||||
|
*.zi
|
||||||
|
*.zi_
|
||||||
|
*.zim
|
||||||
*.zip
|
*.zip
|
||||||
*.zipx
|
*.zipx
|
||||||
|
*.zix
|
||||||
|
*.zl
|
||||||
*.zl9
|
*.zl9
|
||||||
*.zoo
|
*.zoo
|
||||||
*sample.avchd
|
*.zpaq
|
||||||
*sample.avi
|
*.zpi
|
||||||
*sample.mkv
|
*.zsplit
|
||||||
*sample.mov
|
*.zst
|
||||||
*sample.mp4
|
*.zw
|
||||||
*sample.webm
|
*.zwi
|
||||||
*sample.wmv
|
*.zz
|
||||||
Trailer.*
|
Trailer.*
|
||||||
VOSTFR
|
VOSTFR
|
||||||
api
|
api
|
||||||
@@ -1,53 +1,410 @@
|
|||||||
|
*.000
|
||||||
*.001
|
*.001
|
||||||
|
*.002
|
||||||
|
*.004
|
||||||
|
*.7z
|
||||||
|
*.7z.001
|
||||||
|
*.7z.002
|
||||||
|
*.a00
|
||||||
|
*.a01
|
||||||
|
*.a02
|
||||||
|
*.ace
|
||||||
|
*.ain
|
||||||
|
*.alz
|
||||||
|
*.ana
|
||||||
|
*.apex
|
||||||
*.apk
|
*.apk
|
||||||
|
*.apz
|
||||||
|
*.ar
|
||||||
|
*.arc
|
||||||
|
*.archiver
|
||||||
|
*.arduboy
|
||||||
|
*.arh
|
||||||
|
*.ari
|
||||||
*.arj
|
*.arj
|
||||||
|
*.ark
|
||||||
|
*.asice
|
||||||
|
*.ayt
|
||||||
|
*.b1
|
||||||
|
*.b6z
|
||||||
|
*.b64
|
||||||
|
*.ba
|
||||||
*.bat
|
*.bat
|
||||||
|
*.bdoc
|
||||||
|
*.bh
|
||||||
*.bin
|
*.bin
|
||||||
*.bmp
|
*.bmp
|
||||||
|
*.bndl
|
||||||
|
*.boo
|
||||||
|
*.bundle
|
||||||
|
*.bz
|
||||||
|
*.bz2
|
||||||
|
*.bza
|
||||||
|
*.bzabw
|
||||||
|
*.bzip
|
||||||
|
*.bzip2
|
||||||
|
*.c00
|
||||||
|
*.c01
|
||||||
|
*.c02
|
||||||
|
*.c10
|
||||||
|
*.car
|
||||||
|
*.cb7
|
||||||
|
*.cba
|
||||||
|
*.cbr
|
||||||
|
*.cbt
|
||||||
|
*.cbz
|
||||||
|
*.cdz
|
||||||
|
*.cit
|
||||||
*.cmd
|
*.cmd
|
||||||
*.com
|
*.com
|
||||||
|
*.comppkg.hauptwerk.rar
|
||||||
|
*.comppkg_hauptwerk_rar
|
||||||
|
*.conda
|
||||||
|
*.cp9
|
||||||
|
*.cpgz
|
||||||
|
*.cpt
|
||||||
|
*.ctx
|
||||||
|
*.ctz
|
||||||
|
*.cxarchive
|
||||||
|
*.czip
|
||||||
|
*.daf
|
||||||
|
*.dar
|
||||||
*.db
|
*.db
|
||||||
|
*.dd
|
||||||
|
*.deb
|
||||||
|
*.dgc
|
||||||
|
*.dist
|
||||||
*.diz
|
*.diz
|
||||||
|
*.dl_
|
||||||
*.dll
|
*.dll
|
||||||
*.dmg
|
*.dmg
|
||||||
|
*.dz
|
||||||
|
*.ecar
|
||||||
|
*.ecs
|
||||||
|
*.ecsbx
|
||||||
|
*.edz
|
||||||
|
*.efw
|
||||||
|
*.egg
|
||||||
|
*.epi
|
||||||
*.etc
|
*.etc
|
||||||
*.exe
|
*.exe
|
||||||
|
*.f
|
||||||
|
*.f3z
|
||||||
|
*.fcx
|
||||||
|
*.fp8
|
||||||
|
*.fzpz
|
||||||
|
*.gar
|
||||||
|
*.gca
|
||||||
*.gif
|
*.gif
|
||||||
|
*.gmz
|
||||||
|
*.gz
|
||||||
|
*.gz2
|
||||||
|
*.gza
|
||||||
|
*.gzi
|
||||||
|
*.gzip
|
||||||
|
*.ha
|
||||||
|
*.hbc
|
||||||
|
*.hbc2
|
||||||
|
*.hbe
|
||||||
|
*.hki
|
||||||
|
*.hki1
|
||||||
|
*.hki2
|
||||||
|
*.hki3
|
||||||
|
*.hpk
|
||||||
|
*.hpkg
|
||||||
*.htm
|
*.htm
|
||||||
*.html
|
*.html
|
||||||
|
*.htmi
|
||||||
|
*.hyp
|
||||||
|
*.iadproj
|
||||||
|
*.ice
|
||||||
*.ico
|
*.ico
|
||||||
*.ini
|
*.ini
|
||||||
|
*.ipg
|
||||||
|
*.ipk
|
||||||
|
*.ish
|
||||||
*.iso
|
*.iso
|
||||||
|
*.isx
|
||||||
|
*.ita
|
||||||
|
*.ize
|
||||||
|
*.j
|
||||||
*.jar
|
*.jar
|
||||||
|
*.jar.pack
|
||||||
|
*.jex
|
||||||
|
*.jgz
|
||||||
|
*.jhh
|
||||||
|
*.jic
|
||||||
*.jpg
|
*.jpg
|
||||||
*.js
|
*.js
|
||||||
|
*.jsonlz4
|
||||||
|
*.kextraction
|
||||||
|
*.kgb
|
||||||
|
*.ksp
|
||||||
|
*.kwgt
|
||||||
|
*.kz
|
||||||
|
*.layout
|
||||||
|
*.lbr
|
||||||
|
*.lemon
|
||||||
|
*.lha
|
||||||
|
*.lhzd
|
||||||
|
*.libzip
|
||||||
*.link
|
*.link
|
||||||
*.lnk
|
*.lnk
|
||||||
|
*.lpkg
|
||||||
|
*.lqr
|
||||||
|
*.lz
|
||||||
|
*.lz4
|
||||||
|
*.lzh
|
||||||
|
*.lzm
|
||||||
|
*.lzma
|
||||||
|
*.lzo
|
||||||
|
*.lzr
|
||||||
|
*.lzx
|
||||||
|
*.mar
|
||||||
|
*.mbz
|
||||||
|
*.md
|
||||||
|
*.memo
|
||||||
|
*.mint
|
||||||
|
*.mlproj
|
||||||
|
*.mou
|
||||||
|
*.movpkg
|
||||||
|
*.mozlz4
|
||||||
|
*.mpkg
|
||||||
*.msi
|
*.msi
|
||||||
|
*.mxc
|
||||||
|
*.mzp
|
||||||
|
*.nar
|
||||||
|
*.nex
|
||||||
*.nfo
|
*.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
|
*.perl
|
||||||
|
*.pet
|
||||||
|
*.pf
|
||||||
*.php
|
*.php
|
||||||
|
*.pim
|
||||||
|
*.pima
|
||||||
|
*.pit
|
||||||
|
*.piz
|
||||||
|
*.pkg
|
||||||
|
*.pkg.tar.xz
|
||||||
|
*.pkg.tar.zst
|
||||||
|
*.pkz
|
||||||
*.pl
|
*.pl
|
||||||
*.png
|
*.png
|
||||||
|
*.prs
|
||||||
*.ps1
|
*.ps1
|
||||||
*.psc1
|
*.psc1
|
||||||
*.psd1
|
*.psd1
|
||||||
*.psm1
|
*.psm1
|
||||||
|
*.psz
|
||||||
|
*.pup
|
||||||
|
*.puz
|
||||||
|
*.pvmp
|
||||||
|
*.pvmz
|
||||||
|
*.pwa
|
||||||
|
*.pxl
|
||||||
*.py
|
*.py
|
||||||
*.pyd
|
*.pyd
|
||||||
|
*.q
|
||||||
|
*.qda
|
||||||
|
*.r0
|
||||||
|
*.r00
|
||||||
|
*.r01
|
||||||
|
*.r02
|
||||||
|
*.r03
|
||||||
|
*.r04
|
||||||
|
*.r1
|
||||||
|
*.r2
|
||||||
|
*.r21
|
||||||
|
*.r30
|
||||||
|
*.rar
|
||||||
*.rb
|
*.rb
|
||||||
*.readme
|
*.readme
|
||||||
*.reg
|
*.reg
|
||||||
|
*.rev
|
||||||
|
*.rk
|
||||||
|
*.rnc
|
||||||
|
*.rp9
|
||||||
|
*.rpm
|
||||||
|
*.rss
|
||||||
*.run
|
*.run
|
||||||
|
*.rz
|
||||||
|
*.s00
|
||||||
|
*.s01
|
||||||
|
*.s02
|
||||||
|
*.s09
|
||||||
|
*.s7z
|
||||||
|
*.sar
|
||||||
|
*.sbx
|
||||||
*.scr
|
*.scr
|
||||||
|
*.sdc
|
||||||
|
*.sdn
|
||||||
|
*.sdoc
|
||||||
|
*.sdocx
|
||||||
|
*.sea
|
||||||
|
*.sen
|
||||||
|
*.sfg
|
||||||
|
*.sfm
|
||||||
|
*.sfs
|
||||||
|
*.sfx
|
||||||
*.sh
|
*.sh
|
||||||
|
*.shar
|
||||||
|
*.shk
|
||||||
|
*.shr
|
||||||
|
*.sifz
|
||||||
|
*.sipa
|
||||||
|
*.sit
|
||||||
|
*.sitx
|
||||||
|
*.smpf
|
||||||
|
*.snagitstamps
|
||||||
|
*.snappy
|
||||||
|
*.snb
|
||||||
|
*.snz
|
||||||
|
*.spa
|
||||||
|
*.spd
|
||||||
|
*.spl
|
||||||
|
*.spm
|
||||||
|
*.spt
|
||||||
*.sql
|
*.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
|
*.text
|
||||||
|
*.tg
|
||||||
|
*.tgs
|
||||||
|
*.tgz
|
||||||
*.thumb
|
*.thumb
|
||||||
|
*.tlz
|
||||||
|
*.tlzma
|
||||||
*.torrent
|
*.torrent
|
||||||
|
*.tpsr
|
||||||
|
*.trs
|
||||||
*.txt
|
*.txt
|
||||||
|
*.tx_
|
||||||
|
*.txz
|
||||||
|
*.tz
|
||||||
|
*.tzst
|
||||||
|
*.ubz
|
||||||
|
*.uc2
|
||||||
|
*.ufdr
|
||||||
|
*.ufs.uzip
|
||||||
|
*.uha
|
||||||
*.url
|
*.url
|
||||||
|
*.uue
|
||||||
|
*.uvm
|
||||||
|
*.uzed
|
||||||
|
*.uzip
|
||||||
*.vbs
|
*.vbs
|
||||||
|
*.vem
|
||||||
|
*.vfs
|
||||||
|
*.vib
|
||||||
|
*.vip
|
||||||
|
*.vmcz
|
||||||
|
*.vms
|
||||||
|
*.voca
|
||||||
|
*.vpk
|
||||||
|
*.vrpackage
|
||||||
|
*.vsi
|
||||||
|
*.vwi
|
||||||
|
*.wa
|
||||||
|
*.wacz
|
||||||
|
*.waff
|
||||||
|
*.war
|
||||||
|
*.wastickers
|
||||||
|
*.wdz
|
||||||
|
*.whl
|
||||||
|
*.wick
|
||||||
|
*.wlb
|
||||||
|
*.wot
|
||||||
*.wsf
|
*.wsf
|
||||||
|
*.wux
|
||||||
|
*.xapk
|
||||||
|
*.xar
|
||||||
|
*.xcf.bz2
|
||||||
|
*.xcf.gz
|
||||||
|
*.xcf.xz
|
||||||
|
*.xcfbz2
|
||||||
|
*.xcfgz
|
||||||
|
*.xcfxz
|
||||||
|
*.xez
|
||||||
|
*.xfp
|
||||||
|
*.xip
|
||||||
*.xml
|
*.xml
|
||||||
|
*.xmcdz
|
||||||
|
*.xoj
|
||||||
|
*.xopp
|
||||||
|
*.xx
|
||||||
|
*.xz
|
||||||
|
*.xzm
|
||||||
|
*.y
|
||||||
|
*.yc
|
||||||
|
*.yz1
|
||||||
|
*.z
|
||||||
|
*.z00
|
||||||
|
*.z01
|
||||||
|
*.z02
|
||||||
|
*.z03
|
||||||
|
*.z04
|
||||||
|
*.zabw
|
||||||
|
*.zap
|
||||||
|
*.zed
|
||||||
|
*.zfsendtotarget
|
||||||
|
*.zhelp
|
||||||
|
*.zi
|
||||||
|
*.zi_
|
||||||
|
*.zim
|
||||||
|
*.zip
|
||||||
*.zipx
|
*.zipx
|
||||||
|
*.zix
|
||||||
|
*.zl
|
||||||
|
*.zoo
|
||||||
|
*.zpaq
|
||||||
|
*.zpi
|
||||||
|
*.zsplit
|
||||||
|
*.zst
|
||||||
|
*.zw
|
||||||
|
*.zwi
|
||||||
|
*.zz
|
||||||
@@ -39,3 +39,49 @@ backend/**/Tests/
|
|||||||
# Development files
|
# Development files
|
||||||
docker-compose*.yml
|
docker-compose*.yml
|
||||||
test/
|
test/
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# Node and build output
|
||||||
|
# ================================
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
out-tsc
|
||||||
|
.angular
|
||||||
|
.cache
|
||||||
|
.tmp
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# Testing & Coverage
|
||||||
|
# ================================
|
||||||
|
coverage
|
||||||
|
jest
|
||||||
|
cypress
|
||||||
|
cypress/screenshots
|
||||||
|
cypress/videos
|
||||||
|
reports
|
||||||
|
playwright-report
|
||||||
|
.vite
|
||||||
|
.vitepress
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# Environment & log files
|
||||||
|
# ================================
|
||||||
|
*.env*
|
||||||
|
!*.env.production
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# Docker & local orchestration
|
||||||
|
# ================================
|
||||||
|
Dockerfile
|
||||||
|
Dockerfile.*
|
||||||
|
.dockerignore
|
||||||
|
docker-compose.yml
|
||||||
|
docker-compose*.yml
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# Miscellaneous
|
||||||
|
# ================================
|
||||||
|
*.bak
|
||||||
|
*.old
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
# Build Angular frontend
|
# Build Angular frontend
|
||||||
FROM --platform=$BUILDPLATFORM node:18-alpine AS frontend-build
|
FROM --platform=$BUILDPLATFORM node:25-alpine AS frontend-build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files first for better layer caching
|
# Copy package files first for better layer caching
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
RUN npm ci && npm install -g @angular/cli
|
# 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 source code
|
||||||
COPY frontend/ .
|
COPY frontend/ .
|
||||||
@@ -13,7 +15,7 @@ COPY frontend/ .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Build .NET backend
|
# Build .NET backend
|
||||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build
|
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG VERSION=0.0.1
|
ARG VERSION=0.0.1
|
||||||
ARG PACKAGES_USERNAME
|
ARG PACKAGES_USERNAME
|
||||||
@@ -21,34 +23,42 @@ ARG PACKAGES_PAT
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
EXPOSE 11011
|
EXPOSE 11011
|
||||||
|
|
||||||
# Copy solution and project files first for better layer caching
|
|
||||||
# COPY backend/*.sln ./backend/
|
|
||||||
# COPY backend/*/*.csproj ./backend/*/
|
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY backend/ ./backend/
|
COPY backend/ ./backend/
|
||||||
|
|
||||||
# Restore dependencies
|
# 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
|
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
|
||||||
|
|
||||||
# Build and publish
|
# Restore and publish with cache mount
|
||||||
RUN dotnet publish ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj \
|
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 \
|
-a $TARGETARCH \
|
||||||
-c Release \
|
-c Release \
|
||||||
-o /app/publish \
|
-o /app/publish \
|
||||||
|
--no-restore \
|
||||||
/p:Version=${VERSION} \
|
/p:Version=${VERSION} \
|
||||||
/p:PublishSingleFile=true \
|
/p:PublishSingleFile=true \
|
||||||
/p:DebugSymbols=false
|
/p:DebugSymbols=false
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||||
|
|
||||||
# Install required packages for user management and timezone support
|
# Install required packages for user management, timezone support, and Python for Apprise CLI
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
tzdata \
|
tzdata \
|
||||||
gosu \
|
gosu \
|
||||||
|
python3 \
|
||||||
|
python3-venv \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create virtual environment and install Apprise CLI
|
||||||
|
ENV VIRTUAL_ENV=/opt/apprise-venv
|
||||||
|
RUN python3 -m venv $VIRTUAL_ENV
|
||||||
|
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||||
|
RUN pip install --no-cache-dir apprise==1.9.6
|
||||||
|
|
||||||
ENV PUID=1000 \
|
ENV PUID=1000 \
|
||||||
PGID=1000 \
|
PGID=1000 \
|
||||||
UMASK=022 \
|
UMASK=022 \
|
||||||
|
|||||||
@@ -14,3 +14,29 @@ ifndef name
|
|||||||
$(error name is required. Usage: make migrate-events name=YourMigrationName)
|
$(error name is required. Usage: make migrate-events name=YourMigrationName)
|
||||||
endif
|
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
|
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
|
||||||
|
|
||||||
|
migrate-users:
|
||||||
|
ifndef name
|
||||||
|
$(error name is required. Usage: make migrate-users name=YourMigrationName)
|
||||||
|
endif
|
||||||
|
dotnet ef migrations add $(name) --context UsersContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Users
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
ifndef tag
|
||||||
|
$(error tag is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||||
|
endif
|
||||||
|
ifndef version
|
||||||
|
$(error version is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||||
|
endif
|
||||||
|
ifndef user
|
||||||
|
$(error user is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||||
|
endif
|
||||||
|
ifndef pat
|
||||||
|
$(error pat is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||||
|
endif
|
||||||
|
DOCKER_BUILDKIT=1 docker build \
|
||||||
|
--build-arg VERSION=$(version) \
|
||||||
|
--build-arg PACKAGES_USERNAME=$(user) \
|
||||||
|
--build-arg PACKAGES_PAT=$(pat) \
|
||||||
|
-t cleanuparr:$(tag) \
|
||||||
|
.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Cleanuparr.Api\Cleanuparr.Api.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Cleanuparr.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom WebApplicationFactory that uses an isolated SQLite database for each test fixture.
|
||||||
|
/// The database file is created in a temp directory so both DI and static contexts share the same data.
|
||||||
|
/// </summary>
|
||||||
|
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||||
|
{
|
||||||
|
private readonly string _tempDir;
|
||||||
|
|
||||||
|
public CustomWebApplicationFactory()
|
||||||
|
{
|
||||||
|
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-test-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(_tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
builder.UseEnvironment("Testing");
|
||||||
|
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
// Remove the existing UsersContext registration
|
||||||
|
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UsersContext>));
|
||||||
|
if (descriptor != null) services.Remove(descriptor);
|
||||||
|
|
||||||
|
// Also remove the DbContext registration itself
|
||||||
|
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(UsersContext));
|
||||||
|
if (contextDescriptor != null) services.Remove(contextDescriptor);
|
||||||
|
|
||||||
|
var dbPath = Path.Combine(_tempDir, "users.db");
|
||||||
|
|
||||||
|
services.AddDbContext<UsersContext>(options =>
|
||||||
|
{
|
||||||
|
options.UseSqlite($"Data Source={dbPath}");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure DB is created
|
||||||
|
var sp = services.BuildServiceProvider();
|
||||||
|
using var scope = sp.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
base.Dispose(disposing);
|
||||||
|
|
||||||
|
if (disposing && Directory.Exists(_tempDir))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_tempDir, true); } catch { /* best effort cleanup */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for the authentication flow.
|
||||||
|
/// Uses a single shared factory to avoid static state conflicts.
|
||||||
|
/// Tests are ordered to build on each other: setup → login → protected endpoints.
|
||||||
|
/// </summary>
|
||||||
|
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||||
|
public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public AuthControllerTests(CustomWebApplicationFactory factory)
|
||||||
|
{
|
||||||
|
_client = factory.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact, TestPriority(0)]
|
||||||
|
public async Task GetStatus_BeforeSetup_ReturnsNotCompleted()
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync("/api/auth/status");
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
body.GetProperty("setupCompleted").GetBoolean().ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact, TestPriority(1)]
|
||||||
|
public async Task Setup_CreateAccount_ReturnsCreated()
|
||||||
|
{
|
||||||
|
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||||
|
{
|
||||||
|
username = "admin",
|
||||||
|
password = "TestPassword123!"
|
||||||
|
});
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||||
|
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
body.GetProperty("userId").GetString().ShouldNotBeNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact, TestPriority(2)]
|
||||||
|
public async Task Setup_CreateDuplicateAccount_ReturnsConflict()
|
||||||
|
{
|
||||||
|
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||||
|
{
|
||||||
|
username = "another",
|
||||||
|
password = "TestPassword123!"
|
||||||
|
});
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact, TestPriority(3)]
|
||||||
|
public async Task Setup_Generate2FA_ReturnsSecretAndRecoveryCodes()
|
||||||
|
{
|
||||||
|
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
body.GetProperty("secret").GetString().ShouldNotBeNullOrEmpty();
|
||||||
|
body.GetProperty("qrCodeUri").GetString().ShouldNotBeNullOrEmpty();
|
||||||
|
body.GetProperty("recoveryCodes").GetArrayLength().ShouldBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Store the secret for the next test
|
||||||
|
_totpSecret = body.GetProperty("secret").GetString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact, TestPriority(4)]
|
||||||
|
public async Task Setup_Verify2FA_WithValidCode_Succeeds()
|
||||||
|
{
|
||||||
|
// If we don't have the secret from the previous test, generate it again
|
||||||
|
if (string.IsNullOrEmpty(_totpSecret))
|
||||||
|
{
|
||||||
|
var genResponse = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||||
|
var genBody = await genResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
_totpSecret = genBody.GetProperty("secret").GetString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
var code = GenerateTotpCode(_totpSecret);
|
||||||
|
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/verify", new { code });
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact, TestPriority(5)]
|
||||||
|
public async Task Setup_Complete_Succeeds()
|
||||||
|
{
|
||||||
|
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact, TestPriority(6)]
|
||||||
|
public async Task Login_ValidCredentials_RequiresTwoFactor()
|
||||||
|
{
|
||||||
|
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||||
|
{
|
||||||
|
username = "admin",
|
||||||
|
password = "TestPassword123!"
|
||||||
|
});
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeTrue();
|
||||||
|
body.GetProperty("loginToken").GetString().ShouldNotBeNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact, TestPriority(7)]
|
||||||
|
public async Task Login_InvalidCredentials_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||||
|
{
|
||||||
|
username = "admin",
|
||||||
|
password = "WrongPassword!"
|
||||||
|
});
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact, TestPriority(8)]
|
||||||
|
public async Task Login_BruteForce_ReturnsRetryAfter()
|
||||||
|
{
|
||||||
|
// Make multiple failed attempts
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||||
|
{
|
||||||
|
username = "admin",
|
||||||
|
password = "WrongPassword!"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||||
|
{
|
||||||
|
username = "admin",
|
||||||
|
password = "WrongPassword!"
|
||||||
|
});
|
||||||
|
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||||
|
{
|
||||||
|
body.GetProperty("retryAfterSeconds").GetInt32().ShouldBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||||
|
body.TryGetProperty("retryAfterSeconds", out var retry).ShouldBeTrue();
|
||||||
|
retry.GetInt32().ShouldBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact, TestPriority(9)]
|
||||||
|
public async Task ProtectedEndpoint_WithoutAuth_DeniesAccess()
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync("/api/account");
|
||||||
|
|
||||||
|
// 401 (FallbackPolicy) or 403 (SetupGuardMiddleware) - both deny unauthenticated access
|
||||||
|
new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }
|
||||||
|
.ShouldContain(response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact, TestPriority(10)]
|
||||||
|
public async Task HealthEndpoint_WithoutAuth_Returns200()
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync("/health");
|
||||||
|
|
||||||
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#region TOTP helpers
|
||||||
|
|
||||||
|
private static string _totpSecret = "";
|
||||||
|
|
||||||
|
private static string GenerateTotpCode(string base32Secret)
|
||||||
|
{
|
||||||
|
var key = Base32Decode(base32Secret);
|
||||||
|
var timestep = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds / 30;
|
||||||
|
var timestepBytes = BitConverter.GetBytes(timestep);
|
||||||
|
|
||||||
|
if (BitConverter.IsLittleEndian)
|
||||||
|
Array.Reverse(timestepBytes);
|
||||||
|
|
||||||
|
using var hmac = new System.Security.Cryptography.HMACSHA1(key);
|
||||||
|
var hash = hmac.ComputeHash(timestepBytes);
|
||||||
|
|
||||||
|
var offset = hash[^1] & 0x0F;
|
||||||
|
var binaryCode =
|
||||||
|
((hash[offset] & 0x7F) << 24) |
|
||||||
|
((hash[offset + 1] & 0xFF) << 16) |
|
||||||
|
((hash[offset + 2] & 0xFF) << 8) |
|
||||||
|
(hash[offset + 3] & 0xFF);
|
||||||
|
|
||||||
|
return (binaryCode % 1_000_000).ToString("D6");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Base32Decode(string base32)
|
||||||
|
{
|
||||||
|
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
base32 = base32.ToUpperInvariant().TrimEnd('=');
|
||||||
|
|
||||||
|
var bits = new List<byte>();
|
||||||
|
foreach (var c in base32)
|
||||||
|
{
|
||||||
|
var val = alphabet.IndexOf(c);
|
||||||
|
if (val < 0) continue;
|
||||||
|
for (var i = 4; i >= 0; i--)
|
||||||
|
bits.Add((byte)((val >> i) & 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = new byte[bits.Count / 8];
|
||||||
|
for (var i = 0; i < bytes.Length; i++)
|
||||||
|
{
|
||||||
|
for (var j = 0; j < 8; j++)
|
||||||
|
bytes[i] = (byte)((bytes[i] << 1) | bits[i * 8 + j]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
37
code/backend/Cleanuparr.Api.Tests/PriorityOrderer.cs
Normal file
37
code/backend/Cleanuparr.Api.Tests/PriorityOrderer.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using Xunit.Abstractions;
|
||||||
|
using Xunit.Sdk;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class PriorityOrderer : ITestCaseOrderer
|
||||||
|
{
|
||||||
|
public IEnumerable<TTestCase> OrderTestCases<TTestCase>(IEnumerable<TTestCase> testCases)
|
||||||
|
where TTestCase : ITestCase
|
||||||
|
{
|
||||||
|
var sortedMethods = new SortedDictionary<int, List<TTestCase>>();
|
||||||
|
|
||||||
|
foreach (var testCase in testCases)
|
||||||
|
{
|
||||||
|
var priority = testCase.TestMethod.Method
|
||||||
|
.GetCustomAttributes(typeof(TestPriorityAttribute).AssemblyQualifiedName)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?.GetNamedArgument<int>("Priority") ?? 0;
|
||||||
|
|
||||||
|
if (!sortedMethods.TryGetValue(priority, out var list))
|
||||||
|
{
|
||||||
|
list = [];
|
||||||
|
sortedMethods[priority] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(testCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var list in sortedMethods.Values)
|
||||||
|
{
|
||||||
|
foreach (var testCase in list)
|
||||||
|
{
|
||||||
|
yield return testCase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
code/backend/Cleanuparr.Api.Tests/TestPriorityAttribute.cs
Normal file
12
code/backend/Cleanuparr.Api.Tests/TestPriorityAttribute.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Cleanuparr.Api.Tests;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Method)]
|
||||||
|
public sealed class TestPriorityAttribute : Attribute
|
||||||
|
{
|
||||||
|
public int Priority { get; }
|
||||||
|
|
||||||
|
public TestPriorityAttribute(int priority)
|
||||||
|
{
|
||||||
|
Priority = priority;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using Cleanuparr.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Auth;
|
||||||
|
|
||||||
|
public static class ApiKeyAuthenticationDefaults
|
||||||
|
{
|
||||||
|
public const string AuthenticationScheme = "ApiKey";
|
||||||
|
public const string HeaderName = "X-Api-Key";
|
||||||
|
public const string QueryParameterName = "apikey";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||||
|
{
|
||||||
|
public ApiKeyAuthenticationHandler(
|
||||||
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder)
|
||||||
|
: base(options, logger, encoder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
// Try header first, then query string
|
||||||
|
string? apiKey = null;
|
||||||
|
|
||||||
|
if (Request.Headers.TryGetValue(ApiKeyAuthenticationDefaults.HeaderName, out var headerValue))
|
||||||
|
{
|
||||||
|
apiKey = headerValue.ToString();
|
||||||
|
}
|
||||||
|
else if (Request.Query.TryGetValue(ApiKeyAuthenticationDefaults.QueryParameterName, out var queryValue))
|
||||||
|
{
|
||||||
|
apiKey = queryValue.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
return AuthenticateResult.NoResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||||
|
var user = await usersContext.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(u => u.ApiKey == apiKey && u.SetupCompleted);
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Invalid API key");
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||||
|
new Claim(ClaimTypes.Name, user.Username),
|
||||||
|
new Claim("auth_method", "apikey")
|
||||||
|
};
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, ApiKeyAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
var ticket = new AuthenticationTicket(principal, ApiKeyAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
|
return AuthenticateResult.Success(ticket);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<AssemblyName>Cleanuparr</AssemblyName>
|
<AssemblyName>Cleanuparr</AssemblyName>
|
||||||
<Version Condition="'$(Version)' == ''">0.0.1</Version>
|
<Version Condition="'$(Version)' == ''">0.0.1</Version>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<PublishReadyToRun>true</PublishReadyToRun>
|
<PublishReadyToRun>true</PublishReadyToRun>
|
||||||
@@ -19,31 +19,29 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Cleanuparr.Application\Cleanuparr.Application.csproj" />
|
|
||||||
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
|
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
|
<PackageReference Include="MassTransit" Version="8.5.7" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.6" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
|
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.1" />
|
||||||
<PackageReference Include="Quartz" Version="3.14.0" />
|
<PackageReference Include="Quartz" Version="3.15.1" />
|
||||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
|
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
|
||||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
|
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
|
||||||
<PackageReference Include="Serilog" Version="4.3.0" />
|
<PackageReference Include="Serilog" Version="4.3.0" />
|
||||||
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
||||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
|
<PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<!-- API-related packages -->
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
|
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -29,12 +29,24 @@ public class EventsController : ControllerBase
|
|||||||
[FromQuery] string? eventType = null,
|
[FromQuery] string? eventType = null,
|
||||||
[FromQuery] DateTime? fromDate = null,
|
[FromQuery] DateTime? fromDate = null,
|
||||||
[FromQuery] DateTime? toDate = null,
|
[FromQuery] DateTime? toDate = null,
|
||||||
[FromQuery] string? search = null)
|
[FromQuery] string? search = null,
|
||||||
|
[FromQuery] string? jobRunId = null)
|
||||||
{
|
{
|
||||||
// Validate pagination parameters
|
// Validate pagination parameters
|
||||||
if (page < 1) page = 1;
|
if (page < 1)
|
||||||
if (pageSize < 1) pageSize = 100;
|
{
|
||||||
if (pageSize > 1000) pageSize = 1000; // Cap at 1000 for performance
|
page = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageSize < 1)
|
||||||
|
{
|
||||||
|
pageSize = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageSize > 1000)
|
||||||
|
{
|
||||||
|
pageSize = 1000; // Cap at 1000 for performance
|
||||||
|
}
|
||||||
|
|
||||||
var query = _context.Events.AsQueryable();
|
var query = _context.Events.AsQueryable();
|
||||||
|
|
||||||
@@ -62,6 +74,12 @@ public class EventsController : ControllerBase
|
|||||||
query = query.Where(e => e.Timestamp <= toDate.Value);
|
query = query.Where(e => e.Timestamp <= toDate.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply job run ID exact-match filter
|
||||||
|
if (!string.IsNullOrWhiteSpace(jobRunId) && Guid.TryParse(jobRunId, out var jobRunGuid))
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.JobRunId == jobRunGuid);
|
||||||
|
}
|
||||||
|
|
||||||
// Apply search filter if provided
|
// Apply search filter if provided
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
{
|
{
|
||||||
@@ -69,7 +87,10 @@ public class EventsController : ControllerBase
|
|||||||
query = query.Where(e =>
|
query = query.Where(e =>
|
||||||
EF.Functions.Like(e.Message, pattern) ||
|
EF.Functions.Like(e.Message, pattern) ||
|
||||||
EF.Functions.Like(e.Data, pattern) ||
|
EF.Functions.Like(e.Data, pattern) ||
|
||||||
EF.Functions.Like(e.TrackingId.ToString(), pattern)
|
EF.Functions.Like(e.TrackingId.ToString(), pattern) ||
|
||||||
|
EF.Functions.Like(e.InstanceUrl, pattern) ||
|
||||||
|
EF.Functions.Like(e.DownloadClientName, pattern) ||
|
||||||
|
EF.Functions.Like(e.JobRunId.ToString(), pattern)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,10 +108,6 @@ public class EventsController : ControllerBase
|
|||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
events = events
|
|
||||||
.OrderBy(e => e.Timestamp)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// Return paginated result
|
// Return paginated result
|
||||||
var result = new PaginatedResult<AppEvent>
|
var result = new PaginatedResult<AppEvent>
|
||||||
{
|
{
|
||||||
|
|||||||
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Cleanuparr.Api.Models;
|
using Cleanuparr.Api.Models;
|
||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
using Cleanuparr.Infrastructure.Models;
|
using Cleanuparr.Infrastructure.Models;
|
||||||
using Infrastructure.Services.Interfaces;
|
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Cleanuparr.Api.Controllers;
|
namespace Cleanuparr.Api.Controllers;
|
||||||
@@ -76,63 +77,23 @@ public class JobsController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{jobType}/stop")]
|
[HttpPost("{jobType}/trigger")]
|
||||||
public async Task<IActionResult> StopJob(JobType jobType)
|
public async Task<IActionResult> TriggerJob(JobType jobType)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _jobManagementService.StopJob(jobType);
|
var result = await _jobManagementService.TriggerJobOnce(jobType);
|
||||||
|
|
||||||
if (!result)
|
if (!result)
|
||||||
{
|
{
|
||||||
return BadRequest($"Failed to stop job '{jobType}'");
|
return BadRequest($"Failed to trigger job '{jobType}' - job may not exist or be configured");
|
||||||
}
|
}
|
||||||
return Ok(new { Message = $"Job '{jobType}' stopped successfully" });
|
return Ok(new { Message = $"Job '{jobType}' triggered successfully for one-time execution" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error stopping job {jobType}", jobType);
|
_logger.LogError(ex, "Error triggering job {jobType}", jobType);
|
||||||
return StatusCode(500, $"An error occurred while stopping job '{jobType}'");
|
return StatusCode(500, $"An error occurred while triggering job '{jobType}'");
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("{jobType}/pause")]
|
|
||||||
public async Task<IActionResult> PauseJob(JobType jobType)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _jobManagementService.PauseJob(jobType);
|
|
||||||
|
|
||||||
if (!result)
|
|
||||||
{
|
|
||||||
return BadRequest($"Failed to pause job '{jobType}'");
|
|
||||||
}
|
|
||||||
return Ok(new { Message = $"Job '{jobType}' paused successfully" });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error pausing job {jobType}", jobType);
|
|
||||||
return StatusCode(500, $"An error occurred while pausing job '{jobType}'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("{jobType}/resume")]
|
|
||||||
public async Task<IActionResult> ResumeJob(JobType jobType)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _jobManagementService.ResumeJob(jobType);
|
|
||||||
|
|
||||||
if (!result)
|
|
||||||
{
|
|
||||||
return BadRequest($"Failed to resume job '{jobType}'");
|
|
||||||
}
|
|
||||||
return Ok(new { Message = $"Job '{jobType}' resumed successfully" });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error resuming job {jobType}", jobType);
|
|
||||||
return StatusCode(500, $"An error occurred while resuming job '{jobType}'");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
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) ||
|
||||||
|
EF.Functions.Like(e.InstanceUrl, pattern) ||
|
||||||
|
EF.Functions.Like(e.DownloadClientName, 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
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Cleanuparr.Domain.Enums;
|
using Cleanuparr.Domain.Enums;
|
||||||
using Cleanuparr.Infrastructure.Features.Arr;
|
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
|
||||||
using Cleanuparr.Persistence;
|
using Cleanuparr.Persistence;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -14,18 +13,15 @@ public class StatusController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly ILogger<StatusController> _logger;
|
private readonly ILogger<StatusController> _logger;
|
||||||
private readonly DataContext _dataContext;
|
private readonly DataContext _dataContext;
|
||||||
private readonly DownloadServiceFactory _downloadServiceFactory;
|
private readonly IArrClientFactory _arrClientFactory;
|
||||||
private readonly ArrClientFactory _arrClientFactory;
|
|
||||||
|
|
||||||
public StatusController(
|
public StatusController(
|
||||||
ILogger<StatusController> logger,
|
ILogger<StatusController> logger,
|
||||||
DataContext dataContext,
|
DataContext dataContext,
|
||||||
DownloadServiceFactory downloadServiceFactory,
|
IArrClientFactory arrClientFactory)
|
||||||
ArrClientFactory arrClientFactory)
|
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_dataContext = dataContext;
|
_dataContext = dataContext;
|
||||||
_downloadServiceFactory = downloadServiceFactory;
|
|
||||||
_arrClientFactory = arrClientFactory;
|
_arrClientFactory = arrClientFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +48,10 @@ public class StatusController : ControllerBase
|
|||||||
.Include(x => x.Instances)
|
.Include(x => x.Instances)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstAsync(x => x.Type == InstanceType.Lidarr);
|
.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
|
var status = new
|
||||||
{
|
{
|
||||||
@@ -80,6 +80,10 @@ public class StatusController : ControllerBase
|
|||||||
Lidarr = new
|
Lidarr = new
|
||||||
{
|
{
|
||||||
InstanceCount = lidarrConfig.Instances.Count
|
InstanceCount = lidarrConfig.Instances.Count
|
||||||
|
},
|
||||||
|
Readarr = new
|
||||||
|
{
|
||||||
|
InstanceCount = readarrConfig.Instances.Count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -170,8 +174,8 @@ public class StatusController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr);
|
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr, instance.Version);
|
||||||
await sonarrClient.TestConnectionAsync(instance);
|
await sonarrClient.HealthCheckAsync(instance);
|
||||||
|
|
||||||
sonarrStatus.Add(new
|
sonarrStatus.Add(new
|
||||||
{
|
{
|
||||||
@@ -202,8 +206,8 @@ public class StatusController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr);
|
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr, instance.Version);
|
||||||
await radarrClient.TestConnectionAsync(instance);
|
await radarrClient.HealthCheckAsync(instance);
|
||||||
|
|
||||||
radarrStatus.Add(new
|
radarrStatus.Add(new
|
||||||
{
|
{
|
||||||
@@ -234,8 +238,8 @@ public class StatusController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr);
|
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr, instance.Version);
|
||||||
await lidarrClient.TestConnectionAsync(instance);
|
await lidarrClient.HealthCheckAsync(instance);
|
||||||
|
|
||||||
lidarrStatus.Add(new
|
lidarrStatus.Add(new
|
||||||
{
|
{
|
||||||
|
|||||||
189
code/backend/Cleanuparr.Api/Controllers/StrikesController.cs
Normal file
189
code/backend/Cleanuparr.Api/Controllers/StrikesController.cs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
using Cleanuparr.Persistence;
|
||||||
|
using Cleanuparr.Persistence.Models.State;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class StrikesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly EventsContext _context;
|
||||||
|
|
||||||
|
public StrikesController(EventsContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets download items with their strikes (grouped), with pagination and filtering
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<PaginatedResult<DownloadItemStrikesDto>>> GetStrikes(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 50,
|
||||||
|
[FromQuery] string? search = null,
|
||||||
|
[FromQuery] string? type = null)
|
||||||
|
{
|
||||||
|
if (page < 1) page = 1;
|
||||||
|
if (pageSize < 1) pageSize = 50;
|
||||||
|
if (pageSize > 100) pageSize = 100;
|
||||||
|
|
||||||
|
var query = _context.DownloadItems
|
||||||
|
.Include(d => d.Strikes)
|
||||||
|
.Where(d => d.Strikes.Any());
|
||||||
|
|
||||||
|
// Filter by strike type: only show items that have strikes of this type
|
||||||
|
if (!string.IsNullOrWhiteSpace(type))
|
||||||
|
{
|
||||||
|
if (Enum.TryParse<StrikeType>(type, true, out var strikeType))
|
||||||
|
query = query.Where(d => d.Strikes.Any(s => s.Type == strikeType));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search filter on title or download hash
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
string pattern = EventsContext.GetLikePattern(search);
|
||||||
|
query = query.Where(d =>
|
||||||
|
EF.Functions.Like(d.Title, pattern) ||
|
||||||
|
EF.Functions.Like(d.DownloadId, pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync();
|
||||||
|
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||||
|
var skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.OrderByDescending(d => d.Strikes.Max(s => s.CreatedAt))
|
||||||
|
.Skip(skip)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var dtos = items.Select(d => new DownloadItemStrikesDto
|
||||||
|
{
|
||||||
|
DownloadItemId = d.Id,
|
||||||
|
DownloadId = d.DownloadId,
|
||||||
|
Title = d.Title,
|
||||||
|
TotalStrikes = d.Strikes.Count,
|
||||||
|
StrikesByType = d.Strikes
|
||||||
|
.GroupBy(s => s.Type)
|
||||||
|
.ToDictionary(g => g.Key.ToString(), g => g.Count()),
|
||||||
|
LatestStrikeAt = d.Strikes.Max(s => s.CreatedAt),
|
||||||
|
FirstStrikeAt = d.Strikes.Min(s => s.CreatedAt),
|
||||||
|
IsMarkedForRemoval = d.IsMarkedForRemoval,
|
||||||
|
IsRemoved = d.IsRemoved,
|
||||||
|
IsReturning = d.IsReturning,
|
||||||
|
Strikes = d.Strikes
|
||||||
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
|
.Select(s => new StrikeDetailDto
|
||||||
|
{
|
||||||
|
Id = s.Id,
|
||||||
|
Type = s.Type.ToString(),
|
||||||
|
CreatedAt = s.CreatedAt,
|
||||||
|
LastDownloadedBytes = s.LastDownloadedBytes,
|
||||||
|
JobRunId = s.JobRunId,
|
||||||
|
}).ToList(),
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return Ok(new PaginatedResult<DownloadItemStrikesDto>
|
||||||
|
{
|
||||||
|
Items = dtos,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
TotalCount = totalCount,
|
||||||
|
TotalPages = totalPages,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the most recent individual strikes with download item info (for dashboard)
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("recent")]
|
||||||
|
public async Task<ActionResult<List<RecentStrikeDto>>> GetRecentStrikes(
|
||||||
|
[FromQuery] int count = 5)
|
||||||
|
{
|
||||||
|
if (count < 1) count = 1;
|
||||||
|
if (count > 50) count = 50;
|
||||||
|
|
||||||
|
var strikes = await _context.Strikes
|
||||||
|
.Include(s => s.DownloadItem)
|
||||||
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
|
.Take(count)
|
||||||
|
.Select(s => new RecentStrikeDto
|
||||||
|
{
|
||||||
|
Id = s.Id,
|
||||||
|
Type = s.Type.ToString(),
|
||||||
|
CreatedAt = s.CreatedAt,
|
||||||
|
DownloadId = s.DownloadItem.DownloadId,
|
||||||
|
Title = s.DownloadItem.Title,
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(strikes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all available strike types
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("types")]
|
||||||
|
public ActionResult<List<string>> GetStrikeTypes()
|
||||||
|
{
|
||||||
|
var types = Enum.GetNames(typeof(StrikeType)).ToList();
|
||||||
|
return Ok(types);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes all strikes for a specific download item
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("{downloadItemId:guid}")]
|
||||||
|
public async Task<IActionResult> DeleteStrikesForItem(Guid downloadItemId)
|
||||||
|
{
|
||||||
|
var item = await _context.DownloadItems
|
||||||
|
.Include(d => d.Strikes)
|
||||||
|
.FirstOrDefaultAsync(d => d.Id == downloadItemId);
|
||||||
|
|
||||||
|
if (item == null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
_context.Strikes.RemoveRange(item.Strikes);
|
||||||
|
_context.DownloadItems.Remove(item);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DownloadItemStrikesDto
|
||||||
|
{
|
||||||
|
public Guid DownloadItemId { get; set; }
|
||||||
|
public string DownloadId { get; set; } = string.Empty;
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public int TotalStrikes { get; set; }
|
||||||
|
public Dictionary<string, int> StrikesByType { get; set; } = new();
|
||||||
|
public DateTime LatestStrikeAt { get; set; }
|
||||||
|
public DateTime FirstStrikeAt { get; set; }
|
||||||
|
public bool IsMarkedForRemoval { get; set; }
|
||||||
|
public bool IsRemoved { get; set; }
|
||||||
|
public bool IsReturning { get; set; }
|
||||||
|
public List<StrikeDetailDto> Strikes { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StrikeDetailDto
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public long? LastDownloadedBytes { get; set; }
|
||||||
|
public Guid JobRunId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RecentStrikeDto
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public string DownloadId { get; set; } = string.Empty;
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Cleanuparr.Api.Middleware;
|
|
||||||
using Cleanuparr.Infrastructure.Health;
|
using Cleanuparr.Infrastructure.Health;
|
||||||
using Cleanuparr.Infrastructure.Hubs;
|
using Cleanuparr.Infrastructure.Hubs;
|
||||||
using Cleanuparr.Infrastructure.Logging;
|
|
||||||
using Microsoft.AspNetCore.Http.Json;
|
using Microsoft.AspNetCore.Http.Json;
|
||||||
using Microsoft.OpenApi.Models;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Cleanuparr.Api.Middleware;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Cleanuparr.Api.DependencyInjection;
|
namespace Cleanuparr.Api.DependencyInjection;
|
||||||
|
|
||||||
@@ -15,15 +14,21 @@ public static class ApiDI
|
|||||||
{
|
{
|
||||||
services.Configure<JsonOptions>(options =>
|
services.Configure<JsonOptions>(options =>
|
||||||
{
|
{
|
||||||
|
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
||||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||||
options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Make JsonSerializerOptions available for injection
|
||||||
|
services.AddSingleton(sp =>
|
||||||
|
sp.GetRequiredService<IOptions<JsonOptions>>().Value.SerializerOptions);
|
||||||
|
|
||||||
// Add API-specific services
|
// Add API-specific services
|
||||||
services
|
services
|
||||||
.AddControllers()
|
.AddControllers()
|
||||||
.AddJsonOptions(options =>
|
.AddJsonOptions(options =>
|
||||||
{
|
{
|
||||||
|
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||||
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||||
});
|
});
|
||||||
@@ -34,29 +39,13 @@ public static class ApiDI
|
|||||||
.AddSignalR()
|
.AddSignalR()
|
||||||
.AddJsonProtocol(options =>
|
.AddJsonProtocol(options =>
|
||||||
{
|
{
|
||||||
|
options.PayloadSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||||
options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add health status broadcaster
|
// Add health status broadcaster
|
||||||
services.AddHostedService<HealthStatusBroadcaster>();
|
services.AddHostedService<HealthStatusBroadcaster>();
|
||||||
|
|
||||||
// Add logging initializer service
|
|
||||||
services.AddHostedService<LoggingInitializer>();
|
|
||||||
|
|
||||||
services.AddSwaggerGen(options =>
|
|
||||||
{
|
|
||||||
options.SwaggerDoc("v1", new OpenApiInfo
|
|
||||||
{
|
|
||||||
Title = "Cleanuparr API",
|
|
||||||
Version = "v1",
|
|
||||||
Description = "API for managing media downloads and cleanups",
|
|
||||||
Contact = new OpenApiContact
|
|
||||||
{
|
|
||||||
Name = "Cleanuparr Team"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,33 +59,19 @@ public static class ApiDI
|
|||||||
// Serve static files with caching
|
// Serve static files with caching
|
||||||
app.UseStaticFiles(new StaticFileOptions
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
{
|
{
|
||||||
OnPrepareResponse = ctx =>
|
OnPrepareResponse = _ => {}
|
||||||
{
|
|
||||||
// Cache static assets for 30 days
|
|
||||||
// if (ctx.File.Name.EndsWith(".js") || ctx.File.Name.EndsWith(".css"))
|
|
||||||
// {
|
|
||||||
// ctx.Context.Response.Headers.CacheControl = "public,max-age=2592000";
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add the global exception handling middleware first
|
// Add the global exception handling middleware first
|
||||||
app.UseMiddleware<ExceptionMiddleware>();
|
app.UseMiddleware<ExceptionMiddleware>();
|
||||||
|
|
||||||
|
// Block non-auth requests until setup is complete
|
||||||
|
app.UseMiddleware<SetupGuardMiddleware>();
|
||||||
|
|
||||||
app.UseCors("Any");
|
app.UseCors("Any");
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
app.UseAuthentication();
|
||||||
{
|
|
||||||
app.UseSwagger();
|
|
||||||
app.UseSwaggerUI(options =>
|
|
||||||
{
|
|
||||||
options.SwaggerEndpoint("v1/swagger.json", "Cleanuparr API v1");
|
|
||||||
options.RoutePrefix = "swagger";
|
|
||||||
options.DocumentTitle = "Cleanuparr API Documentation";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
@@ -137,11 +112,43 @@ public static class ApiDI
|
|||||||
|
|
||||||
context.Response.ContentType = "text/html";
|
context.Response.ContentType = "text/html";
|
||||||
await context.Response.WriteAsync(indexContent, Encoding.UTF8);
|
await context.Response.WriteAsync(indexContent, Encoding.UTF8);
|
||||||
});
|
}).AllowAnonymous();
|
||||||
|
|
||||||
// Map SignalR hubs
|
// Map SignalR hubs
|
||||||
app.MapHub<HealthStatusHub>("/api/hubs/health");
|
app.MapHub<HealthStatusHub>("/api/hubs/health").RequireAuthorization();
|
||||||
app.MapHub<AppHub>("/api/hubs/app");
|
app.MapHub<AppHub>("/api/hubs/app").RequireAuthorization();
|
||||||
|
|
||||||
|
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");
|
||||||
|
}).AllowAnonymous();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
81
code/backend/Cleanuparr.Api/DependencyInjection/AuthDI.cs
Normal file
81
code/backend/Cleanuparr.Api/DependencyInjection/AuthDI.cs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
using Cleanuparr.Api.Auth;
|
||||||
|
using Cleanuparr.Infrastructure.Features.Auth;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.DependencyInjection;
|
||||||
|
|
||||||
|
public static class AuthDI
|
||||||
|
{
|
||||||
|
private const string SmartScheme = "Smart";
|
||||||
|
|
||||||
|
public static IServiceCollection AddAuthServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
// Get the signing key from the JwtService
|
||||||
|
var jwtService = new JwtService();
|
||||||
|
var signingKey = jwtService.GetOrCreateSigningKey();
|
||||||
|
|
||||||
|
services
|
||||||
|
.AddAuthentication(SmartScheme)
|
||||||
|
.AddPolicyScheme(SmartScheme, "JWT or API Key", options =>
|
||||||
|
{
|
||||||
|
// Route to the correct auth handler based on the request
|
||||||
|
options.ForwardDefaultSelector = context =>
|
||||||
|
{
|
||||||
|
if (context.Request.Headers.ContainsKey(ApiKeyAuthenticationDefaults.HeaderName) ||
|
||||||
|
context.Request.Query.ContainsKey(ApiKeyAuthenticationDefaults.QueryParameterName))
|
||||||
|
{
|
||||||
|
return ApiKeyAuthenticationDefaults.AuthenticationScheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JwtBearerDefaults.AuthenticationScheme;
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = "Cleanuparr",
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudience = "Cleanuparr",
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(signingKey),
|
||||||
|
ClockSkew = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Support SignalR token via query string
|
||||||
|
options.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnMessageReceived = context =>
|
||||||
|
{
|
||||||
|
var accessToken = context.Request.Query["access_token"];
|
||||||
|
var path = context.HttpContext.Request.Path;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/api/hubs"))
|
||||||
|
{
|
||||||
|
context.Token = accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
|
||||||
|
ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||||
|
|
||||||
|
services.AddAuthorization(options =>
|
||||||
|
{
|
||||||
|
var defaultPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
options.DefaultPolicy = defaultPolicy;
|
||||||
|
options.FallbackPolicy = defaultPolicy;
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
using Cleanuparr.Domain.Enums;
|
using Cleanuparr.Infrastructure.Logging;
|
||||||
using Cleanuparr.Infrastructure.Models;
|
|
||||||
using Cleanuparr.Shared.Helpers;
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Serilog.Events;
|
|
||||||
using Serilog.Templates;
|
|
||||||
using Serilog.Templates.Themes;
|
|
||||||
|
|
||||||
namespace Cleanuparr.Api.DependencyInjection;
|
namespace Cleanuparr.Api.DependencyInjection;
|
||||||
|
|
||||||
@@ -12,82 +7,10 @@ public static class LoggingDI
|
|||||||
{
|
{
|
||||||
public static ILoggingBuilder AddLogging(this ILoggingBuilder builder)
|
public static ILoggingBuilder AddLogging(this ILoggingBuilder builder)
|
||||||
{
|
{
|
||||||
Log.Logger = GetDefaultLoggerConfiguration().CreateLogger();
|
Log.Logger = LoggingConfigManager
|
||||||
|
.CreateLoggerConfiguration()
|
||||||
|
.CreateLogger();
|
||||||
|
|
||||||
return builder.ClearProviders().AddSerilog();
|
return builder.ClearProviders().AddSerilog();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static LoggerConfiguration GetDefaultLoggerConfiguration()
|
|
||||||
{
|
|
||||||
LoggerConfiguration logConfig = new();
|
|
||||||
const string categoryTemplate = "{#if Category is not null} {Concat('[',Category,']'),CAT_PAD}{#end}";
|
|
||||||
const string jobNameTemplate = "{#if JobName is not null} {Concat('[',JobName,']'),JOB_PAD}{#end}";
|
|
||||||
|
|
||||||
const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m}}\n{{@x}}";
|
|
||||||
const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m:lj}}\n{{@x}}";
|
|
||||||
|
|
||||||
// Determine job name padding
|
|
||||||
List<string> jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.ContentBlocker), nameof(JobType.DownloadCleaner)];
|
|
||||||
int jobPadding = jobNames.Max(x => x.Length) + 2;
|
|
||||||
|
|
||||||
// Determine instance name padding
|
|
||||||
List<string> categoryNames = [
|
|
||||||
InstanceType.Sonarr.ToString(),
|
|
||||||
InstanceType.Radarr.ToString(),
|
|
||||||
InstanceType.Lidarr.ToString(),
|
|
||||||
InstanceType.Readarr.ToString(),
|
|
||||||
InstanceType.Whisparr.ToString(),
|
|
||||||
"SYSTEM"
|
|
||||||
];
|
|
||||||
int catPadding = categoryNames.Max(x => x.Length) + 2;
|
|
||||||
|
|
||||||
// Apply padding values to templates
|
|
||||||
string consoleTemplate = consoleOutputTemplate
|
|
||||||
.Replace("JOB_PAD", jobPadding.ToString())
|
|
||||||
.Replace("CAT_PAD", catPadding.ToString());
|
|
||||||
|
|
||||||
string fileTemplate = fileOutputTemplate
|
|
||||||
.Replace("JOB_PAD", jobPadding.ToString())
|
|
||||||
.Replace("CAT_PAD", catPadding.ToString());
|
|
||||||
|
|
||||||
// Configure base logger with dynamic level control
|
|
||||||
logConfig
|
|
||||||
.MinimumLevel.Is(LogEventLevel.Information)
|
|
||||||
.Enrich.FromLogContext()
|
|
||||||
.WriteTo.Console(new ExpressionTemplate(consoleTemplate, theme: TemplateTheme.Literate));
|
|
||||||
|
|
||||||
// Create the logs directory
|
|
||||||
string logsPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "logs");
|
|
||||||
if (!Directory.Exists(logsPath))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(logsPath);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
throw new Exception($"Failed to create log directory | {logsPath}", exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add main log file
|
|
||||||
logConfig.WriteTo.File(
|
|
||||||
path: Path.Combine(logsPath, "cleanuparr-.txt"),
|
|
||||||
formatter: new ExpressionTemplate(fileTemplate),
|
|
||||||
fileSizeLimitBytes: 10L * 1024 * 1024,
|
|
||||||
rollingInterval: RollingInterval.Day,
|
|
||||||
rollOnFileSizeLimit: true,
|
|
||||||
shared: true
|
|
||||||
);
|
|
||||||
|
|
||||||
logConfig
|
|
||||||
.MinimumLevel.Override("MassTransit", LogEventLevel.Warning)
|
|
||||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
|
||||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
|
||||||
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
|
|
||||||
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
|
|
||||||
.Enrich.WithProperty("ApplicationName", "Cleanuparr");
|
|
||||||
|
|
||||||
return logConfig;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
using System.Text.Json.Serialization;
|
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.DownloadRemover.Consumers;
|
||||||
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
|
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
|
||||||
|
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||||
using Cleanuparr.Infrastructure.Health;
|
using Cleanuparr.Infrastructure.Health;
|
||||||
using Cleanuparr.Infrastructure.Http;
|
using Cleanuparr.Infrastructure.Http;
|
||||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||||
using Data.Models.Arr;
|
using Data.Models.Arr;
|
||||||
using Infrastructure.Verticals.Notifications.Models;
|
|
||||||
using MassTransit;
|
using MassTransit;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
@@ -15,22 +17,26 @@ public static class MainDI
|
|||||||
{
|
{
|
||||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
|
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
|
||||||
services
|
services
|
||||||
.AddLogging(builder => builder.ClearProviders().AddConsole())
|
|
||||||
.AddHttpClients(configuration)
|
.AddHttpClients(configuration)
|
||||||
.AddSingleton<MemoryCache>()
|
.AddSingleton<MemoryCache>()
|
||||||
.AddSingleton<IMemoryCache>(serviceProvider => serviceProvider.GetRequiredService<MemoryCache>())
|
.AddSingleton<IMemoryCache>(serviceProvider => serviceProvider.GetRequiredService<MemoryCache>())
|
||||||
.AddServices()
|
.AddServices()
|
||||||
.AddHealthServices()
|
.AddHealthServices()
|
||||||
.AddQuartzServices(configuration)
|
.AddQuartzServices(configuration)
|
||||||
.AddNotifications(configuration)
|
.AddNotifications()
|
||||||
.AddMassTransit(config =>
|
.AddMassTransit(config =>
|
||||||
{
|
{
|
||||||
|
config.DisableUsageTelemetry();
|
||||||
|
|
||||||
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
|
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
|
||||||
config.AddConsumer<DownloadRemoverConsumer<SonarrSearchItem>>();
|
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
|
||||||
|
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();
|
||||||
|
config.AddConsumer<DownloadHunterConsumer<SeriesSearchItem>>();
|
||||||
|
|
||||||
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
|
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
|
||||||
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
|
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
|
||||||
config.AddConsumer<NotificationConsumer<SlowStrikeNotification>>();
|
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
|
||||||
|
config.AddConsumer<NotificationConsumer<SlowTimeStrikeNotification>>();
|
||||||
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
|
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
|
||||||
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
|
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
|
||||||
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
|
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
|
||||||
@@ -39,6 +45,7 @@ public static class MainDI
|
|||||||
{
|
{
|
||||||
cfg.ConfigureJsonSerializerOptions(options =>
|
cfg.ConfigureJsonSerializerOptions(options =>
|
||||||
{
|
{
|
||||||
|
options.PropertyNameCaseInsensitive = true;
|
||||||
options.Converters.Add(new JsonStringEnumConverter());
|
options.Converters.Add(new JsonStringEnumConverter());
|
||||||
options.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
options.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||||
|
|
||||||
@@ -48,7 +55,15 @@ public static class MainDI
|
|||||||
cfg.ReceiveEndpoint("download-remover-queue", e =>
|
cfg.ReceiveEndpoint("download-remover-queue", e =>
|
||||||
{
|
{
|
||||||
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
|
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
|
||||||
e.ConfigureConsumer<DownloadRemoverConsumer<SonarrSearchItem>>(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.ConcurrentMessageLimit = 1;
|
||||||
e.PrefetchCount = 1;
|
e.PrefetchCount = 1;
|
||||||
});
|
});
|
||||||
@@ -57,7 +72,8 @@ public static class MainDI
|
|||||||
{
|
{
|
||||||
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
|
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
|
||||||
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(context);
|
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(context);
|
||||||
e.ConfigureConsumer<NotificationConsumer<SlowStrikeNotification>>(context);
|
e.ConfigureConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>(context);
|
||||||
|
e.ConfigureConsumer<NotificationConsumer<SlowTimeStrikeNotification>>(context);
|
||||||
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
|
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
|
||||||
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
|
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
|
||||||
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);
|
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);
|
||||||
@@ -75,6 +91,9 @@ public static class MainDI
|
|||||||
// Add the dynamic HTTP client provider that uses the new system
|
// Add the dynamic HTTP client provider that uses the new system
|
||||||
services.AddSingleton<IDynamicHttpClientProvider, DynamicHttpClientProvider>();
|
services.AddSingleton<IDynamicHttpClientProvider, DynamicHttpClientProvider>();
|
||||||
|
|
||||||
|
// Add HTTP client for Plex authentication
|
||||||
|
services.AddHttpClient("PlexAuth");
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +102,17 @@ public static class MainDI
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static IServiceCollection AddHealthServices(this IServiceCollection services) =>
|
private static IServiceCollection AddHealthServices(this IServiceCollection services) =>
|
||||||
services
|
services
|
||||||
// Register the health check service
|
// Register the existing health check service for download clients
|
||||||
.AddSingleton<IHealthCheckService, HealthCheckService>()
|
.AddSingleton<IHealthCheckService, HealthCheckService>()
|
||||||
|
|
||||||
// Register the background service for periodic health checks
|
// Register the background service for periodic health checks
|
||||||
.AddHostedService<HealthCheckBackgroundService>();
|
.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;
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,30 @@
|
|||||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||||
|
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
|
||||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||||
using Infrastructure.Verticals.Notifications;
|
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||||
|
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
|
||||||
|
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
|
||||||
|
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
|
||||||
|
|
||||||
namespace Cleanuparr.Api.DependencyInjection;
|
namespace Cleanuparr.Api.DependencyInjection;
|
||||||
|
|
||||||
public static class NotificationsDI
|
public static class NotificationsDI
|
||||||
{
|
{
|
||||||
public static IServiceCollection AddNotifications(this IServiceCollection services, IConfiguration configuration) =>
|
public static IServiceCollection AddNotifications(this IServiceCollection services) =>
|
||||||
services
|
services
|
||||||
// Notification configs are now managed through ConfigManager
|
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
|
||||||
.AddTransient<INotifiarrProxy, NotifiarrProxy>()
|
.AddScoped<IAppriseProxy, AppriseProxy>()
|
||||||
.AddTransient<INotificationProvider, NotifiarrProvider>()
|
.AddScoped<IAppriseCliProxy, AppriseCliProxy>()
|
||||||
.AddTransient<IAppriseProxy, AppriseProxy>()
|
.AddSingleton<IAppriseCliDetector, AppriseCliDetector>()
|
||||||
.AddTransient<INotificationProvider, AppriseProvider>()
|
.AddScoped<INtfyProxy, NtfyProxy>()
|
||||||
.AddTransient<INotificationPublisher, NotificationPublisher>()
|
.AddScoped<IPushoverProxy, PushoverProxy>()
|
||||||
.AddTransient<INotificationFactory, NotificationFactory>()
|
.AddScoped<ITelegramProxy, TelegramProxy>()
|
||||||
.AddTransient<NotificationService>();
|
.AddScoped<IDiscordProxy, DiscordProxy>()
|
||||||
|
.AddScoped<IGotifyProxy, GotifyProxy>()
|
||||||
|
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
|
||||||
|
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
|
||||||
|
.AddScoped<NotificationProviderFactory>()
|
||||||
|
.AddScoped<INotificationPublisher, NotificationPublisher>()
|
||||||
|
.AddScoped<NotificationService>();
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,23 @@
|
|||||||
using Cleanuparr.Application.Features.ContentBlocker;
|
|
||||||
using Cleanuparr.Application.Features.DownloadCleaner;
|
|
||||||
using Cleanuparr.Application.Features.QueueCleaner;
|
|
||||||
using Cleanuparr.Infrastructure.Events;
|
using Cleanuparr.Infrastructure.Events;
|
||||||
|
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||||
using Cleanuparr.Infrastructure.Features.Arr;
|
using Cleanuparr.Infrastructure.Features.Arr;
|
||||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||||
|
using Cleanuparr.Infrastructure.Features.Auth;
|
||||||
|
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
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;
|
||||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||||
using Cleanuparr.Infrastructure.Features.Files;
|
using Cleanuparr.Infrastructure.Features.Files;
|
||||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||||
using Cleanuparr.Infrastructure.Features.Security;
|
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||||
|
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||||
|
using Cleanuparr.Infrastructure.Helpers;
|
||||||
using Cleanuparr.Infrastructure.Interceptors;
|
using Cleanuparr.Infrastructure.Interceptors;
|
||||||
using Cleanuparr.Infrastructure.Services;
|
using Cleanuparr.Infrastructure.Services;
|
||||||
|
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||||
using Cleanuparr.Persistence;
|
using Cleanuparr.Persistence;
|
||||||
using Infrastructure.Interceptors;
|
|
||||||
using Infrastructure.Services.Interfaces;
|
|
||||||
using Infrastructure.Verticals.Files;
|
|
||||||
|
|
||||||
namespace Cleanuparr.Api.DependencyInjection;
|
namespace Cleanuparr.Api.DependencyInjection;
|
||||||
|
|
||||||
@@ -23,31 +25,44 @@ public static class ServicesDI
|
|||||||
{
|
{
|
||||||
public static IServiceCollection AddServices(this IServiceCollection services) =>
|
public static IServiceCollection AddServices(this IServiceCollection services) =>
|
||||||
services
|
services
|
||||||
.AddSingleton<IEncryptionService, AesEncryptionService>()
|
.AddScoped<EventsContext>()
|
||||||
.AddTransient<SensitiveDataJsonConverter>()
|
.AddScoped<DataContext>()
|
||||||
.AddTransient<EventsContext>()
|
.AddScoped<UsersContext>()
|
||||||
.AddTransient<DataContext>()
|
.AddSingleton<IJwtService, JwtService>()
|
||||||
.AddTransient<EventPublisher>()
|
.AddSingleton<IPasswordService, PasswordService>()
|
||||||
|
.AddSingleton<ITotpService, TotpService>()
|
||||||
|
.AddScoped<IPlexAuthService, PlexAuthService>()
|
||||||
|
.AddScoped<IEventPublisher, EventPublisher>()
|
||||||
.AddHostedService<EventCleanupService>()
|
.AddHostedService<EventCleanupService>()
|
||||||
// API services
|
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
|
||||||
|
.AddScoped<CertificateValidationService>()
|
||||||
|
.AddScoped<ISonarrClient, SonarrClient>()
|
||||||
|
.AddScoped<IRadarrClient, RadarrClient>()
|
||||||
|
.AddScoped<ILidarrClient, LidarrClient>()
|
||||||
|
.AddScoped<IReadarrClient, ReadarrClient>()
|
||||||
|
.AddScoped<IWhisparrV2Client, WhisparrV2Client>()
|
||||||
|
.AddScoped<IWhisparrV3Client, WhisparrV3Client>()
|
||||||
|
.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<IJobManagementService, JobManagementService>()
|
||||||
// Core services
|
.AddSingleton<IBlocklistProvider, BlocklistProvider>()
|
||||||
.AddTransient<IDryRunInterceptor, DryRunInterceptor>()
|
.AddSingleton(TimeProvider.System)
|
||||||
.AddTransient<CertificateValidationService>()
|
.AddSingleton<AppStatusSnapshot>()
|
||||||
.AddTransient<SonarrClient>()
|
.AddHostedService<AppStatusRefreshService>();
|
||||||
.AddTransient<RadarrClient>()
|
|
||||||
.AddTransient<LidarrClient>()
|
|
||||||
.AddTransient<ArrClientFactory>()
|
|
||||||
.AddTransient<QueueCleaner>()
|
|
||||||
.AddTransient<ContentBlocker>()
|
|
||||||
.AddTransient<DownloadCleaner>()
|
|
||||||
.AddTransient<IQueueItemRemover, QueueItemRemover>()
|
|
||||||
.AddTransient<IFilenameEvaluator, FilenameEvaluator>()
|
|
||||||
.AddTransient<IHardLinkFileService, HardLinkFileService>()
|
|
||||||
.AddTransient<UnixHardLinkFileService>()
|
|
||||||
.AddTransient<WindowsHardLinkFileService>()
|
|
||||||
.AddTransient<ArrQueueIterator>()
|
|
||||||
.AddTransient<DownloadServiceFactory>()
|
|
||||||
.AddTransient<IStriker, Striker>()
|
|
||||||
.AddSingleton<BlocklistProvider>();
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public required float Version { get; init; }
|
||||||
|
|
||||||
|
public string? ExternalUrl { get; init; }
|
||||||
|
|
||||||
|
public ArrInstance ToEntity(Guid configId) => new()
|
||||||
|
{
|
||||||
|
Enabled = Enabled,
|
||||||
|
Name = Name,
|
||||||
|
Url = new Uri(Url),
|
||||||
|
ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null,
|
||||||
|
ApiKey = ApiKey,
|
||||||
|
ArrConfigId = configId,
|
||||||
|
Version = Version,
|
||||||
|
};
|
||||||
|
|
||||||
|
public void ApplyTo(ArrInstance instance)
|
||||||
|
{
|
||||||
|
instance.Enabled = Enabled;
|
||||||
|
instance.Name = Name;
|
||||||
|
instance.Url = new Uri(Url);
|
||||||
|
instance.ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null;
|
||||||
|
instance.ApiKey = ApiKey;
|
||||||
|
instance.Version = Version;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public required float Version { get; init; }
|
||||||
|
|
||||||
|
public ArrInstance ToTestInstance() => new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Name = "Test Instance",
|
||||||
|
Url = new Uri(Url),
|
||||||
|
ApiKey = ApiKey,
|
||||||
|
ArrConfigId = Guid.Empty,
|
||||||
|
Version = Version,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record UpdateArrConfigRequest
|
||||||
|
{
|
||||||
|
public short FailedImportMaxStrikes { get; init; } = -1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||||
|
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||||
|
using Cleanuparr.Persistence;
|
||||||
|
using Mapster;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Arr.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/configuration")]
|
||||||
|
public sealed class ArrConfigController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<ArrConfigController> _logger;
|
||||||
|
private readonly DataContext _dataContext;
|
||||||
|
private readonly IArrClientFactory _arrClientFactory;
|
||||||
|
|
||||||
|
public ArrConfigController(
|
||||||
|
ILogger<ArrConfigController> logger,
|
||||||
|
DataContext dataContext,
|
||||||
|
IArrClientFactory arrClientFactory)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_dataContext = dataContext;
|
||||||
|
_arrClientFactory = arrClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("sonarr")]
|
||||||
|
public Task<IActionResult> GetSonarrConfig() => GetArrConfig(InstanceType.Sonarr);
|
||||||
|
|
||||||
|
[HttpGet("radarr")]
|
||||||
|
public Task<IActionResult> GetRadarrConfig() => GetArrConfig(InstanceType.Radarr);
|
||||||
|
|
||||||
|
[HttpGet("lidarr")]
|
||||||
|
public Task<IActionResult> GetLidarrConfig() => GetArrConfig(InstanceType.Lidarr);
|
||||||
|
|
||||||
|
[HttpGet("readarr")]
|
||||||
|
public Task<IActionResult> GetReadarrConfig() => GetArrConfig(InstanceType.Readarr);
|
||||||
|
|
||||||
|
[HttpGet("whisparr")]
|
||||||
|
public Task<IActionResult> GetWhisparrConfig() => GetArrConfig(InstanceType.Whisparr);
|
||||||
|
|
||||||
|
[HttpPut("sonarr")]
|
||||||
|
public Task<IActionResult> UpdateSonarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||||
|
=> UpdateArrConfig(InstanceType.Sonarr, request);
|
||||||
|
|
||||||
|
[HttpPut("radarr")]
|
||||||
|
public Task<IActionResult> UpdateRadarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||||
|
=> UpdateArrConfig(InstanceType.Radarr, request);
|
||||||
|
|
||||||
|
[HttpPut("lidarr")]
|
||||||
|
public Task<IActionResult> UpdateLidarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||||
|
=> UpdateArrConfig(InstanceType.Lidarr, request);
|
||||||
|
|
||||||
|
[HttpPut("readarr")]
|
||||||
|
public Task<IActionResult> UpdateReadarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||||
|
=> UpdateArrConfig(InstanceType.Readarr, request);
|
||||||
|
|
||||||
|
[HttpPut("whisparr")]
|
||||||
|
public Task<IActionResult> UpdateWhisparrConfig([FromBody] UpdateArrConfigRequest request)
|
||||||
|
=> UpdateArrConfig(InstanceType.Whisparr, request);
|
||||||
|
|
||||||
|
[HttpPost("sonarr/instances")]
|
||||||
|
public Task<IActionResult> CreateSonarrInstance([FromBody] ArrInstanceRequest request)
|
||||||
|
=> CreateArrInstance(InstanceType.Sonarr, request);
|
||||||
|
|
||||||
|
[HttpPut("sonarr/instances/{id}")]
|
||||||
|
public Task<IActionResult> UpdateSonarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||||
|
=> UpdateArrInstance(InstanceType.Sonarr, id, request);
|
||||||
|
|
||||||
|
[HttpDelete("sonarr/instances/{id}")]
|
||||||
|
public Task<IActionResult> DeleteSonarrInstance(Guid id)
|
||||||
|
=> DeleteArrInstance(InstanceType.Sonarr, id);
|
||||||
|
|
||||||
|
[HttpPost("radarr/instances")]
|
||||||
|
public Task<IActionResult> CreateRadarrInstance([FromBody] ArrInstanceRequest request)
|
||||||
|
=> CreateArrInstance(InstanceType.Radarr, request);
|
||||||
|
|
||||||
|
[HttpPut("radarr/instances/{id}")]
|
||||||
|
public Task<IActionResult> UpdateRadarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||||
|
=> UpdateArrInstance(InstanceType.Radarr, id, request);
|
||||||
|
|
||||||
|
[HttpDelete("radarr/instances/{id}")]
|
||||||
|
public Task<IActionResult> DeleteRadarrInstance(Guid id)
|
||||||
|
=> DeleteArrInstance(InstanceType.Radarr, id);
|
||||||
|
|
||||||
|
[HttpPost("lidarr/instances")]
|
||||||
|
public Task<IActionResult> CreateLidarrInstance([FromBody] ArrInstanceRequest request)
|
||||||
|
=> CreateArrInstance(InstanceType.Lidarr, request);
|
||||||
|
|
||||||
|
[HttpPut("lidarr/instances/{id}")]
|
||||||
|
public Task<IActionResult> UpdateLidarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||||
|
=> UpdateArrInstance(InstanceType.Lidarr, id, request);
|
||||||
|
|
||||||
|
[HttpDelete("lidarr/instances/{id}")]
|
||||||
|
public Task<IActionResult> DeleteLidarrInstance(Guid id)
|
||||||
|
=> DeleteArrInstance(InstanceType.Lidarr, id);
|
||||||
|
|
||||||
|
[HttpPost("readarr/instances")]
|
||||||
|
public Task<IActionResult> CreateReadarrInstance([FromBody] ArrInstanceRequest request)
|
||||||
|
=> CreateArrInstance(InstanceType.Readarr, request);
|
||||||
|
|
||||||
|
[HttpPut("readarr/instances/{id}")]
|
||||||
|
public Task<IActionResult> UpdateReadarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||||
|
=> UpdateArrInstance(InstanceType.Readarr, id, request);
|
||||||
|
|
||||||
|
[HttpDelete("readarr/instances/{id}")]
|
||||||
|
public Task<IActionResult> DeleteReadarrInstance(Guid id)
|
||||||
|
=> DeleteArrInstance(InstanceType.Readarr, id);
|
||||||
|
|
||||||
|
[HttpPost("whisparr/instances")]
|
||||||
|
public Task<IActionResult> CreateWhisparrInstance([FromBody] ArrInstanceRequest request)
|
||||||
|
=> CreateArrInstance(InstanceType.Whisparr, request);
|
||||||
|
|
||||||
|
[HttpPut("whisparr/instances/{id}")]
|
||||||
|
public Task<IActionResult> UpdateWhisparrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||||
|
=> UpdateArrInstance(InstanceType.Whisparr, id, request);
|
||||||
|
|
||||||
|
[HttpDelete("whisparr/instances/{id}")]
|
||||||
|
public Task<IActionResult> DeleteWhisparrInstance(Guid id)
|
||||||
|
=> DeleteArrInstance(InstanceType.Whisparr, id);
|
||||||
|
|
||||||
|
[HttpPost("sonarr/instances/test")]
|
||||||
|
public Task<IActionResult> TestSonarrInstance([FromBody] TestArrInstanceRequest request)
|
||||||
|
=> TestArrInstance(InstanceType.Sonarr, request);
|
||||||
|
|
||||||
|
[HttpPost("radarr/instances/test")]
|
||||||
|
public Task<IActionResult> TestRadarrInstance([FromBody] TestArrInstanceRequest request)
|
||||||
|
=> TestArrInstance(InstanceType.Radarr, request);
|
||||||
|
|
||||||
|
[HttpPost("lidarr/instances/test")]
|
||||||
|
public Task<IActionResult> TestLidarrInstance([FromBody] TestArrInstanceRequest request)
|
||||||
|
=> TestArrInstance(InstanceType.Lidarr, request);
|
||||||
|
|
||||||
|
[HttpPost("readarr/instances/test")]
|
||||||
|
public Task<IActionResult> TestReadarrInstance([FromBody] TestArrInstanceRequest request)
|
||||||
|
=> TestArrInstance(InstanceType.Readarr, request);
|
||||||
|
|
||||||
|
[HttpPost("whisparr/instances/test")]
|
||||||
|
public Task<IActionResult> TestWhisparrInstance([FromBody] TestArrInstanceRequest request)
|
||||||
|
=> TestArrInstance(InstanceType.Whisparr, request);
|
||||||
|
|
||||||
|
private async Task<IActionResult> GetArrConfig(InstanceType type)
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = await _dataContext.ArrConfigs
|
||||||
|
.Include(x => x.Instances)
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstAsync(x => x.Type == type);
|
||||||
|
|
||||||
|
config.Instances = config.Instances
|
||||||
|
.OrderBy(i => i.Name)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Ok(config.Adapt<ArrConfigDto>());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> UpdateArrConfig(InstanceType type, UpdateArrConfigRequest request)
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = await _dataContext.ArrConfigs
|
||||||
|
.FirstAsync(x => x.Type == type);
|
||||||
|
|
||||||
|
config.FailedImportMaxStrikes = request.FailedImportMaxStrikes;
|
||||||
|
config.Validate();
|
||||||
|
|
||||||
|
await _dataContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new { Message = $"{type} configuration updated successfully" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save {Type} configuration", type);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> CreateArrInstance(InstanceType type, ArrInstanceRequest request)
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = await _dataContext.ArrConfigs
|
||||||
|
.FirstAsync(x => x.Type == type);
|
||||||
|
|
||||||
|
var instance = request.ToEntity(config.Id);
|
||||||
|
await _dataContext.ArrInstances.AddAsync(instance);
|
||||||
|
await _dataContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return CreatedAtAction(GetConfigActionName(type), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to create {Type} instance", type);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> UpdateArrInstance(InstanceType type, Guid id, ArrInstanceRequest request)
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = await _dataContext.ArrConfigs
|
||||||
|
.Include(c => c.Instances)
|
||||||
|
.FirstAsync(x => x.Type == type);
|
||||||
|
|
||||||
|
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
|
||||||
|
if (instance is null)
|
||||||
|
{
|
||||||
|
return NotFound($"{type} instance with ID {id} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
request.ApplyTo(instance);
|
||||||
|
|
||||||
|
await _dataContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(instance.Adapt<ArrInstanceDto>());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to update {Type} instance with ID {Id}", type, id);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> DeleteArrInstance(InstanceType type, Guid id)
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = await _dataContext.ArrConfigs
|
||||||
|
.Include(c => c.Instances)
|
||||||
|
.FirstAsync(x => x.Type == type);
|
||||||
|
|
||||||
|
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
|
||||||
|
if (instance is null)
|
||||||
|
{
|
||||||
|
return NotFound($"{type} instance with ID {id} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Instances.Remove(instance);
|
||||||
|
await _dataContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to delete {Type} instance with ID {Id}", type, id);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> TestArrInstance(InstanceType type, TestArrInstanceRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var testInstance = request.ToTestInstance();
|
||||||
|
var client = _arrClientFactory.GetClient(type, request.Version);
|
||||||
|
await client.HealthCheckAsync(testInstance);
|
||||||
|
|
||||||
|
return Ok(new { Message = $"Connection to {type} instance successful" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to test {Type} instance connection", type);
|
||||||
|
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetConfigActionName(InstanceType type) => type switch
|
||||||
|
{
|
||||||
|
InstanceType.Sonarr => nameof(GetSonarrConfig),
|
||||||
|
InstanceType.Radarr => nameof(GetRadarrConfig),
|
||||||
|
InstanceType.Lidarr => nameof(GetLidarrConfig),
|
||||||
|
InstanceType.Readarr => nameof(GetReadarrConfig),
|
||||||
|
InstanceType.Whisparr => nameof(GetWhisparrConfig),
|
||||||
|
_ => nameof(GetSonarrConfig),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record ChangePasswordRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required string CurrentPassword { get; init; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MinLength(8)]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public required string NewPassword { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record CreateAccountRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[MinLength(3)]
|
||||||
|
[MaxLength(50)]
|
||||||
|
public required string Username { get; init; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MinLength(8)]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public required string Password { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record LoginRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required string Username { get; init; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public required string Password { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record PlexPinRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required int PinId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record RefreshTokenRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required string RefreshToken { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record Regenerate2faRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required string Password { get; init; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(6, MinimumLength = 6)]
|
||||||
|
public required string TotpCode { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record TwoFactorRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required string LoginToken { get; init; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public required string Code { get; init; }
|
||||||
|
|
||||||
|
public bool IsRecoveryCode { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record VerifyTotpRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[StringLength(6, MinimumLength = 6)]
|
||||||
|
public required string Code { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||||
|
|
||||||
|
public sealed record AccountInfoResponse
|
||||||
|
{
|
||||||
|
public required string Username { get; init; }
|
||||||
|
public required bool PlexLinked { get; init; }
|
||||||
|
public string? PlexUsername { get; init; }
|
||||||
|
public required bool TwoFactorEnabled { get; init; }
|
||||||
|
public required string ApiKeyPreview { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||||
|
|
||||||
|
public sealed record AuthStatusResponse
|
||||||
|
{
|
||||||
|
public required bool SetupCompleted { get; init; }
|
||||||
|
public bool PlexLinked { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||||
|
|
||||||
|
public sealed record LoginResponse
|
||||||
|
{
|
||||||
|
public required bool RequiresTwoFactor { get; init; }
|
||||||
|
public string? LoginToken { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||||
|
|
||||||
|
public sealed record PlexPinStatusResponse
|
||||||
|
{
|
||||||
|
public required int PinId { get; init; }
|
||||||
|
public required string AuthUrl { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PlexVerifyResponse
|
||||||
|
{
|
||||||
|
public required bool Completed { get; init; }
|
||||||
|
public TokenResponse? Tokens { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||||
|
|
||||||
|
public sealed record TokenResponse
|
||||||
|
{
|
||||||
|
public required string AccessToken { get; init; }
|
||||||
|
public required string RefreshToken { get; init; }
|
||||||
|
public required int ExpiresIn { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||||
|
|
||||||
|
public sealed record TotpSetupResponse
|
||||||
|
{
|
||||||
|
public required string Secret { get; init; }
|
||||||
|
public required string QrCodeUri { get; init; }
|
||||||
|
public required List<string> RecoveryCodes { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||||
|
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||||
|
using Cleanuparr.Infrastructure.Features.Auth;
|
||||||
|
using Cleanuparr.Persistence;
|
||||||
|
using Cleanuparr.Persistence.Models.Auth;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Auth.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/account")]
|
||||||
|
[Authorize]
|
||||||
|
public sealed class AccountController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly UsersContext _usersContext;
|
||||||
|
private readonly IPasswordService _passwordService;
|
||||||
|
private readonly ITotpService _totpService;
|
||||||
|
private readonly IPlexAuthService _plexAuthService;
|
||||||
|
private readonly ILogger<AccountController> _logger;
|
||||||
|
|
||||||
|
public AccountController(
|
||||||
|
UsersContext usersContext,
|
||||||
|
IPasswordService passwordService,
|
||||||
|
ITotpService totpService,
|
||||||
|
IPlexAuthService plexAuthService,
|
||||||
|
ILogger<AccountController> logger)
|
||||||
|
{
|
||||||
|
_usersContext = usersContext;
|
||||||
|
_passwordService = passwordService;
|
||||||
|
_totpService = totpService;
|
||||||
|
_plexAuthService = plexAuthService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAccountInfo()
|
||||||
|
{
|
||||||
|
var user = await GetCurrentUser();
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
|
||||||
|
return Ok(new AccountInfoResponse
|
||||||
|
{
|
||||||
|
Username = user.Username,
|
||||||
|
PlexLinked = user.PlexAccountId is not null,
|
||||||
|
PlexUsername = user.PlexUsername,
|
||||||
|
TwoFactorEnabled = user.TotpEnabled,
|
||||||
|
ApiKeyPreview = user.ApiKey[..8] + "..."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("password")]
|
||||||
|
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||||
|
{
|
||||||
|
await UsersContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await GetCurrentUser();
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
|
||||||
|
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Current password is incorrect" });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _usersContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Password changed for user {Username}", user.Username);
|
||||||
|
|
||||||
|
return Ok(new { message = "Password changed" });
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UsersContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("2fa/regenerate")]
|
||||||
|
public async Task<IActionResult> Regenerate2fa([FromBody] Regenerate2faRequest request)
|
||||||
|
{
|
||||||
|
await UsersContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
|
||||||
|
// Verify current credentials
|
||||||
|
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Incorrect password" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Invalid 2FA code" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new TOTP
|
||||||
|
var secret = _totpService.GenerateSecret();
|
||||||
|
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||||
|
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||||
|
|
||||||
|
user.TotpSecret = secret;
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Replace recovery codes
|
||||||
|
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||||
|
|
||||||
|
foreach (var code in recoveryCodes)
|
||||||
|
{
|
||||||
|
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = user.Id,
|
||||||
|
CodeHash = _totpService.HashRecoveryCode(code),
|
||||||
|
IsUsed = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await _usersContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("2FA regenerated for user {Username}", user.Username);
|
||||||
|
|
||||||
|
return Ok(new TotpSetupResponse
|
||||||
|
{
|
||||||
|
Secret = secret,
|
||||||
|
QrCodeUri = qrUri,
|
||||||
|
RecoveryCodes = recoveryCodes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UsersContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("api-key")]
|
||||||
|
public async Task<IActionResult> GetApiKey()
|
||||||
|
{
|
||||||
|
var user = await GetCurrentUser();
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
|
||||||
|
return Ok(new { apiKey = user.ApiKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("api-key/regenerate")]
|
||||||
|
public async Task<IActionResult> RegenerateApiKey()
|
||||||
|
{
|
||||||
|
await UsersContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await GetCurrentUser();
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
|
||||||
|
var bytes = new byte[32];
|
||||||
|
using var rng = RandomNumberGenerator.Create();
|
||||||
|
rng.GetBytes(bytes);
|
||||||
|
|
||||||
|
user.ApiKey = Convert.ToHexString(bytes).ToLowerInvariant();
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _usersContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("API key regenerated for user {Username}", user.Username);
|
||||||
|
|
||||||
|
return Ok(new { apiKey = user.ApiKey });
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UsersContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("plex/link")]
|
||||||
|
public async Task<IActionResult> StartPlexLink()
|
||||||
|
{
|
||||||
|
var pin = await _plexAuthService.RequestPin();
|
||||||
|
|
||||||
|
return Ok(new { pinId = pin.PinId, authUrl = pin.AuthUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("plex/link/verify")]
|
||||||
|
public async Task<IActionResult> VerifyPlexLink([FromBody] PlexPinRequest request)
|
||||||
|
{
|
||||||
|
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||||
|
|
||||||
|
if (!pinResult.Completed || pinResult.AuthToken is null)
|
||||||
|
{
|
||||||
|
return Ok(new { completed = false });
|
||||||
|
}
|
||||||
|
|
||||||
|
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
|
||||||
|
|
||||||
|
await UsersContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await GetCurrentUser();
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
|
||||||
|
user.PlexAccountId = plexAccount.AccountId;
|
||||||
|
user.PlexUsername = plexAccount.Username;
|
||||||
|
user.PlexEmail = plexAccount.Email;
|
||||||
|
user.PlexAuthToken = pinResult.AuthToken;
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _usersContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Plex account linked for user {Username}: {PlexUsername}",
|
||||||
|
user.Username, plexAccount.Username);
|
||||||
|
|
||||||
|
return Ok(new { completed = true, plexUsername = plexAccount.Username });
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UsersContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("plex/link")]
|
||||||
|
public async Task<IActionResult> UnlinkPlex()
|
||||||
|
{
|
||||||
|
await UsersContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await GetCurrentUser();
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
|
||||||
|
user.PlexAccountId = null;
|
||||||
|
user.PlexUsername = null;
|
||||||
|
user.PlexEmail = null;
|
||||||
|
user.PlexAuthToken = null;
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _usersContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Plex account unlinked for user {Username}", user.Username);
|
||||||
|
|
||||||
|
return Ok(new { message = "Plex account unlinked" });
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UsersContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<User?> GetCurrentUser(bool includeRecoveryCodes = false)
|
||||||
|
{
|
||||||
|
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (userIdClaim is null || !Guid.TryParse(userIdClaim, out var userId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = _usersContext.Users.AsQueryable();
|
||||||
|
|
||||||
|
if (includeRecoveryCodes)
|
||||||
|
{
|
||||||
|
query = query.Include(u => u.RecoveryCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.FirstOrDefaultAsync(u => u.Id == userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||||
|
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||||
|
using Cleanuparr.Infrastructure.Features.Auth;
|
||||||
|
using Cleanuparr.Persistence;
|
||||||
|
using Cleanuparr.Persistence.Models.Auth;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Auth.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/auth")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public sealed class AuthController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly UsersContext _usersContext;
|
||||||
|
private readonly IJwtService _jwtService;
|
||||||
|
private readonly IPasswordService _passwordService;
|
||||||
|
private readonly ITotpService _totpService;
|
||||||
|
private readonly IPlexAuthService _plexAuthService;
|
||||||
|
private readonly ILogger<AuthController> _logger;
|
||||||
|
|
||||||
|
public AuthController(
|
||||||
|
UsersContext usersContext,
|
||||||
|
IJwtService jwtService,
|
||||||
|
IPasswordService passwordService,
|
||||||
|
ITotpService totpService,
|
||||||
|
IPlexAuthService plexAuthService,
|
||||||
|
ILogger<AuthController> logger)
|
||||||
|
{
|
||||||
|
_usersContext = usersContext;
|
||||||
|
_jwtService = jwtService;
|
||||||
|
_passwordService = passwordService;
|
||||||
|
_totpService = totpService;
|
||||||
|
_plexAuthService = plexAuthService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("status")]
|
||||||
|
public async Task<IActionResult> GetStatus()
|
||||||
|
{
|
||||||
|
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
return Ok(new AuthStatusResponse
|
||||||
|
{
|
||||||
|
SetupCompleted = user is { SetupCompleted: true },
|
||||||
|
PlexLinked = user?.PlexAccountId is not null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("setup/account")]
|
||||||
|
public async Task<IActionResult> CreateAccount([FromBody] CreateAccountRequest request)
|
||||||
|
{
|
||||||
|
await UsersContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existingUser = await _usersContext.Users.FirstOrDefaultAsync();
|
||||||
|
if (existingUser is not null)
|
||||||
|
{
|
||||||
|
return Conflict(new { error = "Account already exists" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Username = request.Username,
|
||||||
|
PasswordHash = _passwordService.HashPassword(request.Password),
|
||||||
|
TotpSecret = string.Empty,
|
||||||
|
TotpEnabled = false,
|
||||||
|
ApiKey = GenerateApiKey(),
|
||||||
|
SetupCompleted = false,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_usersContext.Users.Add(user);
|
||||||
|
await _usersContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Admin account created for user {Username}", request.Username);
|
||||||
|
|
||||||
|
return Created("", new { userId = user.Id });
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UsersContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("setup/2fa/generate")]
|
||||||
|
public async Task<IActionResult> GenerateTotpSetup()
|
||||||
|
{
|
||||||
|
await UsersContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await _usersContext.Users
|
||||||
|
.Include(u => u.RecoveryCodes)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Create an account first" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.SetupCompleted && user.TotpEnabled)
|
||||||
|
{
|
||||||
|
return Conflict(new { error = "2FA is already configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new TOTP secret
|
||||||
|
var secret = _totpService.GenerateSecret();
|
||||||
|
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||||
|
|
||||||
|
// Generate recovery codes
|
||||||
|
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||||
|
|
||||||
|
// Store secret (will be finalized on verify)
|
||||||
|
user.TotpSecret = secret;
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Remove old recovery codes and add new ones
|
||||||
|
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||||
|
|
||||||
|
foreach (var code in recoveryCodes)
|
||||||
|
{
|
||||||
|
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = user.Id,
|
||||||
|
CodeHash = _totpService.HashRecoveryCode(code),
|
||||||
|
IsUsed = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await _usersContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new TotpSetupResponse
|
||||||
|
{
|
||||||
|
Secret = secret,
|
||||||
|
QrCodeUri = qrUri,
|
||||||
|
RecoveryCodes = recoveryCodes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UsersContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("setup/2fa/verify")]
|
||||||
|
public async Task<IActionResult> VerifyTotpSetup([FromBody] VerifyTotpRequest request)
|
||||||
|
{
|
||||||
|
await UsersContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Create an account first" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Generate 2FA setup first" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
|
||||||
|
{
|
||||||
|
return Unauthorized(new { error = "Invalid verification code" });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.TotpEnabled = true;
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _usersContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
|
||||||
|
|
||||||
|
return Ok(new { message = "2FA verified and enabled" });
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UsersContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("setup/complete")]
|
||||||
|
public async Task<IActionResult> CompleteSetup()
|
||||||
|
{
|
||||||
|
await UsersContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Create an account first" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.TotpEnabled)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "2FA must be configured before completing setup" });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.SetupCompleted = true;
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _usersContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Setup completed for user {Username}", user.Username);
|
||||||
|
|
||||||
|
return Ok(new { message = "Setup complete" });
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UsersContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("login")]
|
||||||
|
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||||
|
{
|
||||||
|
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (user is null || !user.SetupCompleted)
|
||||||
|
{
|
||||||
|
return Unauthorized(new { error = "Invalid credentials" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check lockout
|
||||||
|
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
var remaining = (int)(user.LockoutEnd.Value - DateTime.UtcNow).TotalSeconds;
|
||||||
|
return StatusCode(429, new { error = "Account is locked", retryAfterSeconds = remaining });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash) ||
|
||||||
|
!string.Equals(user.Username, request.Username, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var retryAfterSeconds = await IncrementFailedAttempts(user.Id);
|
||||||
|
return Unauthorized(new { error = "Invalid credentials", retryAfterSeconds });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset failed attempts on successful password verification
|
||||||
|
await ResetFailedAttempts(user.Id);
|
||||||
|
|
||||||
|
// Password valid - require 2FA
|
||||||
|
var loginToken = _jwtService.GenerateLoginToken(user.Id);
|
||||||
|
|
||||||
|
return Ok(new LoginResponse
|
||||||
|
{
|
||||||
|
RequiresTwoFactor = true,
|
||||||
|
LoginToken = loginToken
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("login/2fa")]
|
||||||
|
public async Task<IActionResult> VerifyTwoFactor([FromBody] TwoFactorRequest request)
|
||||||
|
{
|
||||||
|
var userId = _jwtService.ValidateLoginToken(request.LoginToken);
|
||||||
|
if (userId is null)
|
||||||
|
{
|
||||||
|
return Unauthorized(new { error = "Invalid or expired login token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _usersContext.Users
|
||||||
|
.Include(u => u.RecoveryCodes)
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == userId.Value);
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return Unauthorized(new { error = "Invalid login token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
bool codeValid;
|
||||||
|
|
||||||
|
if (request.IsRecoveryCode)
|
||||||
|
{
|
||||||
|
codeValid = await TryUseRecoveryCode(user, request.Code);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
codeValid = _totpService.ValidateCode(user.TotpSecret, request.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!codeValid)
|
||||||
|
{
|
||||||
|
return Unauthorized(new { error = "Invalid verification code" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(await GenerateTokenResponse(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("refresh")]
|
||||||
|
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
|
||||||
|
{
|
||||||
|
await UsersContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tokenHash = HashRefreshToken(request.RefreshToken);
|
||||||
|
|
||||||
|
var storedToken = await _usersContext.RefreshTokens
|
||||||
|
.Include(r => r.User)
|
||||||
|
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash && r.RevokedAt == null);
|
||||||
|
|
||||||
|
if (storedToken is null || storedToken.ExpiresAt < DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
return Unauthorized(new { error = "Invalid or expired refresh token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke the old token (rotation)
|
||||||
|
storedToken.RevokedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Generate new tokens
|
||||||
|
var response = await GenerateTokenResponse(storedToken.User);
|
||||||
|
await _usersContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UsersContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("logout")]
|
||||||
|
public async Task<IActionResult> Logout([FromBody] RefreshTokenRequest request)
|
||||||
|
{
|
||||||
|
await UsersContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tokenHash = HashRefreshToken(request.RefreshToken);
|
||||||
|
|
||||||
|
var storedToken = await _usersContext.RefreshTokens
|
||||||
|
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash && r.RevokedAt == null);
|
||||||
|
|
||||||
|
if (storedToken is not null)
|
||||||
|
{
|
||||||
|
storedToken.RevokedAt = DateTime.UtcNow;
|
||||||
|
await _usersContext.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { message = "Logged out" });
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UsersContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("setup/plex/pin")]
|
||||||
|
public async Task<IActionResult> RequestSetupPlexPin()
|
||||||
|
{
|
||||||
|
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Create an account first" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var pin = await _plexAuthService.RequestPin();
|
||||||
|
|
||||||
|
return Ok(new PlexPinStatusResponse
|
||||||
|
{
|
||||||
|
PinId = pin.PinId,
|
||||||
|
AuthUrl = pin.AuthUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("setup/plex/verify")]
|
||||||
|
public async Task<IActionResult> VerifySetupPlexLink([FromBody] PlexPinRequest request)
|
||||||
|
{
|
||||||
|
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||||
|
|
||||||
|
if (!pinResult.Completed || pinResult.AuthToken is null)
|
||||||
|
{
|
||||||
|
return Ok(new PlexVerifyResponse { Completed = false });
|
||||||
|
}
|
||||||
|
|
||||||
|
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
|
||||||
|
|
||||||
|
await UsersContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Create an account first" });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.PlexAccountId = plexAccount.AccountId;
|
||||||
|
user.PlexUsername = plexAccount.Username;
|
||||||
|
user.PlexEmail = plexAccount.Email;
|
||||||
|
user.PlexAuthToken = pinResult.AuthToken;
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _usersContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Plex account linked during setup for user {Username}: {PlexUsername}",
|
||||||
|
user.Username, plexAccount.Username);
|
||||||
|
|
||||||
|
return Ok(new PlexVerifyResponse { Completed = true });
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UsersContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("login/plex/pin")]
|
||||||
|
public async Task<IActionResult> RequestPlexPin()
|
||||||
|
{
|
||||||
|
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||||
|
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Plex login is not available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var pin = await _plexAuthService.RequestPin();
|
||||||
|
|
||||||
|
return Ok(new PlexPinStatusResponse
|
||||||
|
{
|
||||||
|
PinId = pin.PinId,
|
||||||
|
AuthUrl = pin.AuthUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("login/plex/verify")]
|
||||||
|
public async Task<IActionResult> VerifyPlexLogin([FromBody] PlexPinRequest request)
|
||||||
|
{
|
||||||
|
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||||
|
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Plex login is not available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||||
|
|
||||||
|
if (!pinResult.Completed || pinResult.AuthToken is null)
|
||||||
|
{
|
||||||
|
return Ok(new PlexVerifyResponse { Completed = false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the Plex account matches the linked one
|
||||||
|
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
|
||||||
|
|
||||||
|
if (plexAccount.AccountId != user.PlexAccountId)
|
||||||
|
{
|
||||||
|
return Unauthorized(new { error = "Plex account does not match the linked account" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plex login bypasses 2FA
|
||||||
|
_logger.LogInformation("User {Username} logged in via Plex", user.Username);
|
||||||
|
|
||||||
|
var tokenResponse = await GenerateTokenResponse(user);
|
||||||
|
|
||||||
|
return Ok(new PlexVerifyResponse
|
||||||
|
{
|
||||||
|
Completed = true,
|
||||||
|
Tokens = tokenResponse
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TokenResponse> GenerateTokenResponse(User user)
|
||||||
|
{
|
||||||
|
var accessToken = _jwtService.GenerateAccessToken(user);
|
||||||
|
var refreshToken = _jwtService.GenerateRefreshToken();
|
||||||
|
|
||||||
|
_usersContext.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = user.Id,
|
||||||
|
TokenHash = HashRefreshToken(refreshToken),
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddDays(7),
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
await _usersContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new TokenResponse
|
||||||
|
{
|
||||||
|
AccessToken = accessToken,
|
||||||
|
RefreshToken = refreshToken,
|
||||||
|
ExpiresIn = 60 // seconds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TryUseRecoveryCode(User user, string code)
|
||||||
|
{
|
||||||
|
await UsersContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var recoveryCode in user.RecoveryCodes.Where(r => !r.IsUsed))
|
||||||
|
{
|
||||||
|
if (_totpService.VerifyRecoveryCode(code, recoveryCode.CodeHash))
|
||||||
|
{
|
||||||
|
recoveryCode.IsUsed = true;
|
||||||
|
recoveryCode.UsedAt = DateTime.UtcNow;
|
||||||
|
await _usersContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogWarning("Recovery code used for user {Username}", user.Username);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UsersContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> IncrementFailedAttempts(Guid userId)
|
||||||
|
{
|
||||||
|
await UsersContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
|
||||||
|
user.FailedLoginAttempts++;
|
||||||
|
user.LockoutEnd = DateTime.UtcNow.AddSeconds(user.FailedLoginAttempts * 2);
|
||||||
|
await _usersContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogWarning("Failed login attempt {Attempts} for user {Username}, locked for {Seconds}s",
|
||||||
|
user.FailedLoginAttempts, user.Username, user.FailedLoginAttempts * 2);
|
||||||
|
|
||||||
|
return user.FailedLoginAttempts * 2;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UsersContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResetFailedAttempts(Guid userId)
|
||||||
|
{
|
||||||
|
await UsersContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
|
||||||
|
user.FailedLoginAttempts = 0;
|
||||||
|
user.LockoutEnd = null;
|
||||||
|
await _usersContext.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UsersContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateApiKey()
|
||||||
|
{
|
||||||
|
var bytes = new byte[32];
|
||||||
|
using var rng = RandomNumberGenerator.Create();
|
||||||
|
rng.GetBytes(bytes);
|
||||||
|
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HashRefreshToken(string token)
|
||||||
|
{
|
||||||
|
var bytes = System.Text.Encoding.UTF8.GetBytes(token);
|
||||||
|
var hash = SHA256.HashData(bytes);
|
||||||
|
return Convert.ToBase64String(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record UpdateBlacklistSyncConfigRequest
|
||||||
|
{
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
public string? BlacklistPath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the request to the provided configuration instance.
|
||||||
|
/// </summary>
|
||||||
|
public BlacklistSyncConfig ApplyTo(BlacklistSyncConfig config)
|
||||||
|
{
|
||||||
|
config.Enabled = Enabled;
|
||||||
|
config.BlacklistPath = BlacklistPath;
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasPathChanged(string? currentPath)
|
||||||
|
=> !string.Equals(currentPath, BlacklistPath, StringComparison.InvariantCultureIgnoreCase);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
using Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
|
||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||||
|
using Cleanuparr.Persistence;
|
||||||
|
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.BlacklistSync.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/configuration")]
|
||||||
|
public sealed class BlacklistSyncConfigController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<BlacklistSyncConfigController> _logger;
|
||||||
|
private readonly DataContext _dataContext;
|
||||||
|
private readonly IJobManagementService _jobManagementService;
|
||||||
|
|
||||||
|
public BlacklistSyncConfigController(
|
||||||
|
ILogger<BlacklistSyncConfigController> logger,
|
||||||
|
DataContext dataContext,
|
||||||
|
IJobManagementService jobManagementService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_dataContext = dataContext;
|
||||||
|
_jobManagementService = jobManagementService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("blacklist_sync")]
|
||||||
|
public async Task<IActionResult> GetBlacklistSyncConfig()
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = await _dataContext.BlacklistSyncConfigs
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstAsync();
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("blacklist_sync")]
|
||||||
|
public async Task<IActionResult> UpdateBlacklistSyncConfig([FromBody] UpdateBlacklistSyncConfigRequest request)
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = await _dataContext.BlacklistSyncConfigs
|
||||||
|
.FirstAsync();
|
||||||
|
|
||||||
|
bool enabledChanged = config.Enabled != request.Enabled;
|
||||||
|
bool becameEnabled = !config.Enabled && request.Enabled;
|
||||||
|
bool pathChanged = request.HasPathChanged(config.BlacklistPath);
|
||||||
|
|
||||||
|
request.ApplyTo(config);
|
||||||
|
config.Validate();
|
||||||
|
|
||||||
|
await _dataContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (enabledChanged)
|
||||||
|
{
|
||||||
|
if (becameEnabled)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("BlacklistSynchronizer enabled, starting job");
|
||||||
|
await _jobManagementService.StartJob(JobType.BlacklistSynchronizer, null, config.CronExpression);
|
||||||
|
await _jobManagementService.TriggerJobOnce(JobType.BlacklistSynchronizer);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation("BlacklistSynchronizer disabled, stopping the job");
|
||||||
|
await _jobManagementService.StopJob(JobType.BlacklistSynchronizer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (pathChanged && config.Enabled)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("BlacklistSynchronizer path changed");
|
||||||
|
await _jobManagementService.TriggerJobOnce(JobType.BlacklistSynchronizer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { Message = "BlacklistSynchronizer configuration updated successfully" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save BlacklistSync configuration");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||||
|
|
||||||
|
public record SeedingRuleRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Max ratio before removing a download.
|
||||||
|
/// </summary>
|
||||||
|
public double MaxRatio { get; init; } = -1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Min number of hours to seed before removing a download, if the ratio has been met.
|
||||||
|
/// </summary>
|
||||||
|
public double MinSeedTime { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of hours to seed before removing a download.
|
||||||
|
/// </summary>
|
||||||
|
public double MaxSeedTime { get; init; } = -1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to delete the source files when cleaning the download.
|
||||||
|
/// </summary>
|
||||||
|
public bool DeleteSourceFiles { get; init; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record UpdateDownloadCleanerConfigRequest
|
||||||
|
{
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
public string CronExpression { get; init; } = "0 0 * * * ?";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates whether to use the CronExpression directly or convert from a user-friendly schedule.
|
||||||
|
/// </summary>
|
||||||
|
public bool UseAdvancedScheduling { get; init; }
|
||||||
|
|
||||||
|
public List<SeedingRuleRequest> Categories { get; init; } = [];
|
||||||
|
|
||||||
|
public bool DeletePrivate { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates whether unlinked download handling is enabled.
|
||||||
|
/// </summary>
|
||||||
|
public bool UnlinkedEnabled { get; init; }
|
||||||
|
|
||||||
|
public string UnlinkedTargetCategory { get; init; } = "cleanuparr-unlinked";
|
||||||
|
|
||||||
|
public bool UnlinkedUseTag { get; init; }
|
||||||
|
|
||||||
|
public List<string> UnlinkedIgnoredRootDirs { get; init; } = [];
|
||||||
|
|
||||||
|
public List<string> UnlinkedCategories { get; init; } = [];
|
||||||
|
|
||||||
|
public List<string> IgnoredDownloads { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||||
|
using Cleanuparr.Infrastructure.Utilities;
|
||||||
|
using Cleanuparr.Persistence;
|
||||||
|
using Cleanuparr.Persistence.Models.Configuration;
|
||||||
|
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/configuration")]
|
||||||
|
public sealed class DownloadCleanerConfigController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<DownloadCleanerConfigController> _logger;
|
||||||
|
private readonly DataContext _dataContext;
|
||||||
|
private readonly IJobManagementService _jobManagementService;
|
||||||
|
|
||||||
|
public DownloadCleanerConfigController(
|
||||||
|
ILogger<DownloadCleanerConfigController> logger,
|
||||||
|
DataContext dataContext,
|
||||||
|
IJobManagementService jobManagementService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_dataContext = dataContext;
|
||||||
|
_jobManagementService = jobManagementService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("download_cleaner")]
|
||||||
|
public async Task<IActionResult> GetDownloadCleanerConfig()
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = await _dataContext.DownloadCleanerConfigs
|
||||||
|
.Include(x => x.Categories)
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstAsync();
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("download_cleaner")]
|
||||||
|
public async Task<IActionResult> UpdateDownloadCleanerConfig([FromBody] UpdateDownloadCleanerConfigRequest newConfigDto)
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (newConfigDto is null)
|
||||||
|
{
|
||||||
|
throw new ValidationException("Request body cannot be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate cron expression format
|
||||||
|
if (!string.IsNullOrEmpty(newConfigDto.CronExpression))
|
||||||
|
{
|
||||||
|
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing configuration
|
||||||
|
var oldConfig = await _dataContext.DownloadCleanerConfigs
|
||||||
|
.Include(x => x.Categories)
|
||||||
|
.FirstAsync();
|
||||||
|
|
||||||
|
oldConfig.Enabled = newConfigDto.Enabled;
|
||||||
|
oldConfig.CronExpression = newConfigDto.CronExpression;
|
||||||
|
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
|
||||||
|
oldConfig.DeletePrivate = newConfigDto.DeletePrivate;
|
||||||
|
oldConfig.UnlinkedEnabled = newConfigDto.UnlinkedEnabled;
|
||||||
|
oldConfig.UnlinkedTargetCategory = newConfigDto.UnlinkedTargetCategory;
|
||||||
|
oldConfig.UnlinkedUseTag = newConfigDto.UnlinkedUseTag;
|
||||||
|
oldConfig.UnlinkedIgnoredRootDirs = newConfigDto.UnlinkedIgnoredRootDirs;
|
||||||
|
oldConfig.UnlinkedCategories = newConfigDto.UnlinkedCategories;
|
||||||
|
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
|
||||||
|
oldConfig.Categories.Clear();
|
||||||
|
|
||||||
|
_dataContext.SeedingRules.RemoveRange(oldConfig.Categories);
|
||||||
|
_dataContext.DownloadCleanerConfigs.Update(oldConfig);
|
||||||
|
|
||||||
|
foreach (var categoryDto in newConfigDto.Categories)
|
||||||
|
{
|
||||||
|
_dataContext.SeedingRules.Add(new SeedingRule
|
||||||
|
{
|
||||||
|
Name = categoryDto.Name,
|
||||||
|
MaxRatio = categoryDto.MaxRatio,
|
||||||
|
MinSeedTime = categoryDto.MinSeedTime,
|
||||||
|
MaxSeedTime = categoryDto.MaxSeedTime,
|
||||||
|
DeleteSourceFiles = categoryDto.DeleteSourceFiles,
|
||||||
|
DownloadCleanerConfigId = oldConfig.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
oldConfig.Validate();
|
||||||
|
|
||||||
|
await _dataContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
await UpdateJobSchedule(oldConfig, JobType.DownloadCleaner);
|
||||||
|
|
||||||
|
return Ok(new { Message = "DownloadCleaner configuration updated successfully" });
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save DownloadCleaner configuration");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
|
||||||
|
{
|
||||||
|
if (config.Enabled)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(config.CronExpression))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("{name} is enabled, updating job schedule with cron expression: {CronExpression}",
|
||||||
|
jobType.ToString(), config.CronExpression);
|
||||||
|
|
||||||
|
await _jobManagementService.StartJob(jobType, null, config.CronExpression);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("{name} is enabled, but no cron expression was found in the configuration", jobType.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("{name} is disabled, stopping the job", jobType.ToString());
|
||||||
|
await _jobManagementService.StopJob(jobType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
using Cleanuparr.Domain.Exceptions;
|
||||||
|
using Cleanuparr.Persistence.Models.Configuration;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record CreateDownloadClientRequest
|
||||||
|
{
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public DownloadClientTypeName TypeName { get; init; }
|
||||||
|
|
||||||
|
public DownloadClientType Type { get; init; }
|
||||||
|
|
||||||
|
public string? Host { get; init; }
|
||||||
|
|
||||||
|
public string? Username { get; init; }
|
||||||
|
|
||||||
|
public string? Password { get; init; }
|
||||||
|
|
||||||
|
public string? UrlBase { get; init; }
|
||||||
|
|
||||||
|
public string? ExternalUrl { get; init; }
|
||||||
|
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Name))
|
||||||
|
{
|
||||||
|
throw new ValidationException("Client name cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(Host))
|
||||||
|
{
|
||||||
|
throw new ValidationException("Host cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(Host, UriKind.RelativeOrAbsolute, out _))
|
||||||
|
{
|
||||||
|
throw new ValidationException("Host is not a valid URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(ExternalUrl) && !Uri.TryCreate(ExternalUrl, UriKind.RelativeOrAbsolute, out _))
|
||||||
|
{
|
||||||
|
throw new ValidationException("External URL is not a valid URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DownloadClientConfig ToEntity() => new()
|
||||||
|
{
|
||||||
|
Enabled = Enabled,
|
||||||
|
Name = Name,
|
||||||
|
TypeName = TypeName,
|
||||||
|
Type = Type,
|
||||||
|
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
|
||||||
|
Username = Username,
|
||||||
|
Password = Password,
|
||||||
|
UrlBase = UrlBase,
|
||||||
|
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
using Cleanuparr.Domain.Exceptions;
|
||||||
|
using Cleanuparr.Persistence.Models.Configuration;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record TestDownloadClientRequest
|
||||||
|
{
|
||||||
|
public DownloadClientTypeName TypeName { get; init; }
|
||||||
|
|
||||||
|
public DownloadClientType Type { get; init; }
|
||||||
|
|
||||||
|
public string? Host { get; init; }
|
||||||
|
|
||||||
|
public string? Username { get; init; }
|
||||||
|
|
||||||
|
public string? Password { get; init; }
|
||||||
|
|
||||||
|
public string? UrlBase { get; init; }
|
||||||
|
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Host))
|
||||||
|
{
|
||||||
|
throw new ValidationException("Host cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(Host, UriKind.RelativeOrAbsolute, out _))
|
||||||
|
{
|
||||||
|
throw new ValidationException("Host is not a valid URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DownloadClientConfig ToTestConfig() => new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Enabled = true,
|
||||||
|
Name = "Test Client",
|
||||||
|
TypeName = TypeName,
|
||||||
|
Type = Type,
|
||||||
|
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
|
||||||
|
Username = Username,
|
||||||
|
Password = Password,
|
||||||
|
UrlBase = UrlBase,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
using Cleanuparr.Domain.Exceptions;
|
||||||
|
using Cleanuparr.Persistence.Models.Configuration;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record UpdateDownloadClientRequest
|
||||||
|
{
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public DownloadClientTypeName TypeName { get; init; }
|
||||||
|
|
||||||
|
public DownloadClientType Type { get; init; }
|
||||||
|
|
||||||
|
public string? Host { get; init; }
|
||||||
|
|
||||||
|
public string? Username { get; init; }
|
||||||
|
|
||||||
|
public string? Password { get; init; }
|
||||||
|
|
||||||
|
public string? UrlBase { get; init; }
|
||||||
|
|
||||||
|
public string? ExternalUrl { get; init; }
|
||||||
|
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Name))
|
||||||
|
{
|
||||||
|
throw new ValidationException("Client name cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(Host))
|
||||||
|
{
|
||||||
|
throw new ValidationException("Host cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(Host, UriKind.RelativeOrAbsolute, out _))
|
||||||
|
{
|
||||||
|
throw new ValidationException("Host is not a valid URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(ExternalUrl) && !Uri.TryCreate(ExternalUrl, UriKind.RelativeOrAbsolute, out _))
|
||||||
|
{
|
||||||
|
throw new ValidationException("External URL is not a valid URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DownloadClientConfig ApplyTo(DownloadClientConfig existing) => existing with
|
||||||
|
{
|
||||||
|
Enabled = Enabled,
|
||||||
|
Name = Name,
|
||||||
|
TypeName = TypeName,
|
||||||
|
Type = Type,
|
||||||
|
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
|
||||||
|
Username = Username,
|
||||||
|
Password = Password,
|
||||||
|
UrlBase = UrlBase,
|
||||||
|
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||||
|
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||||
|
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||||
|
using Cleanuparr.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.DownloadClient.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/configuration")]
|
||||||
|
public sealed class DownloadClientController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<DownloadClientController> _logger;
|
||||||
|
private readonly DataContext _dataContext;
|
||||||
|
private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory;
|
||||||
|
private readonly IDownloadServiceFactory _downloadServiceFactory;
|
||||||
|
|
||||||
|
public DownloadClientController(
|
||||||
|
ILogger<DownloadClientController> logger,
|
||||||
|
DataContext dataContext,
|
||||||
|
IDynamicHttpClientFactory dynamicHttpClientFactory,
|
||||||
|
IDownloadServiceFactory downloadServiceFactory)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_dataContext = dataContext;
|
||||||
|
_dynamicHttpClientFactory = dynamicHttpClientFactory;
|
||||||
|
_downloadServiceFactory = downloadServiceFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("download_client")]
|
||||||
|
public async Task<IActionResult> GetDownloadClientConfig()
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var clients = await _dataContext.DownloadClients
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
clients = clients
|
||||||
|
.OrderBy(c => c.TypeName)
|
||||||
|
.ThenBy(c => c.Name)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Ok(new { clients });
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("download_client")]
|
||||||
|
public async Task<IActionResult> CreateDownloadClientConfig([FromBody] CreateDownloadClientRequest newClient)
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
newClient.Validate();
|
||||||
|
|
||||||
|
var clientConfig = newClient.ToEntity();
|
||||||
|
|
||||||
|
_dataContext.DownloadClients.Add(clientConfig);
|
||||||
|
await _dataContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return CreatedAtAction(nameof(GetDownloadClientConfig), new { id = clientConfig.Id }, clientConfig);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to create download client");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("download_client/{id}")]
|
||||||
|
public async Task<IActionResult> UpdateDownloadClientConfig(Guid id, [FromBody] UpdateDownloadClientRequest updatedClient)
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
updatedClient.Validate();
|
||||||
|
|
||||||
|
var existingClient = await _dataContext.DownloadClients
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == id);
|
||||||
|
|
||||||
|
if (existingClient is null)
|
||||||
|
{
|
||||||
|
return NotFound($"Download client with ID {id} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientToPersist = updatedClient.ApplyTo(existingClient);
|
||||||
|
|
||||||
|
_dataContext.Entry(existingClient).CurrentValues.SetValues(clientToPersist);
|
||||||
|
await _dataContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(clientToPersist);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to update download client with ID {Id}", id);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("download_client/{id}")]
|
||||||
|
public async Task<IActionResult> DeleteDownloadClientConfig(Guid id)
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existingClient = await _dataContext.DownloadClients
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == id);
|
||||||
|
|
||||||
|
if (existingClient is null)
|
||||||
|
{
|
||||||
|
return NotFound($"Download client with ID {id} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
_dataContext.DownloadClients.Remove(existingClient);
|
||||||
|
await _dataContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var clientName = $"DownloadClient_{id}";
|
||||||
|
_dynamicHttpClientFactory.UnregisterConfiguration(clientName);
|
||||||
|
|
||||||
|
_logger.LogInformation("Removed HTTP client configuration for deleted download client {ClientName}", clientName);
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to delete download client with ID {Id}", id);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("download_client/test")]
|
||||||
|
public async Task<IActionResult> TestDownloadClient([FromBody] TestDownloadClientRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
request.Validate();
|
||||||
|
|
||||||
|
var testConfig = request.ToTestConfig();
|
||||||
|
using var downloadService = _downloadServiceFactory.GetDownloadService(testConfig);
|
||||||
|
var healthResult = await downloadService.HealthCheckAsync();
|
||||||
|
|
||||||
|
if (healthResult.IsHealthy)
|
||||||
|
{
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Message = $"Connection to {request.TypeName} successful",
|
||||||
|
ResponseTime = healthResult.ResponseTime.TotalMilliseconds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return BadRequest(new { Message = healthResult.ErrorMessage ?? "Connection failed" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to test {TypeName} client connection", request.TypeName);
|
||||||
|
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||||
|
using Cleanuparr.Infrastructure.Logging;
|
||||||
|
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||||
|
using Cleanuparr.Shared.Helpers;
|
||||||
|
using Serilog.Events;
|
||||||
|
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record UpdateGeneralConfigRequest
|
||||||
|
{
|
||||||
|
public bool DisplaySupportBanner { get; init; } = true;
|
||||||
|
|
||||||
|
public bool DryRun { get; init; }
|
||||||
|
|
||||||
|
public ushort HttpMaxRetries { get; init; }
|
||||||
|
|
||||||
|
public ushort HttpTimeout { get; init; } = 100;
|
||||||
|
|
||||||
|
public CertificateValidationType HttpCertificateValidation { get; init; } = CertificateValidationType.Enabled;
|
||||||
|
|
||||||
|
public bool SearchEnabled { get; init; } = true;
|
||||||
|
|
||||||
|
public ushort SearchDelay { get; init; } = Constants.DefaultSearchDelaySeconds;
|
||||||
|
|
||||||
|
public bool StatusCheckEnabled { get; init; } = true;
|
||||||
|
|
||||||
|
public string EncryptionKey { get; init; } = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
public List<string> IgnoredDownloads { get; init; } = [];
|
||||||
|
|
||||||
|
public ushort StrikeInactivityWindowHours { get; init; } = 24;
|
||||||
|
|
||||||
|
public UpdateLoggingConfigRequest Log { get; init; } = new();
|
||||||
|
|
||||||
|
public GeneralConfig ApplyTo(GeneralConfig existingConfig, IServiceProvider services, ILogger logger)
|
||||||
|
{
|
||||||
|
existingConfig.DisplaySupportBanner = DisplaySupportBanner;
|
||||||
|
existingConfig.DryRun = DryRun;
|
||||||
|
existingConfig.HttpMaxRetries = HttpMaxRetries;
|
||||||
|
existingConfig.HttpTimeout = HttpTimeout;
|
||||||
|
existingConfig.HttpCertificateValidation = HttpCertificateValidation;
|
||||||
|
existingConfig.SearchEnabled = SearchEnabled;
|
||||||
|
existingConfig.SearchDelay = SearchDelay;
|
||||||
|
existingConfig.StatusCheckEnabled = StatusCheckEnabled;
|
||||||
|
existingConfig.EncryptionKey = EncryptionKey;
|
||||||
|
existingConfig.IgnoredDownloads = IgnoredDownloads;
|
||||||
|
existingConfig.StrikeInactivityWindowHours = StrikeInactivityWindowHours;
|
||||||
|
|
||||||
|
bool loggingChanged = Log.ApplyTo(existingConfig.Log);
|
||||||
|
|
||||||
|
Validate(existingConfig);
|
||||||
|
|
||||||
|
ApplySideEffects(existingConfig, services, logger, loggingChanged);
|
||||||
|
|
||||||
|
return existingConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Validate(GeneralConfig config)
|
||||||
|
{
|
||||||
|
if (config.HttpTimeout is 0)
|
||||||
|
{
|
||||||
|
throw new ValidationException("HTTP_TIMEOUT must be greater than 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.StrikeInactivityWindowHours is 0)
|
||||||
|
{
|
||||||
|
throw new ValidationException("STRIKE_INACTIVITY_WINDOW_HOURS must be greater than 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.StrikeInactivityWindowHours > 168)
|
||||||
|
{
|
||||||
|
throw new ValidationException("STRIKE_INACTIVITY_WINDOW_HOURS must be less than or equal to 168");
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Log.Validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplySideEffects(GeneralConfig config, IServiceProvider services, ILogger logger, bool loggingChanged)
|
||||||
|
{
|
||||||
|
var dynamicHttpClientFactory = services.GetRequiredService<IDynamicHttpClientFactory>();
|
||||||
|
dynamicHttpClientFactory.UpdateAllClientsFromGeneralConfig(config);
|
||||||
|
|
||||||
|
logger.LogInformation("Updated all HTTP client configurations with new general settings");
|
||||||
|
|
||||||
|
if (!loggingChanged)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Log.LevelOnlyChange)
|
||||||
|
{
|
||||||
|
logger.LogCritical("Setting global log level to {level}", config.Log.Level);
|
||||||
|
LoggingConfigManager.SetLogLevel(config.Log.Level);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogCritical("Reconfiguring logger due to configuration changes");
|
||||||
|
LoggingConfigManager.ReconfigureLogging(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UpdateLoggingConfigRequest
|
||||||
|
{
|
||||||
|
public LogEventLevel Level { get; init; } = LogEventLevel.Information;
|
||||||
|
|
||||||
|
public ushort RollingSizeMB { get; init; } = 10;
|
||||||
|
|
||||||
|
public ushort RetainedFileCount { get; init; } = 5;
|
||||||
|
|
||||||
|
public ushort TimeLimitHours { get; init; } = 24;
|
||||||
|
|
||||||
|
public bool ArchiveEnabled { get; init; } = true;
|
||||||
|
|
||||||
|
public ushort ArchiveRetainedCount { get; init; } = 60;
|
||||||
|
|
||||||
|
public ushort ArchiveTimeLimitHours { get; init; } = 24 * 30;
|
||||||
|
|
||||||
|
public bool ApplyTo(LoggingConfig existingConfig)
|
||||||
|
{
|
||||||
|
bool levelChanged = existingConfig.Level != Level;
|
||||||
|
bool otherPropertiesChanged =
|
||||||
|
existingConfig.RollingSizeMB != RollingSizeMB ||
|
||||||
|
existingConfig.RetainedFileCount != RetainedFileCount ||
|
||||||
|
existingConfig.TimeLimitHours != TimeLimitHours ||
|
||||||
|
existingConfig.ArchiveEnabled != ArchiveEnabled ||
|
||||||
|
existingConfig.ArchiveRetainedCount != ArchiveRetainedCount ||
|
||||||
|
existingConfig.ArchiveTimeLimitHours != ArchiveTimeLimitHours;
|
||||||
|
|
||||||
|
existingConfig.Level = Level;
|
||||||
|
existingConfig.RollingSizeMB = RollingSizeMB;
|
||||||
|
existingConfig.RetainedFileCount = RetainedFileCount;
|
||||||
|
existingConfig.TimeLimitHours = TimeLimitHours;
|
||||||
|
existingConfig.ArchiveEnabled = ArchiveEnabled;
|
||||||
|
existingConfig.ArchiveRetainedCount = ArchiveRetainedCount;
|
||||||
|
existingConfig.ArchiveTimeLimitHours = ArchiveTimeLimitHours;
|
||||||
|
|
||||||
|
existingConfig.Validate();
|
||||||
|
|
||||||
|
LevelOnlyChange = levelChanged && !otherPropertiesChanged;
|
||||||
|
|
||||||
|
return levelChanged || otherPropertiesChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool LevelOnlyChange { get; private set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
using Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
using Cleanuparr.Persistence;
|
||||||
|
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.General.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/configuration")]
|
||||||
|
public sealed class GeneralConfigController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<GeneralConfigController> _logger;
|
||||||
|
private readonly DataContext _dataContext;
|
||||||
|
private readonly MemoryCache _cache;
|
||||||
|
|
||||||
|
public GeneralConfigController(
|
||||||
|
ILogger<GeneralConfigController> logger,
|
||||||
|
DataContext dataContext,
|
||||||
|
MemoryCache cache)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_dataContext = dataContext;
|
||||||
|
_cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("general")]
|
||||||
|
public async Task<IActionResult> GetGeneralConfig()
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = await _dataContext.GeneralConfigs
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstAsync();
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("general")]
|
||||||
|
public async Task<IActionResult> UpdateGeneralConfig([FromBody] UpdateGeneralConfigRequest request)
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = await _dataContext.GeneralConfigs
|
||||||
|
.FirstAsync();
|
||||||
|
|
||||||
|
bool wasDryRun = config.DryRun;
|
||||||
|
|
||||||
|
request.ApplyTo(config, HttpContext.RequestServices, _logger);
|
||||||
|
|
||||||
|
await _dataContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
ClearStrikesCacheIfNeeded(wasDryRun, config.DryRun);
|
||||||
|
|
||||||
|
return Ok(new { Message = "General configuration updated successfully" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save General configuration");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("strikes/purge")]
|
||||||
|
public async Task<IActionResult> PurgeAllStrikes(
|
||||||
|
[FromServices] EventsContext eventsContext)
|
||||||
|
{
|
||||||
|
var deletedStrikes = await eventsContext.Strikes.ExecuteDeleteAsync();
|
||||||
|
var deletedItems = await eventsContext.DownloadItems
|
||||||
|
.Where(d => !d.Strikes.Any())
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
_logger.LogWarning("Purged all strikes: {strikes} strikes, {items} download items removed",
|
||||||
|
deletedStrikes, deletedItems);
|
||||||
|
|
||||||
|
return Ok(new { DeletedStrikes = deletedStrikes, DeletedItems = deletedItems });
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearStrikesCacheIfNeeded(bool wasDryRun, bool isDryRun)
|
||||||
|
{
|
||||||
|
if (!wasDryRun || isDryRun)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<object> keys;
|
||||||
|
|
||||||
|
// Remove strikes
|
||||||
|
foreach (string strikeType in Enum.GetNames(typeof(StrikeType)))
|
||||||
|
{
|
||||||
|
keys = _cache.Keys
|
||||||
|
.Where(key => key.ToString()?.StartsWith(strikeType, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (object key in keys)
|
||||||
|
{
|
||||||
|
_cache.Remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogTrace("Removed all cache entries for strike type: {StrikeType}", strikeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove strike cache items
|
||||||
|
keys = _cache.Keys
|
||||||
|
.Where(key => key.ToString()?.StartsWith("item_", StringComparison.InvariantCultureIgnoreCase) is true)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (object key in keys)
|
||||||
|
{
|
||||||
|
_cache.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record UpdateMalwareBlockerConfigRequest
|
||||||
|
{
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
public string CronExpression { get; init; } = "0/5 * * * * ?";
|
||||||
|
|
||||||
|
public bool UseAdvancedScheduling { get; init; }
|
||||||
|
|
||||||
|
public bool IgnorePrivate { get; init; }
|
||||||
|
|
||||||
|
public bool DeletePrivate { get; init; }
|
||||||
|
|
||||||
|
public bool DeleteKnownMalware { get; init; }
|
||||||
|
|
||||||
|
public BlocklistSettings Sonarr { get; init; } = new();
|
||||||
|
|
||||||
|
public BlocklistSettings Radarr { get; init; } = new();
|
||||||
|
|
||||||
|
public BlocklistSettings Lidarr { get; init; } = new();
|
||||||
|
|
||||||
|
public BlocklistSettings Readarr { get; init; } = new();
|
||||||
|
|
||||||
|
public BlocklistSettings Whisparr { get; init; } = new();
|
||||||
|
|
||||||
|
public List<string> IgnoredDownloads { get; init; } = [];
|
||||||
|
|
||||||
|
public ContentBlockerConfig ApplyTo(ContentBlockerConfig config)
|
||||||
|
{
|
||||||
|
config.Enabled = Enabled;
|
||||||
|
config.CronExpression = CronExpression;
|
||||||
|
config.UseAdvancedScheduling = UseAdvancedScheduling;
|
||||||
|
config.IgnorePrivate = IgnorePrivate;
|
||||||
|
config.DeletePrivate = DeletePrivate;
|
||||||
|
config.DeleteKnownMalware = DeleteKnownMalware;
|
||||||
|
config.Sonarr = Sonarr;
|
||||||
|
config.Radarr = Radarr;
|
||||||
|
config.Lidarr = Lidarr;
|
||||||
|
config.Readarr = Readarr;
|
||||||
|
config.Whisparr = Whisparr;
|
||||||
|
config.IgnoredDownloads = IgnoredDownloads;
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
using Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
|
||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||||
|
using Cleanuparr.Infrastructure.Utilities;
|
||||||
|
using Cleanuparr.Persistence;
|
||||||
|
using Cleanuparr.Persistence.Models.Configuration;
|
||||||
|
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.MalwareBlocker.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/configuration")]
|
||||||
|
public sealed class MalwareBlockerConfigController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<MalwareBlockerConfigController> _logger;
|
||||||
|
private readonly DataContext _dataContext;
|
||||||
|
private readonly IJobManagementService _jobManagementService;
|
||||||
|
|
||||||
|
public MalwareBlockerConfigController(
|
||||||
|
ILogger<MalwareBlockerConfigController> logger,
|
||||||
|
DataContext dataContext,
|
||||||
|
IJobManagementService jobManagementService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_dataContext = dataContext;
|
||||||
|
_jobManagementService = jobManagementService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("malware_blocker")]
|
||||||
|
public async Task<IActionResult> GetMalwareBlockerConfig()
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = await _dataContext.ContentBlockerConfigs
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstAsync();
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("malware_blocker")]
|
||||||
|
public async Task<IActionResult> UpdateMalwareBlockerConfig([FromBody] UpdateMalwareBlockerConfigRequest request)
|
||||||
|
{
|
||||||
|
await DataContext.Lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(request.CronExpression))
|
||||||
|
{
|
||||||
|
CronValidationHelper.ValidateCronExpression(request.CronExpression, JobType.MalwareBlocker);
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = await _dataContext.ContentBlockerConfigs
|
||||||
|
.FirstAsync();
|
||||||
|
|
||||||
|
request.ApplyTo(config);
|
||||||
|
config.Validate();
|
||||||
|
|
||||||
|
await _dataContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
await UpdateJobSchedule(config, JobType.MalwareBlocker);
|
||||||
|
|
||||||
|
return Ok(new { Message = "MalwareBlocker configuration updated successfully" });
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save MalwareBlocker configuration");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DataContext.Lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
|
||||||
|
{
|
||||||
|
if (config.Enabled)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(config.CronExpression))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("{name} is enabled, updating job schedule with cron expression: {CronExpression}",
|
||||||
|
jobType.ToString(), config.CronExpression);
|
||||||
|
|
||||||
|
await _jobManagementService.StartJob(jobType, null, config.CronExpression);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("{name} is enabled, but no cron expression was found in the configuration", jobType.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("{name} is disabled, stopping the job", jobType.ToString());
|
||||||
|
await _jobManagementService.StopJob(jobType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public record CreateAppriseProviderRequest : CreateNotificationProviderRequestBase
|
||||||
|
{
|
||||||
|
public AppriseMode Mode { get; init; } = AppriseMode.Api;
|
||||||
|
|
||||||
|
// API mode fields
|
||||||
|
public string Url { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Key { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Tags { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
// CLI mode fields
|
||||||
|
public string? ServiceUrls { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public record CreateDiscordProviderRequest : CreateNotificationProviderRequestBase
|
||||||
|
{
|
||||||
|
public string WebhookUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string AvatarUrl { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public record CreateGotifyProviderRequest : CreateNotificationProviderRequestBase
|
||||||
|
{
|
||||||
|
public string ServerUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string ApplicationToken { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public int Priority { get; init; } = 5;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public record CreateNotifiarrProviderRequest : CreateNotificationProviderRequestBase
|
||||||
|
{
|
||||||
|
public string ApiKey { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string ChannelId { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public abstract record CreateNotificationProviderRequestBase
|
||||||
|
{
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public bool IsEnabled { get; init; } = true;
|
||||||
|
|
||||||
|
public bool OnFailedImportStrike { get; init; }
|
||||||
|
|
||||||
|
public bool OnStalledStrike { get; init; }
|
||||||
|
|
||||||
|
public bool OnSlowStrike { get; init; }
|
||||||
|
|
||||||
|
public bool OnQueueItemDeleted { get; init; }
|
||||||
|
|
||||||
|
public bool OnDownloadCleaned { get; init; }
|
||||||
|
|
||||||
|
public bool OnCategoryChanged { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public record CreateNtfyProviderRequest : CreateNotificationProviderRequestBase
|
||||||
|
{
|
||||||
|
public string ServerUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public List<string> Topics { get; init; } = [];
|
||||||
|
|
||||||
|
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||||
|
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Password { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string AccessToken { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||||
|
|
||||||
|
public List<string> Tags { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public record CreatePushoverProviderRequest : CreateNotificationProviderRequestBase
|
||||||
|
{
|
||||||
|
public string ApiToken { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string UserKey { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public List<string> Devices { get; init; } = [];
|
||||||
|
|
||||||
|
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
|
||||||
|
|
||||||
|
public string? Sound { get; init; }
|
||||||
|
|
||||||
|
public int? Retry { get; init; }
|
||||||
|
|
||||||
|
public int? Expire { get; init; }
|
||||||
|
|
||||||
|
public List<string> Tags { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record CreateTelegramProviderRequest : CreateNotificationProviderRequestBase
|
||||||
|
{
|
||||||
|
public string BotToken { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string ChatId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? TopicId { get; init; }
|
||||||
|
|
||||||
|
public bool SendSilently { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public record TestAppriseProviderRequest
|
||||||
|
{
|
||||||
|
public AppriseMode Mode { get; init; } = AppriseMode.Api;
|
||||||
|
|
||||||
|
// API mode fields
|
||||||
|
public string Url { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Key { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Tags { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
// CLI mode fields
|
||||||
|
public string? ServiceUrls { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public record TestDiscordProviderRequest
|
||||||
|
{
|
||||||
|
public string WebhookUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string AvatarUrl { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public record TestGotifyProviderRequest
|
||||||
|
{
|
||||||
|
public string ServerUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string ApplicationToken { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public int Priority { get; init; } = 5;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public record TestNotifiarrProviderRequest
|
||||||
|
{
|
||||||
|
public string ApiKey { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string ChannelId { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public record TestNtfyProviderRequest
|
||||||
|
{
|
||||||
|
public string ServerUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public List<string> Topics { get; init; } = [];
|
||||||
|
|
||||||
|
public NtfyAuthenticationType AuthenticationType { get; init; } = NtfyAuthenticationType.None;
|
||||||
|
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Password { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string AccessToken { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||||
|
|
||||||
|
public List<string> Tags { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public record TestPushoverProviderRequest
|
||||||
|
{
|
||||||
|
public string ApiToken { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string UserKey { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public List<string> Devices { get; init; } = [];
|
||||||
|
|
||||||
|
public PushoverPriority Priority { get; init; } = PushoverPriority.Normal;
|
||||||
|
|
||||||
|
public string? Sound { get; init; }
|
||||||
|
|
||||||
|
public int? Retry { get; init; }
|
||||||
|
|
||||||
|
public int? Expire { get; init; }
|
||||||
|
|
||||||
|
public List<string> Tags { get; init; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public sealed record TestTelegramProviderRequest
|
||||||
|
{
|
||||||
|
public string BotToken { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string ChatId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? TopicId { get; init; }
|
||||||
|
|
||||||
|
public bool SendSilently { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Cleanuparr.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public record UpdateAppriseProviderRequest : UpdateNotificationProviderRequestBase
|
||||||
|
{
|
||||||
|
public AppriseMode Mode { get; init; } = AppriseMode.Api;
|
||||||
|
|
||||||
|
// API mode fields
|
||||||
|
public string Url { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Key { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Tags { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
// CLI mode fields
|
||||||
|
public string? ServiceUrls { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||||
|
|
||||||
|
public record UpdateDiscordProviderRequest : UpdateNotificationProviderRequestBase
|
||||||
|
{
|
||||||
|
public string WebhookUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string AvatarUrl { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user