mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2025-12-24 06:28:55 -05:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac3be75082 | ||
|
|
a1663b865a | ||
|
|
c97a416d1e | ||
|
|
d28ab42303 | ||
|
|
fbb2bba3b6 | ||
|
|
08eda22587 | ||
|
|
a4045eebd3 | ||
|
|
a57cbccbb4 | ||
|
|
2221f118bb | ||
|
|
2cc3eb4ebb | ||
|
|
3a064a22bd |
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 }}
|
||||
23
.github/workflows/build-docker.yml
vendored
23
.github/workflows/build-docker.yml
vendored
@@ -1,14 +1,21 @@
|
||||
name: Build Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/**'
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
push_docker:
|
||||
description: 'Push Docker image to registry'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build_app:
|
||||
@@ -115,6 +122,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push docker image
|
||||
id: docker-build
|
||||
timeout-minutes: 15
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
@@ -133,6 +141,9 @@ jobs:
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
push: true
|
||||
push: ${{ inputs.push_docker }}
|
||||
tags: |
|
||||
${{ env.githubTags }}
|
||||
${{ env.githubTags }}
|
||||
# Enable BuildKit cache for faster builds
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
157
.github/workflows/build-executable.yml
vendored
157
.github/workflows/build-executable.yml
vendored
@@ -1,40 +1,55 @@
|
||||
name: Build Executables
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Build for each platform in parallel using matrix strategy
|
||||
build-platform:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- runtime: win-x64
|
||||
platform: win-amd64
|
||||
- runtime: linux-x64
|
||||
platform: linux-amd64
|
||||
- runtime: linux-arm64
|
||||
platform: linux-arm64
|
||||
- runtime: osx-x64
|
||||
platform: osx-amd64
|
||||
- runtime: osx-arm64
|
||||
platform: osx-arm64
|
||||
|
||||
steps:
|
||||
|
||||
- name: 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
|
||||
run: |
|
||||
repoFullName=${{ github.repository }}
|
||||
ref=${{ github.ref }}
|
||||
|
||||
# Handle both tag events and manual dispatch
|
||||
if [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
|
||||
# Use input version if provided, otherwise determine from ref
|
||||
if [[ -n "${{ inputs.app_version }}" ]]; then
|
||||
appVersion="${{ inputs.app_version }}"
|
||||
releaseVersion="v$appVersion"
|
||||
elif [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
releaseVersion=${ref##refs/tags/}
|
||||
appVersion=${releaseVersion#v}
|
||||
else
|
||||
# For manual dispatch, use a default version
|
||||
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
|
||||
appVersion="0.0.1-dev"
|
||||
fi
|
||||
|
||||
repoFullName=${{ github.repository }}
|
||||
repositoryName=${repoFullName#*/}
|
||||
|
||||
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
|
||||
echo "githubRepositoryName=${repoFullName#*/}" >> $GITHUB_ENV
|
||||
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
|
||||
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
|
||||
echo "appVersion=$appVersion" >> $GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
|
||||
@@ -58,27 +73,28 @@ jobs:
|
||||
ref: ${{ github.ref_name }}
|
||||
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
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nuget-
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Install dependencies and restore
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ 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
|
||||
|
||||
- name: Copy frontend to backend wwwroot
|
||||
@@ -86,52 +102,49 @@ jobs:
|
||||
mkdir -p code/backend/${{ env.executableName }}/wwwroot
|
||||
cp -r code/frontend/dist/ui/browser/* code/backend/${{ env.executableName }}/wwwroot/
|
||||
|
||||
- name: Build win-x64
|
||||
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 ${{ matrix.platform }}
|
||||
run: |
|
||||
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
|
||||
-c Release \
|
||||
--runtime ${{ matrix.runtime }} \
|
||||
--self-contained \
|
||||
-o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }} \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:Version=${{ env.appVersion }} \
|
||||
/p:DebugSymbols=false
|
||||
|
||||
- name: 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: Zip win-x64
|
||||
- name: Zip artifact
|
||||
run: |
|
||||
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
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: executable-${{ matrix.platform }}
|
||||
path: ./artifacts/*.zip
|
||||
retention-days: 30
|
||||
|
||||
# Consolidate all executable artifacts
|
||||
consolidate:
|
||||
needs: build-platform
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all platform artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: executable-*
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: List downloaded artifacts
|
||||
run: |
|
||||
cd ./artifacts
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64/
|
||||
echo "Consolidated executable artifacts:"
|
||||
find ./artifacts -type f -name "*.zip" | sort
|
||||
|
||||
- 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
|
||||
- name: Upload consolidated artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cleanuparr-executables
|
||||
path: |
|
||||
./artifacts/*.zip
|
||||
path: ./artifacts/*.zip
|
||||
retention-days: 30
|
||||
|
||||
# Removed individual release step - handled by main release workflow
|
||||
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
|
||||
@@ -1,28 +1,47 @@
|
||||
name: Build macOS ARM Installer
|
||||
name: Build macOS Installers
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build-macos-arm-installer:
|
||||
name: Build macOS ARM Installer
|
||||
runs-on: macos-14 # ARM runner for Apple Silicon
|
||||
|
||||
build-macos-installer:
|
||||
name: Build macOS ${{ matrix.arch }} Installer
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: Intel
|
||||
runner: macos-13
|
||||
runtime: osx-x64
|
||||
min_os_version: "10.15"
|
||||
artifact_suffix: intel
|
||||
- arch: ARM
|
||||
runner: macos-14
|
||||
runtime: osx-arm64
|
||||
min_os_version: "11.0"
|
||||
artifact_suffix: arm64
|
||||
|
||||
steps:
|
||||
- name: Set variables
|
||||
run: |
|
||||
repoFullName=${{ github.repository }}
|
||||
ref=${{ github.ref }}
|
||||
|
||||
# Handle both tag events and manual dispatch
|
||||
if [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
|
||||
# Use input version if provided, otherwise determine from ref
|
||||
if [[ -n "${{ inputs.app_version }}" ]]; then
|
||||
appVersion="${{ inputs.app_version }}"
|
||||
releaseVersion="v$appVersion"
|
||||
elif [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
releaseVersion=${ref##refs/tags/}
|
||||
appVersion=${releaseVersion#v}
|
||||
else
|
||||
@@ -30,9 +49,9 @@ jobs:
|
||||
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
|
||||
@@ -58,18 +77,11 @@ jobs:
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js for frontend build
|
||||
uses: actions/setup-node@v4
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@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: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
@@ -81,16 +93,16 @@ jobs:
|
||||
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
|
||||
- name: Build macOS ${{ matrix.arch }} executable
|
||||
run: |
|
||||
# Clean any existing output directory
|
||||
rm -rf dist
|
||||
mkdir -p dist/temp
|
||||
|
||||
|
||||
# Build to a temporary location
|
||||
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
|
||||
-c Release \
|
||||
--runtime osx-arm64 \
|
||||
--runtime ${{ matrix.runtime }} \
|
||||
--self-contained true \
|
||||
-o dist/temp \
|
||||
/p:PublishSingleFile=true \
|
||||
@@ -103,17 +115,17 @@ jobs:
|
||||
/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
|
||||
@@ -124,16 +136,16 @@ jobs:
|
||||
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/
|
||||
|
||||
@@ -141,12 +153,12 @@ jobs:
|
||||
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
|
||||
@@ -158,14 +170,14 @@ jobs:
|
||||
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"?>
|
||||
@@ -196,7 +208,7 @@ jobs:
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
|
||||
# Create Info.plist with proper configuration
|
||||
cat > dist/Cleanuparr.app/Contents/Info.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -228,7 +240,7 @@ jobs:
|
||||
<key>NSRequiresAquaSystemAppearance</key>
|
||||
<false/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.0</string>
|
||||
<string>${{ matrix.min_os_version }}</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.productivity</string>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
@@ -245,7 +257,7 @@ jobs:
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf dist/temp
|
||||
|
||||
@@ -255,96 +267,96 @@ jobs:
|
||||
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"
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}.pkg"
|
||||
else
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-arm64-dev.pkg"
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}-dev.pkg"
|
||||
fi
|
||||
|
||||
|
||||
# Create PKG installer with better metadata
|
||||
pkgbuild --root dist/ \
|
||||
--scripts scripts/ \
|
||||
@@ -353,14 +365,12 @@ jobs:
|
||||
--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
|
||||
name: Cleanuparr-macos-${{ matrix.artifact_suffix }}-installer
|
||||
path: '${{ env.pkgName }}'
|
||||
retention-days: 30
|
||||
|
||||
# Removed individual release step - handled by main release workflow
|
||||
366
.github/workflows/build-macos-intel-installer.yml
vendored
366
.github/workflows/build-macos-intel-installer.yml
vendored
@@ -1,366 +0,0 @@
|
||||
name: Build macOS Intel Installer
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build-macos-intel-installer:
|
||||
name: Build macOS Intel Installer
|
||||
runs-on: macos-13 # Intel runner
|
||||
|
||||
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 Intel 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-x64 \
|
||||
--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>10.15</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-intel.pkg"
|
||||
else
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-intel-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-intel-installer
|
||||
path: '${{ env.pkgName }}'
|
||||
retention-days: 30
|
||||
|
||||
# Removed individual release step - handled by main release workflow
|
||||
41
.github/workflows/build-windows-installer.yml
vendored
41
.github/workflows/build-windows-installer.yml
vendored
@@ -1,11 +1,13 @@
|
||||
name: Build Windows Installer
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build-windows-installer:
|
||||
@@ -17,9 +19,13 @@ jobs:
|
||||
run: |
|
||||
$repoFullName = "${{ github.repository }}"
|
||||
$ref = "${{ github.ref }}"
|
||||
|
||||
# Handle both tag events and manual dispatch
|
||||
if ($ref -match "^refs/tags/") {
|
||||
$inputVersion = "${{ inputs.app_version }}"
|
||||
|
||||
# Use input version if provided, otherwise determine from ref
|
||||
if ($inputVersion -ne "") {
|
||||
$appVersion = $inputVersion
|
||||
$releaseVersion = "v$appVersion"
|
||||
} elseif ($ref -match "^refs/tags/") {
|
||||
$releaseVersion = $ref -replace "refs/tags/", ""
|
||||
$appVersion = $releaseVersion -replace "^v", ""
|
||||
} else {
|
||||
@@ -27,15 +33,15 @@ jobs:
|
||||
$releaseVersion = "dev-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
$appVersion = "0.0.1-dev"
|
||||
}
|
||||
|
||||
|
||||
$repositoryName = $repoFullName.Split("/")[1]
|
||||
|
||||
|
||||
echo "githubRepository=${{ github.repository }}" >> $env:GITHUB_ENV
|
||||
echo "githubRepositoryName=$repositoryName" >> $env:GITHUB_ENV
|
||||
echo "releaseVersion=$releaseVersion" >> $env:GITHUB_ENV
|
||||
echo "appVersion=$appVersion" >> $env:GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $env:GITHUB_ENV
|
||||
echo "APP_VERSION=$appVersion" >> $env:GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
@@ -55,18 +61,11 @@ jobs:
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Setup Node.js for frontend build
|
||||
uses: actions/setup-node@v4
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@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: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
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
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 24.x
|
||||
cache: yarn
|
||||
cache-dependency-path: docs/yarn.lock
|
||||
|
||||
|
||||
177
.github/workflows/release.yml
vendored
177
.github/workflows/release.yml
vendored
@@ -10,6 +10,31 @@ on:
|
||||
description: 'Version to release (e.g., 1.0.0)'
|
||||
required: false
|
||||
default: ''
|
||||
runTests:
|
||||
description: 'Run test suite'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
buildDocker:
|
||||
description: 'Build Docker image'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
pushDocker:
|
||||
description: 'Push Docker image to registry'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
buildBinaries:
|
||||
description: 'Build executables and installers'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
createRelease:
|
||||
description: 'Create GitHub release'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
# Validate release
|
||||
@@ -19,7 +44,7 @@ jobs:
|
||||
app_version: ${{ steps.version.outputs.app_version }}
|
||||
release_version: ${{ steps.version.outputs.release_version }}
|
||||
is_tag: ${{ steps.version.outputs.is_tag }}
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -47,40 +72,98 @@ jobs:
|
||||
echo "app_version=$app_version" >> $GITHUB_OUTPUT
|
||||
echo "release_version=$release_version" >> $GITHUB_OUTPUT
|
||||
echo "is_tag=$is_tag" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
echo "🏷️ Release Version: $release_version"
|
||||
echo "📱 App Version: $app_version"
|
||||
echo "🔖 Is Tag: $is_tag"
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
needs: validate
|
||||
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
|
||||
uses: ./.github/workflows/test.yml
|
||||
secrets: inherit
|
||||
|
||||
# Build frontend once for all build jobs and cache it
|
||||
build-frontend:
|
||||
needs: [validate, test]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-frontend.yml
|
||||
secrets: inherit
|
||||
|
||||
# Build portable executables
|
||||
build-executables:
|
||||
needs: validate
|
||||
needs: [validate, test, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-executable.yml
|
||||
with:
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# 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
|
||||
with:
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Build macOS Intel installer
|
||||
build-macos-intel:
|
||||
needs: validate
|
||||
uses: ./.github/workflows/build-macos-intel-installer.yml
|
||||
# Build macOS installers (Intel and ARM)
|
||||
build-macos:
|
||||
needs: [validate, test, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-macos-installer.yml
|
||||
with:
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Build macOS ARM installer
|
||||
build-macos-arm:
|
||||
needs: validate
|
||||
uses: ./.github/workflows/build-macos-arm-installer.yml
|
||||
# Build and push Docker image(s)
|
||||
build-docker:
|
||||
needs: [validate, test]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildDocker == 'true')
|
||||
uses: ./.github/workflows/build-docker.yml
|
||||
with:
|
||||
push_docker: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.pushDocker == 'true' }}
|
||||
secrets: inherit
|
||||
|
||||
# Create GitHub 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
|
||||
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:
|
||||
- name: Get vault secrets
|
||||
@@ -119,46 +202,56 @@ jobs:
|
||||
|
||||
# Summary job
|
||||
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
|
||||
if: always()
|
||||
|
||||
|
||||
steps:
|
||||
- name: Record workflow start time
|
||||
id: workflow-start
|
||||
run: |
|
||||
# Get workflow start time from GitHub API
|
||||
workflow_start=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }} --jq '.run_started_at')
|
||||
start_epoch=$(date -d "$workflow_start" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$workflow_start" +%s)
|
||||
echo "start=$start_epoch" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
# Calculate total workflow duration
|
||||
start_time=${{ steps.workflow-start.outputs.start }}
|
||||
end_time=$(date +%s)
|
||||
duration=$((end_time - start_time))
|
||||
minutes=$((duration / 60))
|
||||
seconds=$((duration % 60))
|
||||
echo "## 🏗️ Cleanuparr Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version**: ${{ needs.validate.outputs.release_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**App Version**: ${{ needs.validate.outputs.app_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Is Tag**: ${{ needs.validate.outputs.is_tag }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total Duration**: ${minutes}m ${seconds}s" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Build Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check job results
|
||||
if [[ "${{ needs.build-executables.result }}" == "success" ]]; then
|
||||
echo "✅ **Portable Executables**: Success" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Portable Executables**: ${{ needs.build-executables.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.build-windows-installer.result }}" == "success" ]]; then
|
||||
echo "✅ **Windows Installer**: Success" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Windows Installer**: ${{ needs.build-windows-installer.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
|
||||
# Helper function to print job result
|
||||
print_result() {
|
||||
local name="$1"
|
||||
local result="$2"
|
||||
case "$result" in
|
||||
success) echo "✅ **$name**: Success" >> $GITHUB_STEP_SUMMARY ;;
|
||||
skipped) echo "⏭️ **$name**: Skipped" >> $GITHUB_STEP_SUMMARY ;;
|
||||
*) echo "❌ **$name**: $result" >> $GITHUB_STEP_SUMMARY ;;
|
||||
esac
|
||||
}
|
||||
|
||||
print_result "Tests" "${{ needs.test.result }}"
|
||||
print_result "Frontend Build" "${{ needs.build-frontend.result }}"
|
||||
print_result "Portable Executables" "${{ needs.build-executables.result }}"
|
||||
print_result "Windows Installer" "${{ needs.build-windows-installer.result }}"
|
||||
print_result "macOS Installers (Intel & ARM)" "${{ needs.build-macos.result }}"
|
||||
print_result "Docker Image Build" "${{ needs.build-docker.result }}"
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "🎉 **Build completed!**" >> $GITHUB_STEP_SUMMARY
|
||||
99
.github/workflows/test.yml
vendored
Normal file
99
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'code/backend/**'
|
||||
- '.github/workflows/test.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/backend/**'
|
||||
- '.github/workflows/test.yml'
|
||||
workflow_call:
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nuget-
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Restore dependencies
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/cleanuparr.sln
|
||||
|
||||
- name: Build solution
|
||||
run: dotnet build code/backend/cleanuparr.sln --configuration Release --no-restore
|
||||
|
||||
- name: Run tests
|
||||
id: run-tests
|
||||
run: dotnet test code/backend/cleanuparr.sln --configuration Release --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" --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}"
|
||||
@@ -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
|
||||
|
||||

|
||||

|
||||
[](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml)
|
||||
|
||||
|
||||
[](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.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Build Angular frontend
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine AS frontend-build
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-build
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first for better layer caching
|
||||
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 frontend/ .
|
||||
@@ -28,14 +30,17 @@ EXPOSE 11011
|
||||
# Copy source code
|
||||
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
|
||||
|
||||
# Build and publish
|
||||
RUN dotnet publish ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj \
|
||||
# Restore and publish with cache mount
|
||||
RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
|
||||
dotnet restore ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj -a $TARGETARCH && \
|
||||
dotnet publish ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj \
|
||||
-a $TARGETARCH \
|
||||
-c Release \
|
||||
-o /app/publish \
|
||||
--no-restore \
|
||||
/p:Version=${VERSION} \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:DebugSymbols=false
|
||||
|
||||
@@ -23,27 +23,24 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MassTransit" Version="8.4.1" />
|
||||
<PackageReference Include="MassTransit" Version="8.5.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
|
||||
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
|
||||
<PackageReference Include="Quartz" Version="3.15.1" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
|
||||
<PackageReference Include="Serilog" Version="4.3.0" />
|
||||
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<!-- API-related packages -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -2,7 +2,6 @@ using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using System.Text;
|
||||
using Cleanuparr.Api.Middleware;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -46,20 +45,6 @@ public static class ApiDI
|
||||
|
||||
// Add health status broadcaster
|
||||
services.AddHostedService<HealthStatusBroadcaster>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -83,17 +68,6 @@ public static class ApiDI
|
||||
app.UseCors("Any");
|
||||
app.UseRouting();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(options =>
|
||||
{
|
||||
options.SwaggerEndpoint("v1/swagger.json", "Cleanuparr API v1");
|
||||
options.RoutePrefix = "swagger";
|
||||
options.DocumentTitle = "Cleanuparr API Documentation";
|
||||
});
|
||||
}
|
||||
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
|
||||
@@ -62,65 +62,13 @@ public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
throw new ValidationException("Request body cannot be null");
|
||||
}
|
||||
|
||||
// Validate cron expression format
|
||||
if (!string.IsNullOrEmpty(newConfigDto.CronExpression))
|
||||
{
|
||||
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
|
||||
}
|
||||
|
||||
if (newConfigDto.Enabled && newConfigDto.Categories.Any())
|
||||
{
|
||||
if (newConfigDto.Categories.GroupBy(x => x.Name).Any(x => x.Count() > 1))
|
||||
{
|
||||
throw new ValidationException("Duplicate category names found");
|
||||
}
|
||||
|
||||
foreach (var categoryDto in newConfigDto.Categories)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(categoryDto.Name))
|
||||
{
|
||||
throw new ValidationException("Category name cannot be empty");
|
||||
}
|
||||
|
||||
if (categoryDto is { MaxRatio: < 0, MaxSeedTime: < 0 })
|
||||
{
|
||||
throw new ValidationException("Either max ratio or max seed time must be enabled");
|
||||
}
|
||||
|
||||
if (categoryDto.MinSeedTime < 0)
|
||||
{
|
||||
throw new ValidationException("Min seed time cannot be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newConfigDto.UnlinkedEnabled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newConfigDto.UnlinkedTargetCategory))
|
||||
{
|
||||
throw new ValidationException("Unlinked target category cannot be empty");
|
||||
}
|
||||
|
||||
if (newConfigDto.UnlinkedCategories?.Count is null or 0)
|
||||
{
|
||||
throw new ValidationException("Unlinked categories cannot be empty");
|
||||
}
|
||||
|
||||
if (newConfigDto.UnlinkedCategories.Contains(newConfigDto.UnlinkedTargetCategory))
|
||||
{
|
||||
throw new ValidationException("The unlinked target category should not be present in unlinked categories");
|
||||
}
|
||||
|
||||
if (newConfigDto.UnlinkedCategories.Any(string.IsNullOrWhiteSpace))
|
||||
{
|
||||
throw new ValidationException("Empty unlinked category filter found");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(newConfigDto.UnlinkedIgnoredRootDir) && !Directory.Exists(newConfigDto.UnlinkedIgnoredRootDir))
|
||||
{
|
||||
throw new ValidationException($"{newConfigDto.UnlinkedIgnoredRootDir} root directory does not exist");
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing configuration
|
||||
var oldConfig = await _dataContext.DownloadCleanerConfigs
|
||||
.Include(x => x.Categories)
|
||||
.FirstAsync();
|
||||
@@ -135,6 +83,7 @@ public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
oldConfig.UnlinkedIgnoredRootDir = newConfigDto.UnlinkedIgnoredRootDir;
|
||||
oldConfig.UnlinkedCategories = newConfigDto.UnlinkedCategories;
|
||||
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
|
||||
oldConfig.Categories.Clear();
|
||||
|
||||
_dataContext.CleanCategories.RemoveRange(oldConfig.Categories);
|
||||
_dataContext.DownloadCleanerConfigs.Update(oldConfig);
|
||||
@@ -151,6 +100,8 @@ public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
oldConfig.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await UpdateJobSchedule(oldConfig, JobType.DownloadCleaner);
|
||||
|
||||
@@ -59,8 +59,6 @@ public sealed class QueueCleanerConfigController : ControllerBase
|
||||
CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression);
|
||||
}
|
||||
|
||||
newConfigDto.FailedImport.Validate();
|
||||
|
||||
var oldConfig = await _dataContext.QueueCleanerConfigs
|
||||
.FirstAsync();
|
||||
|
||||
@@ -70,6 +68,8 @@ public sealed class QueueCleanerConfigController : ControllerBase
|
||||
oldConfig.FailedImport = newConfigDto.FailedImport;
|
||||
oldConfig.DownloadingMetadataMaxStrikes = newConfigDto.DownloadingMetadataMaxStrikes;
|
||||
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
|
||||
|
||||
oldConfig.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -16,20 +16,16 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="Serilog" Version="4.3.0" />
|
||||
<PackageReference Include="Serilog.Expressions" Version="5.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="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -424,22 +424,6 @@ public class QBitItemTests
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingName_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo { Name = "Test Torrent", Hash = "abc123" };
|
||||
var trackers = new List<TorrentTracker>();
|
||||
var wrapper = new QBitItem(torrentInfo, trackers, false);
|
||||
var ignoredDownloads = new[] { "test" };
|
||||
|
||||
// Act
|
||||
var result = wrapper.IsIgnored(ignoredDownloads);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIgnored_MatchingHash_ReturnsTrue()
|
||||
{
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
<PackageReference Include="FLM.QBittorrent" Version="1.0.2" />
|
||||
<PackageReference Include="FLM.Transmission" Version="1.0.3" />
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MassTransit.Abstractions" Version="8.4.1" />
|
||||
<PackageReference Include="MassTransit.Abstractions" Version="8.5.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Mono.Unix" Version="7.1.0-final.1.21458.1" />
|
||||
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||
<PackageReference Include="Quartz" Version="3.15.1" />
|
||||
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<PackageReference Include="System.Threading.RateLimiting" Version="9.0.9" />
|
||||
<PackageReference Include="System.Threading.RateLimiting" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.Deluge.Response;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
|
||||
|
||||
@@ -70,11 +71,26 @@ public sealed class DelugeItem : ITorrentItem
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (string pattern in ignoredDownloads)
|
||||
{
|
||||
if (Hash?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return ignoredDownloads.Any(pattern =>
|
||||
Name.Contains(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Trackers.Any(tracker => tracker.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase)));
|
||||
if (Category?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_downloadStatus.Trackers.Any(x => UriService.GetDomain(x.Url)?.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase) is true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
|
||||
@@ -73,10 +75,30 @@ public sealed class QBitItem : ITorrentItem
|
||||
return false;
|
||||
}
|
||||
|
||||
return ignoredDownloads.Any(pattern =>
|
||||
Name.Contains(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Trackers.Any(tracker => tracker.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase)));
|
||||
foreach (string pattern in ignoredDownloads)
|
||||
{
|
||||
if (Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Category?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_torrentInfo.Tags?.Contains(pattern, StringComparer.InvariantCultureIgnoreCase) is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_trackers.Any(tracker => tracker.ShouldIgnore(ignoredDownloads)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Transmission.API.RPC.Entity;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
|
||||
@@ -55,7 +57,7 @@ public sealed class TransmissionItem : ITorrentItem
|
||||
public long SeedingTimeSeconds => _torrentInfo.SecondsSeeding ?? 0;
|
||||
|
||||
// Categories and tags
|
||||
public string? Category => _torrentInfo.Labels?.FirstOrDefault();
|
||||
public string? Category => _torrentInfo.GetCategory();
|
||||
public IReadOnlyList<string> Tags => _torrentInfo.Labels?.ToList().AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>();
|
||||
|
||||
// State checking methods
|
||||
@@ -78,10 +80,28 @@ public sealed class TransmissionItem : ITorrentItem
|
||||
return false;
|
||||
}
|
||||
|
||||
return ignoredDownloads.Any(pattern =>
|
||||
Name.Contains(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Trackers.Any(tracker => tracker.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase)));
|
||||
foreach (string pattern in ignoredDownloads)
|
||||
{
|
||||
if (Hash?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Category?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bool? hasIgnoredTracker = _torrentInfo.Trackers?
|
||||
.Any(x => UriService.GetDomain(x.Announce)?.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase) ?? false);
|
||||
|
||||
if (hasIgnoredTracker is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.UTorrent.Response;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
|
||||
|
||||
@@ -82,11 +83,26 @@ public sealed class UTorrentItemWrapper : ITorrentItem
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (string value in ignoredDownloads)
|
||||
{
|
||||
if (Hash.Equals(value, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Category?.Equals(value, StringComparison.InvariantCultureIgnoreCase) is true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return ignoredDownloads.Any(pattern =>
|
||||
Name.Contains(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) ||
|
||||
Trackers.Any(tracker => tracker.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase)));
|
||||
if (_torrentProperties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -52,10 +52,10 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
|
||||
bool isUnlinkedEnabled = config.UnlinkedEnabled && !string.IsNullOrEmpty(config.UnlinkedTargetCategory) && config.UnlinkedCategories.Count > 0;
|
||||
bool isCleaningEnabled = config.Categories.Count > 0;
|
||||
|
||||
|
||||
if (!isUnlinkedEnabled && !isCleaningEnabled)
|
||||
{
|
||||
_logger.LogWarning("{name} is not configured properly", nameof(DownloadCleaner));
|
||||
_logger.LogWarning("No features are enabled for {name}", nameof(DownloadCleaner));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,9 +133,9 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr)), InstanceType.Readarr, true);
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr)), InstanceType.Whisparr, true);
|
||||
|
||||
if (isUnlinkedEnabled && downloadServiceWithDownloads.Count > 0)
|
||||
if (isUnlinkedEnabled && downloadServiceWithDownloads.Sum(x => x.Item2.Count) > 0)
|
||||
{
|
||||
_logger.LogInformation("Found {count} potential downloads to change category", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
|
||||
_logger.LogInformation("Evaluating {count} downloads for hardlinks", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
|
||||
|
||||
// Process each client with its own filtered downloads
|
||||
foreach (var (downloadService, downloadsToChangeCategory) in downloadServiceWithDownloads)
|
||||
@@ -150,7 +150,7 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Finished changing category");
|
||||
_logger.LogInformation("Finished hardlinks evaluation");
|
||||
}
|
||||
|
||||
if (config.Categories.Count is 0)
|
||||
@@ -175,7 +175,7 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("found {count} potential downloads to clean", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
|
||||
_logger.LogInformation("Evaluating {count} downloads for cleanup", downloadServiceWithDownloads.Sum(x => x.Item2.Count));
|
||||
|
||||
// Process cleaning for each client
|
||||
foreach (var (downloadService, downloadsToClean) in downloadServiceWithDownloads)
|
||||
@@ -190,7 +190,7 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("finished cleaning downloads");
|
||||
_logger.LogInformation("Finished cleanup evaluation");
|
||||
|
||||
foreach (var downloadService in downloadServices)
|
||||
{
|
||||
|
||||
@@ -22,7 +22,6 @@ public sealed class AppStatusRefreshService : BackgroundService
|
||||
private static readonly Uri StatusUri = new("https://cleanuparr-status.pages.dev/status.json");
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(10);
|
||||
private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
public AppStatusRefreshService(
|
||||
ILogger<AppStatusRefreshService> logger,
|
||||
@@ -70,7 +69,6 @@ public sealed class AppStatusRefreshService : BackgroundService
|
||||
try
|
||||
{
|
||||
using var client = _httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
|
||||
client.Timeout = RequestTimeout;
|
||||
|
||||
using var response = await client.GetAsync(StatusUri, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.6" />
|
||||
<PackageReference Include="Serilog" Version="4.3.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" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed record CleanCategory : IConfig
|
||||
|
||||
if (MaxRatio < 0 && MaxSeedTime < 0)
|
||||
{
|
||||
throw new ValidationException("Both max ratio and max seed time are disabled");
|
||||
throw new ValidationException("Either max ratio or max seed time must be set to a non-negative value");
|
||||
}
|
||||
|
||||
if (MinSeedTime < 0)
|
||||
|
||||
@@ -44,10 +44,19 @@ public sealed record DownloadCleanerConfig : IJobConfig
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Validate that at least one feature is configured
|
||||
bool hasSeedingCategories = Categories.Count > 0;
|
||||
bool hasUnlinkedFeature = UnlinkedEnabled && UnlinkedCategories.Count > 0 && !string.IsNullOrWhiteSpace(UnlinkedTargetCategory);
|
||||
|
||||
if (!hasSeedingCategories && !hasUnlinkedFeature)
|
||||
{
|
||||
throw new ValidationException("No features are enabled");
|
||||
}
|
||||
|
||||
if (Categories.GroupBy(x => x.Name).Any(x => x.Count() > 1))
|
||||
{
|
||||
throw new ValidationException("duplicated clean categories found");
|
||||
throw new ValidationException("Duplicated clean categories found");
|
||||
}
|
||||
|
||||
Categories.ForEach(x => x.Validate());
|
||||
@@ -63,19 +72,19 @@ public sealed record DownloadCleanerConfig : IJobConfig
|
||||
throw new ValidationException("unlinked target category is required");
|
||||
}
|
||||
|
||||
if (UnlinkedCategories?.Count is null or 0)
|
||||
if (UnlinkedCategories.Count is 0)
|
||||
{
|
||||
throw new ValidationException("no unlinked categories configured");
|
||||
throw new ValidationException("No unlinked categories configured");
|
||||
}
|
||||
|
||||
if (UnlinkedCategories.Contains(UnlinkedTargetCategory))
|
||||
{
|
||||
throw new ValidationException($"The unlinked target category should not be present in unlinked categories");
|
||||
throw new ValidationException("The unlinked target category should not be present in unlinked categories");
|
||||
}
|
||||
|
||||
if (UnlinkedCategories.Any(string.IsNullOrEmpty))
|
||||
{
|
||||
throw new ValidationException("empty unlinked category filter found");
|
||||
throw new ValidationException("Empty unlinked category filter found");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(UnlinkedIgnoredRootDir) && !Directory.Exists(UnlinkedIgnoredRootDir))
|
||||
|
||||
@@ -36,6 +36,16 @@ public sealed record ContentBlockerConfig : IJobConfig
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Sonarr.Enabled && !Radarr.Enabled && !Lidarr.Enabled && !Readarr.Enabled && !Whisparr.Enabled)
|
||||
{
|
||||
throw new ValidationException("At least one blocklist must be configured when Malware Blocker is enabled");
|
||||
}
|
||||
|
||||
ValidateBlocklistSettings(Sonarr, "Sonarr");
|
||||
ValidateBlocklistSettings(Radarr, "Radarr");
|
||||
ValidateBlocklistSettings(Lidarr, "Lidarr");
|
||||
@@ -45,9 +55,25 @@ public sealed record ContentBlockerConfig : IJobConfig
|
||||
|
||||
private static void ValidateBlocklistSettings(BlocklistSettings settings, string context)
|
||||
{
|
||||
if (settings.Enabled && string.IsNullOrWhiteSpace(settings.BlocklistPath))
|
||||
if (!settings.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.BlocklistPath))
|
||||
{
|
||||
throw new ValidationException($"{context} blocklist is enabled but path is not specified");
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(settings.BlocklistPath, UriKind.Absolute, out var uri) &&
|
||||
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!File.Exists(settings.BlocklistPath))
|
||||
{
|
||||
throw new ValidationException($"{context} blocklist does not exist: {settings.BlocklistPath}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,12 @@ public sealed record FailedImportConfig
|
||||
{
|
||||
if (MaxStrikes is > 0 and < 3)
|
||||
{
|
||||
throw new ValidationException("the minimum value for failed imports max strikes must be 3");
|
||||
throw new ValidationException("The minimum value for failed imports max strikes must be 3");
|
||||
}
|
||||
|
||||
if (MaxStrikes >= 3 && PatternMode is PatternMode.Include && Patterns.Count is 0)
|
||||
{
|
||||
throw new ValidationException("At least one pattern must be specified when using the Include pattern mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
5251
code/frontend/package-lock.json
generated
5251
code/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,14 +11,14 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/common": "^19.2.0",
|
||||
"@angular/common": "^19.2.16",
|
||||
"@angular/compiler": "^19.2.0",
|
||||
"@angular/core": "^19.2.0",
|
||||
"@angular/forms": "^19.2.0",
|
||||
"@angular/platform-browser": "^19.2.0",
|
||||
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||
"@angular/router": "^19.2.0",
|
||||
"@angular/service-worker": "^19.2.0",
|
||||
"@angular/core": "^19.2.16",
|
||||
"@angular/forms": "^19.2.16",
|
||||
"@angular/platform-browser": "^19.2.16",
|
||||
"@angular/platform-browser-dynamic": "^19.2.16",
|
||||
"@angular/router": "^19.2.16",
|
||||
"@angular/service-worker": "^19.2.16",
|
||||
"@microsoft/signalr": "^8.0.7",
|
||||
"@ngrx/signals": "^19.2.0",
|
||||
"@primeng/themes": "^19.1.3",
|
||||
@@ -32,7 +32,7 @@
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.2.12",
|
||||
"@angular/cli": "^19.2.12",
|
||||
"@angular/compiler-cli": "^19.2.0",
|
||||
"@angular/compiler-cli": "^19.2.16",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"angular-eslint": "19.6.0",
|
||||
"eslint": "^9.27.0",
|
||||
|
||||
@@ -22,6 +22,7 @@ export class DocumentationService {
|
||||
'failedImport.maxStrikes': 'failed-import-max-strikes',
|
||||
'failedImport.ignorePrivate': 'failed-import-ignore-private',
|
||||
'failedImport.deletePrivate': 'failed-import-delete-private',
|
||||
'failedImport.skipIfNotFoundInClient': 'failed-import-skip-if-not-found-in-client',
|
||||
'failedImport.pattern-mode': 'failed-import-pattern-mode',
|
||||
'failedImport.patterns': 'failed-import-patterns',
|
||||
'downloadingMetadataMaxStrikes': 'stalled-downloading-metadata-max-strikes',
|
||||
|
||||
133
code/frontend/src/app/core/utils/form-validation.util.ts
Normal file
133
code/frontend/src/app/core/utils/form-validation.util.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { AbstractControl, FormGroup, FormArray } from '@angular/forms';
|
||||
|
||||
/**
|
||||
* Utility functions for form validation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Recursively checks if a form control or form group has any validation errors
|
||||
* @param control The form control, form group, or form array to check
|
||||
* @returns True if there are any errors in the control or its children
|
||||
*/
|
||||
export function hasFormErrors(control: AbstractControl | null): boolean {
|
||||
if (!control) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the control itself has errors
|
||||
if (control.errors && Object.keys(control.errors).length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If it's a FormGroup, check all its controls
|
||||
if (control instanceof FormGroup) {
|
||||
const controls = control.controls;
|
||||
for (const key in controls) {
|
||||
if (controls.hasOwnProperty(key)) {
|
||||
if (hasFormErrors(controls[key])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a FormArray, check all its controls
|
||||
if (control instanceof FormArray) {
|
||||
for (let i = 0; i < control.length; i++) {
|
||||
if (hasFormErrors(control.at(i))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a form control or any of its children have been touched
|
||||
* @param control The form control, form group, or form array to check
|
||||
* @returns True if the control or any of its children have been touched
|
||||
*/
|
||||
export function isFormTouched(control: AbstractControl | null): boolean {
|
||||
if (!control) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the control itself has been touched
|
||||
if (control.touched) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If it's a FormGroup, check all its controls
|
||||
if (control instanceof FormGroup) {
|
||||
const controls = control.controls;
|
||||
for (const key in controls) {
|
||||
if (controls.hasOwnProperty(key)) {
|
||||
if (isFormTouched(controls[key])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a FormArray, check all its controls
|
||||
if (control instanceof FormArray) {
|
||||
for (let i = 0; i < control.length; i++) {
|
||||
if (isFormTouched(control.at(i))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a form section has validation errors and has been touched
|
||||
* This is useful for showing validation errors only after user interaction
|
||||
* @param control The form control or group to check
|
||||
* @returns True if there are errors and the control has been touched
|
||||
*/
|
||||
export function hasTouchedFormErrors(control: AbstractControl | null): boolean {
|
||||
return hasFormErrors(control) && isFormTouched(control);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a form control or group has validation errors where each errored field has been touched
|
||||
* Only returns true if the specific invalid fields have been touched, not just any sibling
|
||||
* @param control The form control or group to check
|
||||
* @returns True if there are errors in fields that have been individually touched
|
||||
*/
|
||||
export function hasIndividuallyDirtyFormErrors(control: AbstractControl | null): boolean {
|
||||
if (!control) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For a single control, check if it has errors AND is touched
|
||||
if (!(control instanceof FormGroup) && !(control instanceof FormArray)) {
|
||||
return control.invalid && control.dirty;
|
||||
}
|
||||
|
||||
// For FormGroup, check each child recursively
|
||||
if (control instanceof FormGroup) {
|
||||
const controls = control.controls;
|
||||
for (const key in controls) {
|
||||
if (controls.hasOwnProperty(key)) {
|
||||
if (hasIndividuallyDirtyFormErrors(controls[key])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For FormArray, check each element recursively
|
||||
if (control instanceof FormArray) {
|
||||
for (let i = 0; i < control.length; i++) {
|
||||
if (hasIndividuallyDirtyFormErrors(control.at(i))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -51,16 +51,14 @@
|
||||
</i>
|
||||
Blacklist Path
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<input
|
||||
type="text"
|
||||
pInputText
|
||||
formControlName="blacklistPath"
|
||||
placeholder="File path or http(s) URL"
|
||||
id="blacklistPath" />
|
||||
</div>
|
||||
<small *ngIf="hasError('blacklistPath', 'required')" class="p-error">This field is required when blacklist sync is enabled</small>
|
||||
<div class="field-input">
|
||||
<input
|
||||
type="text"
|
||||
pInputText
|
||||
formControlName="blacklistPath"
|
||||
placeholder="Local file path or URL"
|
||||
id="blacklistPath" />
|
||||
<small *ngIf="hasError('blacklistPath', 'required')" class="form-error-text">This field is required when blacklist sync is enabled</small>
|
||||
<small class="form-helper-text">Path to blacklist file or HTTP(S) URL containing blacklist patterns</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,13 +29,14 @@
|
||||
<!-- Main Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Download Cleaner
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="enabled" [binary]="true" inputId="dcEnabled"></p-checkbox>
|
||||
<small *ngIf="hasNoFeaturesConfiguredError()" class="form-error-text">At least one feature must be configured</small>
|
||||
<small class="form-helper-text">When enabled, the download cleaner will run according to the schedule</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,8 +68,8 @@
|
||||
<label class="field-label">
|
||||
Run Schedule
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input schedule-input flex flex-wrap">
|
||||
<div class="field-input">
|
||||
<div class="schedule-input flex flex-wrap">
|
||||
<span class="schedule-label">Every</span>
|
||||
<p-select
|
||||
formControlName="every"
|
||||
@@ -88,7 +89,7 @@
|
||||
>
|
||||
</p-selectButton>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('jobSchedule', 'every', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('jobSchedule', 'every', 'required')" class="form-error-text">This field is required</small>
|
||||
<small class="form-helper-text">How often the download cleaner should run</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,11 +102,9 @@
|
||||
title="Click for documentation"></i>
|
||||
Cron Expression
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
|
||||
</div>
|
||||
<small *ngIf="hasError('cronExpression', 'required')" class="p-error">Cron expression is required</small>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
|
||||
<small *ngIf="hasError('cronExpression', 'required')" class="form-error-text">Cron expression is required</small>
|
||||
<small class="form-helper-text">Enter a valid Quartz cron expression (e.g., "0 0/5 * ? * * *" runs every 5 minutes)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,28 +112,16 @@
|
||||
<!-- Ignored Downloads Field -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('ignoredDownloads')"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('ignoredDownloads')"
|
||||
title="Click for documentation"></i>
|
||||
Ignored Downloads
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<!-- Mobile-friendly autocomplete -->
|
||||
<app-mobile-autocomplete
|
||||
formControlName="ignoredDownloads"
|
||||
placeholder="Add download pattern"
|
||||
></app-mobile-autocomplete>
|
||||
|
||||
<!-- Desktop autocomplete -->
|
||||
<p-autocomplete
|
||||
formControlName="ignoredDownloads"
|
||||
inputId="dc-ignoredDownloads"
|
||||
multiple
|
||||
fluid
|
||||
[typeahead]="false"
|
||||
placeholder="Add download pattern and press enter"
|
||||
class="desktop-only"
|
||||
></p-autocomplete>
|
||||
<small class="form-helper-text">Downloads matching these patterns will be ignored by the download cleaner</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,7 +138,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Seeding Settings
|
||||
<span class="accordion-header-title">
|
||||
Seeding Settings
|
||||
@if (sectionHasErrors(0)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<!-- Delete Private Option -->
|
||||
@@ -186,16 +178,15 @@
|
||||
<div *ngFor="let category of categoriesFormArray.controls; let i = index" class="category-item" [formGroup]="getCategoryAsFormGroup(i)">
|
||||
<div class="category-header">
|
||||
<div class="category-title">
|
||||
<i class="pi pi-tag category-icon"></i>
|
||||
<input type="text" pInputText formControlName="name" placeholder="Category name" class="category-name-input" />
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('name')"
|
||||
title="Click for documentation"></i>
|
||||
<input type="text" pInputText formControlName="name" placeholder="Category name" class="category-name-input" />
|
||||
</div>
|
||||
<button pButton type="button" icon="pi pi-trash" class="p-button-danger p-button-sm"
|
||||
(click)="removeCategory(i)" [disabled]="downloadCleanerForm.disabled"></button>
|
||||
</div>
|
||||
<small *ngIf="hasCategoryError(i, 'name', 'required')" class="p-error block">Name is required</small>
|
||||
<small *ngIf="hasCategoryError(i, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
|
||||
<div class="category-content">
|
||||
<div class="category-field">
|
||||
@@ -211,7 +202,8 @@
|
||||
decrementButtonClass="p-button-danger" incrementButtonClass="p-button-success" incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus">
|
||||
</p-inputNumber>
|
||||
<small *ngIf="hasCategoryError(i, 'maxRatio', 'min')" class="p-error block">Min value is -1</small>
|
||||
<small *ngIf="hasCategoryError(i, 'maxRatio', 'required')" class="form-error-text">This input is required</small>
|
||||
<small *ngIf="hasCategoryError(i, 'maxRatio', 'min')" class="form-error-text">Min value is -1</small>
|
||||
<small class="form-helper-text">Maximum ratio to seed before removing (<code>-1</code> means disabled)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,7 +220,8 @@
|
||||
decrementButtonClass="p-button-danger" incrementButtonClass="p-button-success" incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus">
|
||||
</p-inputNumber>
|
||||
<small *ngIf="hasCategoryError(i, 'minSeedTime', 'min')" class="p-error block">Min value is 0</small>
|
||||
<small *ngIf="hasCategoryError(i, 'minSeedTime', 'required')" class="form-error-text">This input is required</small>
|
||||
<small *ngIf="hasCategoryError(i, 'minSeedTime', 'min')" class="form-error-text">Min value is 0</small>
|
||||
<small class="form-helper-text">Minimum time to seed before removing a download that has reached the max ratio (<code>0</code> means disabled)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,20 +233,19 @@
|
||||
title="Click for documentation"></i>
|
||||
Max Seed Time (hours)
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber formControlName="maxSeedTime" [min]="-1" [showButtons]="true" buttonLayout="horizontal"
|
||||
decrementButtonClass="p-button-danger" incrementButtonClass="p-button-success" incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus">
|
||||
</p-inputNumber>
|
||||
</div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber formControlName="maxSeedTime" [min]="-1" [showButtons]="true" buttonLayout="horizontal"
|
||||
decrementButtonClass="p-button-danger" incrementButtonClass="p-button-success" incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus">
|
||||
</p-inputNumber>
|
||||
<small *ngIf="hasCategoryError(i, 'maxSeedTime', 'required')" class="form-error-text">This input is required</small>
|
||||
<small *ngIf="hasCategoryError(i, 'maxSeedTime', 'min')" class="form-error-text">Min value is -1</small>
|
||||
<small class="form-helper-text">Maximum time to seed before removing (<code>-1</code> means disabled)</small>
|
||||
<small *ngIf="hasCategoryError(i, 'maxSeedTime', 'min')" class="p-error block">Min value is -1</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Error for both maxRatio and maxSeedTime disabled -->
|
||||
<small *ngIf="hasCategoryGroupError(i, 'bothDisabled')" class="p-error block">
|
||||
<small *ngIf="hasCategoryGroupError(i, 'bothDisabled')" class="form-error-text">
|
||||
Both max ratio and max seed time cannot be disabled at the same time
|
||||
</small>
|
||||
</div>
|
||||
@@ -277,7 +269,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Unlinked Download Settings
|
||||
<span class="accordion-header-title">
|
||||
Unlinked Download Settings
|
||||
@if (sectionHasErrors(1)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div class="field-row">
|
||||
@@ -301,11 +298,9 @@
|
||||
title="Click for documentation"></i>
|
||||
Target Category
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="unlinkedTargetCategory" placeholder="Target category name" />
|
||||
</div>
|
||||
<small *ngIf="hasError('unlinkedTargetCategory', 'required')" class="p-error">Target category is required</small>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="unlinkedTargetCategory" placeholder="Target category name" />
|
||||
<small *ngIf="hasError('unlinkedTargetCategory', 'required')" class="form-error-text">Target category is required</small>
|
||||
<small class="form-helper-text">Category to move unlinked downloads to</small>
|
||||
<small class="form-helper-text">You have to create a seeding rule for this category if you want to remove the downloads</small>
|
||||
</div>
|
||||
@@ -333,10 +328,8 @@
|
||||
title="Click for documentation"></i>
|
||||
Ignored Root Directory
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="unlinkedIgnoredRootDir" placeholder="/path/to/directory" />
|
||||
</div>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="unlinkedIgnoredRootDir" placeholder="/path/to/directory" />
|
||||
<small class="form-helper-text">Root directory to ignore when checking for unlinked downloads (used for cross-seed)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,33 +337,19 @@
|
||||
<!-- Unlinked Categories -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('unlinkedCategories')"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('unlinkedCategories')"
|
||||
title="Click for documentation"></i>
|
||||
Unlinked Categories
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<!-- Mobile-friendly autocomplete -->
|
||||
<app-mobile-autocomplete
|
||||
formControlName="unlinkedCategories"
|
||||
placeholder="Add category"
|
||||
></app-mobile-autocomplete>
|
||||
|
||||
<!-- Desktop autocomplete -->
|
||||
<p-autocomplete
|
||||
formControlName="unlinkedCategories"
|
||||
multiple
|
||||
fluid
|
||||
[typeahead]="false"
|
||||
placeholder="Add category and press Enter"
|
||||
class="desktop-only"
|
||||
>
|
||||
</p-autocomplete>
|
||||
<small *ngIf="hasUnlinkedCategoriesError()" class="form-error-text">At least one category is required when unlinked download handling is enabled</small>
|
||||
<small class="form-helper-text">Categories to check for unlinked downloads</small>
|
||||
</div>
|
||||
<small *ngIf="hasUnlinkedCategoriesError()" class="p-error">At least one category is required when unlinked download handling is enabled</small>
|
||||
<small class="form-helper-text">Categories to check for unlinked downloads</small>
|
||||
</div>
|
||||
</div>
|
||||
</p-accordion-content>
|
||||
</p-accordion-panel>
|
||||
@@ -402,9 +381,7 @@
|
||||
</form>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<p-confirmDialog
|
||||
[style]="{ width: '450px' }"
|
||||
[baseZIndex]="10000"
|
||||
<p-confirmDialog
|
||||
rejectButtonStyleClass="p-button-text">
|
||||
</p-confirmDialog>
|
||||
</div>
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../styles/accordion-error-indicator.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
|
||||
.section-header {
|
||||
@@ -89,15 +90,6 @@
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.field-input {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-categories-message {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../../shared/models/download-cleaner-config.model";
|
||||
import { ScheduleUnit, ScheduleOptions } from "../../shared/models/queue-cleaner-config.model";
|
||||
import { MobileAutocompleteComponent } from "../../shared/components/mobile-autocomplete/mobile-autocomplete.component";
|
||||
import { hasIndividuallyDirtyFormErrors } from "../../core/utils/form-validation.util";
|
||||
|
||||
// PrimeNG Components
|
||||
import { CardModule } from "primeng/card";
|
||||
@@ -24,7 +25,6 @@ import { SelectButtonModule } from "primeng/selectbutton";
|
||||
import { ToastModule } from "primeng/toast";
|
||||
import { NotificationService } from "../../core/services/notification.service";
|
||||
import { SelectModule } from "primeng/select";
|
||||
import { AutoCompleteModule } from "primeng/autocomplete";
|
||||
import { DropdownModule } from "primeng/dropdown";
|
||||
import { TableModule } from "primeng/table";
|
||||
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
|
||||
@@ -48,7 +48,6 @@ import { DocumentationService } from "../../core/services/documentation.service"
|
||||
SelectButtonModule,
|
||||
ToastModule,
|
||||
SelectModule,
|
||||
AutoCompleteModule,
|
||||
DropdownModule,
|
||||
TableModule,
|
||||
LoadingErrorStateComponent,
|
||||
@@ -87,7 +86,10 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
|
||||
// Track the previous enabled state to detect when user is trying to enable
|
||||
private previousEnabledState = false;
|
||||
|
||||
|
||||
// Track the previous unlinked enabled state to detect when user is trying to enable
|
||||
private previousUnlinkedEnabledState = false;
|
||||
|
||||
// Flag to track if form has been initially loaded to avoid showing dialog on page load
|
||||
private formInitialized = false;
|
||||
|
||||
@@ -150,7 +152,7 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
unlinkedUseTag: [{ value: false, disabled: true }],
|
||||
unlinkedIgnoredRootDir: [{ value: '', disabled: true }],
|
||||
unlinkedCategories: [{ value: [], disabled: true }]
|
||||
}, { validators: this.validateUnlinkedCategories });
|
||||
}, { validators: [this.validateUnlinkedCategories, this.validateAtLeastOneFeature] });
|
||||
|
||||
// Load the current configuration
|
||||
effect(() => {
|
||||
@@ -190,9 +192,16 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
addCategory(category: CleanCategory = createDefaultCategory()): void {
|
||||
// Create a form group for the category with validation and add it to the form array
|
||||
const categoryGroup = this.createCategoryFormGroup(category);
|
||||
|
||||
|
||||
this.categoriesFormArray.push(categoryGroup);
|
||||
this.downloadCleanerForm.markAsDirty();
|
||||
|
||||
// Mark all controls in the new category as dirty to trigger validation immediately
|
||||
Object.keys(categoryGroup.controls).forEach(key => {
|
||||
categoryGroup.get(key)?.markAsDirty();
|
||||
});
|
||||
// Also mark the group itself as dirty to trigger group-level validators
|
||||
categoryGroup.markAsDirty();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,9 +210,9 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
private createCategoryFormGroup(category: CleanCategory): FormGroup {
|
||||
return this.formBuilder.group({
|
||||
name: [category.name, Validators.required],
|
||||
maxRatio: [category.maxRatio],
|
||||
minSeedTime: [category.minSeedTime, [Validators.min(0)]],
|
||||
maxSeedTime: [category.maxSeedTime],
|
||||
maxRatio: [category.maxRatio, [Validators.min(-1), Validators.required]],
|
||||
minSeedTime: [category.minSeedTime, [Validators.min(0), Validators.required]],
|
||||
maxSeedTime: [category.maxSeedTime, [Validators.min(-1), Validators.required]],
|
||||
}, { validators: this.validateCategory });
|
||||
}
|
||||
|
||||
@@ -225,8 +234,16 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
* Custom validator for unlinked categories - requires categories when unlinked handling is enabled
|
||||
*/
|
||||
private validateUnlinkedCategories(group: FormGroup): ValidationErrors | null {
|
||||
const unlinkedEnabled = group.get('unlinkedEnabled')?.value;
|
||||
const unlinkedCategories = group.get('unlinkedCategories')?.value;
|
||||
const unlinkedEnabledControl = group.get('unlinkedEnabled');
|
||||
const unlinkedCategoriesControl = group.get('unlinkedCategories');
|
||||
|
||||
// Don't validate if controls don't exist or if unlinkedCategories is disabled
|
||||
if (!unlinkedEnabledControl || !unlinkedCategoriesControl || unlinkedCategoriesControl.disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unlinkedEnabled = unlinkedEnabledControl.value;
|
||||
const unlinkedCategories = unlinkedCategoriesControl.value;
|
||||
|
||||
if (unlinkedEnabled && (!unlinkedCategories || unlinkedCategories.length === 0)) {
|
||||
return { unlinkedCategoriesRequired: true };
|
||||
@@ -234,6 +251,39 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validator to ensure at least one feature is configured when Download Cleaner is enabled
|
||||
*/
|
||||
private validateAtLeastOneFeature(group: FormGroup): ValidationErrors | null {
|
||||
const enabled = group.get('enabled')?.value;
|
||||
|
||||
// If not enabled, validation passes
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if seeding categories are configured
|
||||
const categories = group.get('categories')?.value;
|
||||
const hasSeedingCategories = categories && categories.length > 0;
|
||||
|
||||
// Check if unlinked feature is properly configured
|
||||
const unlinkedEnabled = group.get('unlinkedEnabled')?.value;
|
||||
const unlinkedCategories = group.get('unlinkedCategories')?.value;
|
||||
const unlinkedTargetCategory = group.get('unlinkedTargetCategory')?.value;
|
||||
const hasUnlinkedFeature = unlinkedEnabled &&
|
||||
unlinkedCategories &&
|
||||
unlinkedCategories.length > 0 &&
|
||||
unlinkedTargetCategory &&
|
||||
unlinkedTargetCategory.trim() !== '';
|
||||
|
||||
// At least one feature must be configured
|
||||
if (!hasSeedingCategories && !hasUnlinkedFeature) {
|
||||
return { noFeaturesConfigured: true };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get a category control as FormGroup for the template
|
||||
@@ -305,10 +355,11 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
|
||||
// Store original values for change detection
|
||||
this.storeOriginalValues();
|
||||
|
||||
|
||||
// Track the enabled state for confirmation dialog logic
|
||||
this.previousEnabledState = config.enabled;
|
||||
|
||||
this.previousUnlinkedEnabledState = config.unlinkedEnabled;
|
||||
|
||||
// Mark form as initialized to enable confirmation dialogs for user actions
|
||||
this.formInitialized = true;
|
||||
|
||||
@@ -383,7 +434,14 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
unlinkedEnabledControl.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(enabled => {
|
||||
this.updateUnlinkedControlsState(enabled);
|
||||
// Only show confirmation dialog if form is initialized and user is trying to enable
|
||||
if (this.formInitialized && enabled && !this.previousUnlinkedEnabledState) {
|
||||
this.showUnlinkedEnableConfirmationDialog();
|
||||
} else {
|
||||
// Update control states normally
|
||||
this.updateUnlinkedControlsState(enabled);
|
||||
this.previousUnlinkedEnabledState = enabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -664,6 +722,13 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
hasUnlinkedCategoriesError(): boolean {
|
||||
return this.downloadCleanerForm.dirty && this.downloadCleanerForm.hasError('unlinkedCategoriesRequired');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the form has the no features configured validation error
|
||||
*/
|
||||
hasNoFeaturesConfiguredError(): boolean {
|
||||
return this.downloadCleanerForm.dirty && this.downloadCleanerForm.hasError('noFeaturesConfigured');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get schedule value options based on the current schedule unit type
|
||||
@@ -748,6 +813,37 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an accordion section has validation errors
|
||||
* @param sectionIndex The accordion panel index
|
||||
* @returns True if the section has validation errors
|
||||
*/
|
||||
sectionHasErrors(sectionIndex: number): boolean {
|
||||
switch (sectionIndex) {
|
||||
case 0: // Seeding Settings
|
||||
const categoriesArray = this.downloadCleanerForm.get('categories') as FormArray;
|
||||
// Check if categories array has errors or if any category has errors
|
||||
if (hasIndividuallyDirtyFormErrors(categoriesArray)) {
|
||||
return true;
|
||||
}
|
||||
// Also check for group-level errors on category form groups (like bothDisabled)
|
||||
for (let i = 0; i < categoriesArray.length; i++) {
|
||||
const categoryGroup = categoriesArray.at(i) as FormGroup;
|
||||
if (categoryGroup.dirty && categoryGroup.errors && Object.keys(categoryGroup.errors).length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
case 1: // Unlinked Download Settings
|
||||
return hasIndividuallyDirtyFormErrors(this.downloadCleanerForm.get('unlinkedEnabled')) ||
|
||||
hasIndividuallyDirtyFormErrors(this.downloadCleanerForm.get('unlinkedTargetCategory')) ||
|
||||
hasIndividuallyDirtyFormErrors(this.downloadCleanerForm.get('unlinkedCategories')) ||
|
||||
this.hasUnlinkedCategoriesError();
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show confirmation dialog when enabling the download cleaner
|
||||
*/
|
||||
@@ -776,8 +872,33 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Add any other necessary methods here
|
||||
/**
|
||||
* Show confirmation dialog when enabling unlinked download handling
|
||||
*/
|
||||
private showUnlinkedEnableConfirmationDialog(): void {
|
||||
this.confirmationService.confirm({
|
||||
header: 'Enable Unlinked Download Handling',
|
||||
message: 'This feature requires your downloads directory to be accessible (and mounted if using Docker).<br/><br/>Are you sure you want to proceed?',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-check',
|
||||
rejectIcon: 'pi pi-times',
|
||||
acceptLabel: 'Yes, Enable',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptButtonStyleClass: 'p-button-warning',
|
||||
accept: () => {
|
||||
// User confirmed, update control states and track state
|
||||
this.updateUnlinkedControlsState(true);
|
||||
this.previousUnlinkedEnabledState = true;
|
||||
},
|
||||
reject: () => {
|
||||
// User cancelled, revert the checkbox without triggering value change
|
||||
const unlinkedEnabledControl = this.downloadCleanerForm.get('unlinkedEnabled');
|
||||
if (unlinkedEnabledControl) {
|
||||
unlinkedEnabledControl.setValue(false, { emitEvent: false });
|
||||
this.previousUnlinkedEnabledState = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
placeholder="My Download Client"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(clientForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
<small *ngIf="hasError(clientForm, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -169,7 +169,7 @@
|
||||
appendTo="body"
|
||||
class="w-full"
|
||||
></p-select>
|
||||
<small *ngIf="hasError(clientForm, 'typeName', 'required')" class="p-error">Client type is required</small>
|
||||
<small *ngIf="hasError(clientForm, 'typeName', 'required')" class="form-error-text">Client type is required</small>
|
||||
</div>
|
||||
|
||||
<ng-container>
|
||||
@@ -188,9 +188,9 @@
|
||||
placeholder="http://localhost:8080"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(clientForm, 'host', 'required')" class="p-error">Host is required</small>
|
||||
<small *ngIf="hasError(clientForm, 'host', 'invalidUri')" class="p-error">Host must be a valid URL</small>
|
||||
<small *ngIf="hasError(clientForm, 'host', 'invalidProtocol')" class="p-error">Host must use http or https protocol</small>
|
||||
<small *ngIf="hasError(clientForm, 'host', 'required')" class="form-error-text">Host is required</small>
|
||||
<small *ngIf="hasError(clientForm, 'host', 'invalidUri')" class="form-error-text">Host must be a valid URL</small>
|
||||
<small *ngIf="hasError(clientForm, 'host', 'invalidProtocol')" class="form-error-text">Host must use http or https protocol</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="dryRun" [binary]="true" inputId="dryRun"></p-checkbox>
|
||||
<small class="form-helper-text">When enabled, no changes will be made to the system</small>
|
||||
<small class="form-helper-text">When enabled, actions will be logged without being executed (e.g. download removal)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,18 +66,16 @@
|
||||
</i>
|
||||
Maximum HTTP Retries
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="httpMaxRetries"
|
||||
inputId="httpMaxRetries"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('httpMaxRetries', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('httpMaxRetries', 'max')" class="p-error">Maximum value is 5</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="httpMaxRetries"
|
||||
inputId="httpMaxRetries"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasError('httpMaxRetries', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasError('httpMaxRetries', 'max')" class="form-error-text">Maximum value is 5</small>
|
||||
<small class="form-helper-text">Number of retry attempts for failed HTTPS requests</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,18 +88,16 @@
|
||||
</i>
|
||||
HTTP Timeout (seconds)
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="httpTimeout"
|
||||
inputId="httpTimeout"
|
||||
[showButtons]="true"
|
||||
[min]="1"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('httpTimeout', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('httpTimeout', 'max')" class="p-error">Maximum value is 100</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="httpTimeout"
|
||||
inputId="httpTimeout"
|
||||
[showButtons]="true"
|
||||
[min]="1"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasError('httpTimeout', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasError('httpTimeout', 'max')" class="form-error-text">Maximum value is 100</small>
|
||||
<small class="form-helper-text">Timeout duration for HTTP requests in seconds</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,19 +145,17 @@
|
||||
</i>
|
||||
Search Delay (seconds)
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="searchDelay"
|
||||
inputId="searchDelay"
|
||||
[showButtons]="true"
|
||||
[min]="1"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('searchDelay', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('searchDelay', 'min')" class="p-error">Minimum value is 60</small>
|
||||
<small *ngIf="hasError('searchDelay', 'max')" class="p-error">Maximum value is 300</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="searchDelay"
|
||||
inputId="searchDelay"
|
||||
[showButtons]="true"
|
||||
[min]="1"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasError('searchDelay', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasError('searchDelay', 'min')" class="form-error-text">Minimum value is 60</small>
|
||||
<small *ngIf="hasError('searchDelay', 'max')" class="form-error-text">Maximum value is 300</small>
|
||||
<small class="form-helper-text">Delay between search operations in seconds</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,29 +163,17 @@
|
||||
<!-- Ignored Downloads -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('ignoredDownloads')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
Ignored Downloads
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<!-- Mobile-friendly autocomplete -->
|
||||
<app-mobile-autocomplete
|
||||
formControlName="ignoredDownloads"
|
||||
placeholder="Add download pattern"
|
||||
></app-mobile-autocomplete>
|
||||
|
||||
<!-- Desktop autocomplete -->
|
||||
<p-autocomplete
|
||||
formControlName="ignoredDownloads"
|
||||
inputId="ignoredDownloads"
|
||||
multiple
|
||||
fluid
|
||||
[typeahead]="false"
|
||||
placeholder="Add download pattern and press enter"
|
||||
class="desktop-only"
|
||||
></p-autocomplete>
|
||||
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,18 +223,16 @@
|
||||
</i>
|
||||
Rolling Size (MB)
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="rollingSizeMB"
|
||||
inputId="rollingSizeMB"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('log', 'rollingSizeMB', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'rollingSizeMB', 'max')" class="p-error">Maximum value is 100 MB</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="rollingSizeMB"
|
||||
inputId="rollingSizeMB"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasNestedError('log', 'rollingSizeMB', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'rollingSizeMB', 'max')" class="form-error-text">Maximum value is 100 MB</small>
|
||||
<small class="form-helper-text">Maximum size of each log file in megabytes (0 = disabled)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,18 +246,16 @@
|
||||
</i>
|
||||
Retained File Count
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="retainedFileCount"
|
||||
inputId="retainedFileCount"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('log', 'retainedFileCount', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'retainedFileCount', 'max')" class="p-error">Maximum value is 50</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="retainedFileCount"
|
||||
inputId="retainedFileCount"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasNestedError('log', 'retainedFileCount', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'retainedFileCount', 'max')" class="form-error-text">Maximum value is 50</small>
|
||||
<small class="form-helper-text">Number of old log files to retain (0 = unlimited)</small>
|
||||
<small class="form-helper-text">Files exceeding this limit will be deleted or archived</small>
|
||||
</div>
|
||||
@@ -292,18 +270,16 @@
|
||||
</i>
|
||||
Time Limit (hours)
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="timeLimitHours"
|
||||
inputId="timeLimitHours"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('log', 'timeLimitHours', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'timeLimitHours', 'max')" class="p-error">Maximum value is 1440 hours (60 days)</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="timeLimitHours"
|
||||
inputId="timeLimitHours"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasNestedError('log', 'timeLimitHours', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'timeLimitHours', 'max')" class="form-error-text">Maximum value is 1440 hours (60 days)</small>
|
||||
<small class="form-helper-text">Maximum age of old log files in hours (0 = unlimited)</small>
|
||||
<small class="form-helper-text">Files exceeding this limit will be deleted or archived</small>
|
||||
</div>
|
||||
@@ -333,18 +309,16 @@
|
||||
</i>
|
||||
Archive Retained Count
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="archiveRetainedCount"
|
||||
inputId="archiveRetainedCount"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('log', 'archiveRetainedCount', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'archiveRetainedCount', 'max')" class="p-error">Maximum value is 100</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="archiveRetainedCount"
|
||||
inputId="archiveRetainedCount"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasNestedError('log', 'archiveRetainedCount', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'archiveRetainedCount', 'max')" class="form-error-text">Maximum value is 100</small>
|
||||
<small class="form-helper-text">Number of archive files to retain (0 = unlimited)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -358,18 +332,16 @@
|
||||
</i>
|
||||
Archive Time Limit (hours)
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="archiveTimeLimitHours"
|
||||
inputId="archiveTimeLimitHours"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('log', 'archiveTimeLimitHours', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'archiveTimeLimitHours', 'max')" class="p-error">Maximum value is 1440 hours (60 days)</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="archiveTimeLimitHours"
|
||||
inputId="archiveTimeLimitHours"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
buttonLayout="horizontal"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasNestedError('log', 'archiveTimeLimitHours', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasNestedError('log', 'archiveTimeLimitHours', 'max')" class="form-error-text">Maximum value is 1440 hours (60 days)</small>
|
||||
<small class="form-helper-text">Maximum age of archive files in hours (0 = unlimited)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -399,8 +371,7 @@
|
||||
</div>
|
||||
</form>
|
||||
<!-- Confirmation Dialog -->
|
||||
<p-confirmDialog
|
||||
[style]="{ width: '500px', maxWidth: '90vw' }"
|
||||
[baseZIndex]="10000">
|
||||
<p-confirmDialog
|
||||
rejectButtonStyleClass="p-button-text">
|
||||
</p-confirmDialog>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,6 @@ import { DocumentationService } from '../../core/services/documentation.service'
|
||||
import { SelectModule } from "primeng/select";
|
||||
import { ChipsModule } from "primeng/chips";
|
||||
import { ChipModule } from "primeng/chip";
|
||||
import { AutoCompleteModule } from "primeng/autocomplete";
|
||||
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
|
||||
import { ConfirmDialogModule } from "primeng/confirmdialog";
|
||||
import { ConfirmationService } from "primeng/api";
|
||||
@@ -43,7 +42,6 @@ import { ErrorHandlerUtil } from "../../core/utils/error-handler.util";
|
||||
ChipModule,
|
||||
ToastModule,
|
||||
SelectModule,
|
||||
AutoCompleteModule,
|
||||
LoadingErrorStateComponent,
|
||||
ConfirmDialogModule,
|
||||
MobileAutocompleteComponent,
|
||||
|
||||
@@ -31,20 +31,18 @@
|
||||
<form [formGroup]="globalForm" class="p-fluid">
|
||||
<div class="field-row">
|
||||
<label class="field-label">Failed Import Max Strikes</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="form-error-text">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="form-error-text">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +182,7 @@
|
||||
placeholder="My Lidarr Instance"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -197,9 +195,9 @@
|
||||
placeholder="http://localhost:8686"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="p-error">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="p-error">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="p-error">URL must use http or https protocol</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="form-error-text">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="form-error-text">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="form-error-text">URL must use http or https protocol</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -212,7 +210,7 @@
|
||||
placeholder="Your Lidarr API key"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="p-error">API key is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="form-error-text">API key is required</small>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -29,13 +29,14 @@
|
||||
<!-- Main Settings -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('enabled')"
|
||||
title="Click for documentation"></i>
|
||||
Enable Malware Blocker
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<p-checkbox formControlName="enabled" [binary]="true" inputId="cbEnabled"></p-checkbox>
|
||||
<small *ngIf="hasNoBlocklistConfiguredError()" class="form-error-text">At least one blocklist must be configured</small>
|
||||
<small class="form-helper-text">When enabled, the Malware blocker will run according to the schedule</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,8 +68,8 @@
|
||||
<label class="field-label">
|
||||
Run Schedule
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input schedule-input flex flex-wrap">
|
||||
<div class="field-input">
|
||||
<div class="schedule-input flex flex-wrap">
|
||||
<span class="schedule-label">Every</span>
|
||||
<p-select
|
||||
formControlName="every"
|
||||
@@ -88,7 +89,7 @@
|
||||
>
|
||||
</p-selectButton>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('jobSchedule', 'every', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('jobSchedule', 'every', 'required')" class="form-error-text">This field is required</small>
|
||||
<small class="form-helper-text">How often the Malware Blocker should run</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,11 +102,9 @@
|
||||
title="Click for documentation"></i>
|
||||
Cron Expression
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
|
||||
</div>
|
||||
<small *ngIf="hasError('cronExpression', 'required')" class="p-error">Cron expression is required</small>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
|
||||
<small *ngIf="hasError('cronExpression', 'required')" class="form-error-text">Cron expression is required</small>
|
||||
<small class="form-helper-text">Enter a valid Quartz cron expression (e.g., "0 0/5 * ? * * *" runs every 5 minutes)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,29 +112,17 @@
|
||||
<!-- Ignored Downloads -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('ignoredDownloads')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
Ignored Downloads
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<!-- Mobile-friendly autocomplete -->
|
||||
<app-mobile-autocomplete
|
||||
formControlName="ignoredDownloads"
|
||||
placeholder="Add download pattern"
|
||||
></app-mobile-autocomplete>
|
||||
|
||||
<!-- Desktop autocomplete -->
|
||||
<p-autocomplete
|
||||
formControlName="ignoredDownloads"
|
||||
inputId="mb-ignoredDownloads"
|
||||
multiple
|
||||
fluid
|
||||
[typeahead]="false"
|
||||
placeholder="Add download pattern and press enter"
|
||||
class="desktop-only"
|
||||
></p-autocomplete>
|
||||
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,7 +179,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Sonarr Settings
|
||||
<span class="accordion-header-title">
|
||||
Sonarr Settings
|
||||
@if (sectionHasErrors(0)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div formGroupName="sonarr">
|
||||
@@ -216,13 +208,11 @@
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
</label>
|
||||
<p-fluid>
|
||||
<div class="field-input">
|
||||
<input pInputText formControlName="blocklistPath" placeholder="Path to blocklist file or URL" />
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('sonarr', 'blocklistPath', 'required')" class="p-error">Path is required when Sonarr blocklist is enabled</small>
|
||||
<div class="field-input">
|
||||
<input fluid pInputText formControlName="blocklistPath" placeholder="Local file path or URL" />
|
||||
<small *ngIf="hasNestedError('sonarr', 'blocklistPath', 'required')" class="form-error-text">Path is required when Sonarr blocklist is enabled</small>
|
||||
<small class="form-helper-text">Path to the blocklist file or URL</small>
|
||||
</p-fluid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
@@ -262,7 +252,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Radarr Settings
|
||||
<span class="accordion-header-title">
|
||||
Radarr Settings
|
||||
@if (sectionHasErrors(1)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div formGroupName="radarr">
|
||||
@@ -286,13 +281,11 @@
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
</label>
|
||||
<p-fluid>
|
||||
<div class="field-input">
|
||||
<input pInputText formControlName="blocklistPath" placeholder="Path to blocklist file or URL" />
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('radarr', 'blocklistPath', 'required')" class="p-error">Path is required when Radarr blocklist is enabled</small>
|
||||
<div class="field-input">
|
||||
<input fluid pInputText formControlName="blocklistPath" placeholder="Local file path or URL" />
|
||||
<small *ngIf="hasNestedError('radarr', 'blocklistPath', 'required')" class="form-error-text">Path is required when Radarr blocklist is enabled</small>
|
||||
<small class="form-helper-text">Path to the blocklist file or URL</small>
|
||||
</p-fluid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
@@ -332,7 +325,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Lidarr Settings
|
||||
<span class="accordion-header-title">
|
||||
Lidarr Settings
|
||||
@if (sectionHasErrors(2)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div formGroupName="lidarr">
|
||||
@@ -356,13 +354,11 @@
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
</label>
|
||||
<p-fluid>
|
||||
<div class="field-input">
|
||||
<input pInputText formControlName="blocklistPath" placeholder="Path to blocklist file or URL" />
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('lidarr', 'blocklistPath', 'required')" class="p-error">Path is required when Lidarr blocklist is enabled</small>
|
||||
<div class="field-input">
|
||||
<input fluid pInputText formControlName="blocklistPath" placeholder="Local file path or URL" />
|
||||
<small *ngIf="hasNestedError('lidarr', 'blocklistPath', 'required')" class="form-error-text">Path is required when Lidarr blocklist is enabled</small>
|
||||
<small class="form-helper-text">Path to the blocklist file or URL</small>
|
||||
</p-fluid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
@@ -402,7 +398,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Readarr Settings
|
||||
<span class="accordion-header-title">
|
||||
Readarr Settings
|
||||
@if (sectionHasErrors(3)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div formGroupName="readarr">
|
||||
@@ -426,13 +427,11 @@
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
</label>
|
||||
<p-fluid>
|
||||
<div class="field-input">
|
||||
<input pInputText formControlName="blocklistPath" placeholder="Path to blocklist file or URL" />
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('readarr', 'blocklistPath', 'required')" class="p-error">Path is required when Readarr blocklist is enabled</small>
|
||||
<div class="field-input">
|
||||
<input fluid pInputText formControlName="blocklistPath" placeholder="Local file path or URL" />
|
||||
<small *ngIf="hasNestedError('readarr', 'blocklistPath', 'required')" class="form-error-text">Path is required when Readarr blocklist is enabled</small>
|
||||
<small class="form-helper-text">Path to the blocklist file or URL</small>
|
||||
</p-fluid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
@@ -472,7 +471,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Whisparr Settings
|
||||
<span class="accordion-header-title">
|
||||
Whisparr Settings
|
||||
@if (sectionHasErrors(4)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div formGroupName="whisparr">
|
||||
@@ -496,13 +500,11 @@
|
||||
title="Click for documentation"></i>
|
||||
Blocklist Path
|
||||
</label>
|
||||
<p-fluid>
|
||||
<div class="field-input">
|
||||
<input pInputText formControlName="blocklistPath" placeholder="Path to blocklist file or URL" />
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('whisparr', 'blocklistPath', 'required')" class="p-error">Path is required when Whisparr blocklist is enabled</small>
|
||||
<div class="field-input">
|
||||
<input fluid pInputText formControlName="blocklistPath" placeholder="Local file path or URL" />
|
||||
<small *ngIf="hasNestedError('whisparr', 'blocklistPath', 'required')" class="form-error-text">Path is required when Whisparr blocklist is enabled</small>
|
||||
<small class="form-helper-text">Path to the blocklist file or URL</small>
|
||||
</p-fluid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../styles/accordion-error-indicator.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, ValidationErrors, Validators } from "@angular/forms";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { MalwareBlockerConfigStore } from "./malware-blocker-config.store";
|
||||
import { CanComponentDeactivate } from "../../core/guards";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ScheduleOptions
|
||||
} from "../../shared/models/malware-blocker-config.model";
|
||||
import { FluidModule } from 'primeng/fluid';
|
||||
import { hasIndividuallyDirtyFormErrors } from "../../core/utils/form-validation.util";
|
||||
|
||||
|
||||
// PrimeNG Components
|
||||
@@ -29,7 +30,6 @@ import { LoadingErrorStateComponent } from "../../shared/components/loading-erro
|
||||
import { ErrorHandlerUtil } from "../../core/utils/error-handler.util";
|
||||
import { DocumentationService } from "../../core/services/documentation.service";
|
||||
import { MobileAutocompleteComponent } from "../../shared/components/mobile-autocomplete/mobile-autocomplete.component";
|
||||
import { AutoCompleteModule } from "primeng/autocomplete";
|
||||
|
||||
@Component({
|
||||
selector: "app-malware-blocker-settings",
|
||||
@@ -49,7 +49,6 @@ import { AutoCompleteModule } from "primeng/autocomplete";
|
||||
LoadingErrorStateComponent,
|
||||
FluidModule,
|
||||
MobileAutocompleteComponent,
|
||||
AutoCompleteModule,
|
||||
],
|
||||
providers: [MalwareBlockerConfigStore],
|
||||
templateUrl: "./malware-blocker-settings.component.html",
|
||||
@@ -123,6 +122,34 @@ export class MalwareBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
this.documentationService.openFieldDocumentation('malware-blocker', fieldName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validator to ensure at least one blocklist is configured when Malware Blocker is enabled
|
||||
*/
|
||||
private validateAtLeastOneBlocklist(group: FormGroup): ValidationErrors | null {
|
||||
const enabled = group.get('enabled')?.value;
|
||||
|
||||
// If not enabled, validation passes
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if at least one *arr blocklist is enabled
|
||||
const sonarrEnabled = group.get('sonarr.enabled')?.value;
|
||||
const radarrEnabled = group.get('radarr.enabled')?.value;
|
||||
const lidarrEnabled = group.get('lidarr.enabled')?.value;
|
||||
const readarrEnabled = group.get('readarr.enabled')?.value;
|
||||
const whisparrEnabled = group.get('whisparr.enabled')?.value;
|
||||
|
||||
const hasBlocklist = sonarrEnabled || radarrEnabled || lidarrEnabled || readarrEnabled || whisparrEnabled;
|
||||
|
||||
// At least one blocklist must be configured
|
||||
if (!hasBlocklist) {
|
||||
return { noBlocklistConfigured: true };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Initialize the content blocker form with proper disabled states
|
||||
this.malwareBlockerForm = this.formBuilder.group({
|
||||
@@ -165,7 +192,7 @@ export class MalwareBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
blocklistPath: [{ value: "", disabled: true }],
|
||||
blocklistType: [{ value: BlocklistType.Blacklist, disabled: true }],
|
||||
}),
|
||||
});
|
||||
}, { validators: [this.validateAtLeastOneBlocklist] });
|
||||
|
||||
// Create an effect to update the form when the configuration changes
|
||||
effect(() => {
|
||||
@@ -409,13 +436,23 @@ export class MalwareBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
pathControl?.enable(options);
|
||||
typeControl?.enable(options);
|
||||
pathControl?.setValidators([Validators.required]);
|
||||
pathControl?.updateValueAndValidity();
|
||||
|
||||
// Mark as dirty to trigger validation display immediately
|
||||
// This ensures users see the required field error right away
|
||||
if (pathControl && !pathControl.value) {
|
||||
pathControl.markAsDirty();
|
||||
}
|
||||
} else {
|
||||
// Disable dependent controls and clear validation
|
||||
pathControl?.disable(options);
|
||||
typeControl?.disable(options);
|
||||
pathControl?.clearValidators();
|
||||
pathControl?.updateValueAndValidity();
|
||||
|
||||
// Clear dirty state when disabling
|
||||
pathControl?.markAsPristine();
|
||||
}
|
||||
pathControl?.updateValueAndValidity();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -712,6 +749,33 @@ export class MalwareBlockerSettingsComponent implements OnDestroy, CanComponentD
|
||||
const control = parentControl.get(controlName);
|
||||
return control ? control.dirty && control.hasError(errorName) : false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the form has the no blocklist configured validation error
|
||||
*/
|
||||
hasNoBlocklistConfiguredError(): boolean {
|
||||
return this.malwareBlockerForm.dirty && this.malwareBlockerForm.hasError('noBlocklistConfigured');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an accordion section has validation errors
|
||||
* @param sectionIndex The accordion panel index
|
||||
* @returns True if the section has validation errors
|
||||
*/
|
||||
sectionHasErrors(sectionIndex: number): boolean {
|
||||
switch (sectionIndex) {
|
||||
case 0: // Sonarr Settings
|
||||
return hasIndividuallyDirtyFormErrors(this.malwareBlockerForm.get('sonarr'));
|
||||
case 1: // Radarr Settings
|
||||
return hasIndividuallyDirtyFormErrors(this.malwareBlockerForm.get('radarr'));
|
||||
case 2: // Lidarr Settings
|
||||
return hasIndividuallyDirtyFormErrors(this.malwareBlockerForm.get('lidarr'));
|
||||
case 3: // Readarr Settings
|
||||
return hasIndividuallyDirtyFormErrors(this.malwareBlockerForm.get('readarr'));
|
||||
case 4: // Whisparr Settings
|
||||
return hasIndividuallyDirtyFormErrors(this.malwareBlockerForm.get('whisparr'));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +28,9 @@
|
||||
placeholder="http://localhost:8000"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasFieldError(urlControl, 'required')" class="p-error">URL is required</small>
|
||||
<small *ngIf="hasFieldError(urlControl, 'invalidUri')" class="p-error">Must be a valid URL</small>
|
||||
<small *ngIf="hasFieldError(urlControl, 'invalidProtocol')" class="p-error">Must use http or https protocol</small>
|
||||
<small *ngIf="hasFieldError(urlControl, 'required')" class="form-error-text">URL is required</small>
|
||||
<small *ngIf="hasFieldError(urlControl, 'invalidUri')" class="form-error-text">Must be a valid URL</small>
|
||||
<small *ngIf="hasFieldError(urlControl, 'invalidProtocol')" class="form-error-text">Must use http or https protocol</small>
|
||||
<small class="form-helper-text">The URL of your Apprise server where notifications will be sent.</small>
|
||||
</div>
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
Configuration Key *
|
||||
</label>
|
||||
<input id="key" type="text" pInputText [formControl]="keyControl" placeholder="my-config-key" class="w-full" />
|
||||
<small *ngIf="hasFieldError(keyControl, 'required')" class="p-error">Configuration key is required</small>
|
||||
<small *ngIf="hasFieldError(keyControl, 'minlength')" class="p-error">Key must be at least 2 characters</small>
|
||||
<small *ngIf="hasFieldError(keyControl, 'required')" class="form-error-text">Configuration key is required</small>
|
||||
<small *ngIf="hasFieldError(keyControl, 'minlength')" class="form-error-text">Key must be at least 2 characters</small>
|
||||
<small class="form-helper-text">The key that identifies your Apprise configuration on the server.</small>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
class="w-full"
|
||||
/>
|
||||
<small class="form-helper-text">A unique name to identify this provider</small>
|
||||
<small *ngIf="hasError('name', 'required')" class="p-error"> Provider name is required </small>
|
||||
<small *ngIf="hasError('name', 'required')" class="form-error-text"> Provider name is required </small>
|
||||
</div>
|
||||
|
||||
<!-- Provider-Specific Configuration (Content Projection) -->
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
[formControl]="apiKeyControl"
|
||||
placeholder="Enter your Notifiarr API key"
|
||||
class="w-full" />
|
||||
<small *ngIf="hasFieldError(apiKeyControl, 'required')" class="p-error">API Key is required</small>
|
||||
<small *ngIf="hasFieldError(apiKeyControl, 'minlength')" class="p-error">API Key must be at least 10 characters</small>
|
||||
<small *ngIf="hasFieldError(apiKeyControl, 'required')" class="form-error-text">API Key is required</small>
|
||||
<small *ngIf="hasFieldError(apiKeyControl, 'minlength')" class="form-error-text">API Key must be at least 10 characters</small>
|
||||
<small class="form-helper-text">Your Notifiarr API key from your dashboard. Requires Passthrough integration.</small>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
[formControl]="channelIdControl"
|
||||
placeholder="Enter Discord channel ID"
|
||||
class="w-full" />
|
||||
<small *ngIf="hasFieldError(channelIdControl, 'required')" class="p-error">Channel ID is required</small>
|
||||
<small *ngIf="hasFieldError(channelIdControl, 'required')" class="form-error-text">Channel ID is required</small>
|
||||
<small class="form-helper-text">The Discord channel ID where notifications will be sent.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,9 +28,9 @@
|
||||
placeholder="https://ntfy.sh"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasFieldError(serverUrlControl, 'required')" class="p-error">Server URL is required</small>
|
||||
<small *ngIf="hasFieldError(serverUrlControl, 'invalidUri')" class="p-error">Must be a valid URL</small>
|
||||
<small *ngIf="hasFieldError(serverUrlControl, 'invalidProtocol')" class="p-error">Must use http or https protocol</small>
|
||||
<small *ngIf="hasFieldError(serverUrlControl, 'required')" class="form-error-text">Server URL is required</small>
|
||||
<small *ngIf="hasFieldError(serverUrlControl, 'invalidUri')" class="form-error-text">Must be a valid URL</small>
|
||||
<small *ngIf="hasFieldError(serverUrlControl, 'invalidProtocol')" class="form-error-text">Must use http or https protocol</small>
|
||||
<small class="form-helper-text">The URL of your ntfy server. Use https://ntfy.sh for the public service or your self-hosted instance.</small>
|
||||
</div>
|
||||
|
||||
@@ -61,8 +61,8 @@
|
||||
class="desktop-only w-full"
|
||||
></p-autocomplete>
|
||||
|
||||
<small *ngIf="hasFieldError(topicsControl, 'required')" class="p-error">At least one topic is required</small>
|
||||
<small *ngIf="hasFieldError(topicsControl, 'minlength')" class="p-error">At least one topic is required</small>
|
||||
<small *ngIf="hasFieldError(topicsControl, 'required')" class="form-error-text">At least one topic is required</small>
|
||||
<small *ngIf="hasFieldError(topicsControl, 'minlength')" class="form-error-text">At least one topic is required</small>
|
||||
<small class="form-helper-text">Enter the ntfy topics you want to publish to. Press Enter or comma to add each topic.</small>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
class="w-full"
|
||||
[showClear]="false"
|
||||
></p-select>
|
||||
<small *ngIf="hasFieldError(authenticationTypeControl, 'required')" class="p-error">Authentication type is required</small>
|
||||
<small *ngIf="hasFieldError(authenticationTypeControl, 'required')" class="form-error-text">Authentication type is required</small>
|
||||
<small class="form-helper-text">Choose how to authenticate with the ntfy server.</small>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
class="w-full"
|
||||
autocomplete="username"
|
||||
/>
|
||||
<small *ngIf="hasFieldError(usernameControl, 'required')" class="p-error">Username is required for Basic Auth</small>
|
||||
<small *ngIf="hasFieldError(usernameControl, 'required')" class="form-error-text">Username is required for Basic Auth</small>
|
||||
<small class="form-helper-text">Your username for basic authentication.</small>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
class="w-full"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<small *ngIf="hasFieldError(passwordControl, 'required')" class="p-error">Password is required for Basic Auth</small>
|
||||
<small *ngIf="hasFieldError(passwordControl, 'required')" class="form-error-text">Password is required for Basic Auth</small>
|
||||
<small class="form-helper-text">Your password for basic authentication.</small>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
placeholder="Enter access token"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasFieldError(accessTokenControl, 'required')" class="p-error">Access token is required</small>
|
||||
<small *ngIf="hasFieldError(accessTokenControl, 'required')" class="form-error-text">Access token is required</small>
|
||||
<small class="form-helper-text">Your access token for bearer token authentication.</small>
|
||||
</div>
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
class="w-full"
|
||||
[showClear]="false"
|
||||
></p-select>
|
||||
<small *ngIf="hasFieldError(priorityControl, 'required')" class="p-error">Priority is required</small>
|
||||
<small *ngIf="hasFieldError(priorityControl, 'required')" class="form-error-text">Priority is required</small>
|
||||
<small class="form-helper-text">The priority level for notifications (1=min, 5=max).</small>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -67,8 +67,8 @@
|
||||
<label class="field-label">
|
||||
Run Schedule
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input schedule-input flex flex-wrap">
|
||||
<div class="field-input">
|
||||
<div class="schedule-input flex flex-wrap">
|
||||
<span class="schedule-label">Every</span>
|
||||
<p-select
|
||||
formControlName="every"
|
||||
@@ -88,7 +88,7 @@
|
||||
>
|
||||
</p-selectButton>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('jobSchedule', 'every', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('jobSchedule', 'every', 'required')" class="form-error-text">This field is required</small>
|
||||
<small class="form-helper-text">How often the queue cleaner should run</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,11 +101,9 @@
|
||||
title="Click for documentation"></i>
|
||||
Cron Expression
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
|
||||
</div>
|
||||
<small *ngIf="hasMainFormError('cronExpression', 'required')" class="p-error">Cron expression is required</small>
|
||||
<div class="field-input">
|
||||
<input type="text" pInputText formControlName="cronExpression" placeholder="0 0/5 * ? * * *" />
|
||||
<small *ngIf="hasMainFormError('cronExpression', 'required')" class="form-error-text">Cron expression is required</small>
|
||||
<small class="form-helper-text">Enter a valid Quartz cron expression (e.g., "0 0/5 * ? * * *" runs every 5 minutes)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,29 +111,17 @@
|
||||
<!-- Ignored Downloads -->
|
||||
<div class="field-row">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('ignoredDownloads')"
|
||||
title="Click for documentation">
|
||||
</i>
|
||||
Ignored Downloads
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<!-- Mobile-friendly autocomplete -->
|
||||
<app-mobile-autocomplete
|
||||
formControlName="ignoredDownloads"
|
||||
placeholder="Add download pattern"
|
||||
></app-mobile-autocomplete>
|
||||
|
||||
<!-- Desktop autocomplete -->
|
||||
<p-autocomplete
|
||||
formControlName="ignoredDownloads"
|
||||
inputId="qc-ignoredDownloads"
|
||||
multiple
|
||||
fluid
|
||||
[typeahead]="false"
|
||||
placeholder="Add download pattern and press enter"
|
||||
class="desktop-only"
|
||||
></p-autocomplete>
|
||||
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,7 +138,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Failed Import Settings
|
||||
<span class="accordion-header-title">
|
||||
Failed Import Settings
|
||||
@if (sectionHasErrors(0)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div class="field-row" formGroupName="failedImport">
|
||||
@@ -162,24 +153,20 @@
|
||||
title="Click for documentation"></i>
|
||||
Max Strikes
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="maxStrikes"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
[step]="1"
|
||||
[minFractionDigits]="0"
|
||||
[maxFractionDigits]="0"
|
||||
buttonLayout="horizontal"
|
||||
>
|
||||
</p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasNestedError('failedImport', 'maxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasNestedError('failedImport', 'maxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text"
|
||||
>Number of strikes before action is taken (0 to disable, min 3 to enable)</small
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="maxStrikes"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
[step]="1"
|
||||
[minFractionDigits]="0"
|
||||
[maxFractionDigits]="0"
|
||||
buttonLayout="horizontal"
|
||||
>
|
||||
</p-inputNumber>
|
||||
<small *ngIf="hasNestedError('failedImport', 'maxStrikes', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasNestedError('failedImport', 'maxStrikes', 'max')" class="form-error-text">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text">Number of strikes before action is taken (0 to disable, min 3 to enable)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -244,28 +231,17 @@
|
||||
|
||||
<div class="field-row" formGroupName="failedImport">
|
||||
<label class="field-label">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('failedImport.patterns')"
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
(click)="openFieldDocs('failedImport.patterns')"
|
||||
title="Click for documentation"></i>
|
||||
{{ queueCleanerForm.get('failedImport.patternMode')?.value === PatternMode.Include ? 'Included Patterns' : 'Excluded Patterns' }}
|
||||
</label>
|
||||
<div class="field-input">
|
||||
<!-- Mobile-friendly autocomplete -->
|
||||
<app-mobile-autocomplete
|
||||
formControlName="patterns"
|
||||
placeholder="Add pattern"
|
||||
></app-mobile-autocomplete>
|
||||
|
||||
<!-- Desktop autocomplete -->
|
||||
<p-autocomplete
|
||||
formControlName="patterns"
|
||||
multiple
|
||||
fluid
|
||||
[typeahead]="false"
|
||||
placeholder="Add pattern and press Enter"
|
||||
class="desktop-only"
|
||||
>
|
||||
</p-autocomplete>
|
||||
<small *ngIf="hasNestedError('failedImport', 'patterns', 'patternsRequired')" class="form-error-text">At least one pattern is required when using Include mode</small>
|
||||
<small class="form-helper-text">
|
||||
{{ queueCleanerForm.get('failedImport.patternMode')?.value === PatternMode.Include ?
|
||||
'Only failed imports containing these patterns will be removed and everything else will be skipped' :
|
||||
@@ -287,7 +263,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Downloading Metadata Settings (qBittorrent only)
|
||||
<span class="accordion-header-title">
|
||||
Downloading Metadata Settings (qBittorrent only)
|
||||
@if (sectionHasErrors(2)) {
|
||||
<i class="pi pi-exclamation-circle accordion-error-icon" title="This section has validation errors"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div class="field-row">
|
||||
@@ -297,24 +278,20 @@
|
||||
title="Click for documentation"></i>
|
||||
Max Strikes for Downloading Metadata
|
||||
</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="downloadingMetadataMaxStrikes"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
[step]="1"
|
||||
[minFractionDigits]="0"
|
||||
[maxFractionDigits]="0"
|
||||
buttonLayout="horizontal"
|
||||
>
|
||||
</p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasMainFormError('downloadingMetadataMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasMainFormError('downloadingMetadataMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text"
|
||||
>Number of strikes before action is taken (0 to disable, min 3 to enable)</small
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="downloadingMetadataMaxStrikes"
|
||||
[showButtons]="true"
|
||||
[min]="0"
|
||||
[step]="1"
|
||||
[minFractionDigits]="0"
|
||||
[maxFractionDigits]="0"
|
||||
buttonLayout="horizontal"
|
||||
>
|
||||
</p-inputNumber>
|
||||
<small *ngIf="hasMainFormError('downloadingMetadataMaxStrikes', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasMainFormError('downloadingMetadataMaxStrikes', 'max')" class="form-error-text">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text">Number of strikes before action is taken (0 to disable, min 3 to enable)</small>
|
||||
</div>
|
||||
</div>
|
||||
</p-accordion-content>
|
||||
@@ -330,7 +307,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Stalled Download Rules
|
||||
<span class="accordion-header-title">
|
||||
Stalled Download Rules
|
||||
@if (sectionHasErrors(4)) {
|
||||
<i class="pi pi-exclamation-triangle accordion-error-icon" title="Coverage gaps detected"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<!-- Coverage Analysis Warning -->
|
||||
@@ -441,7 +423,12 @@
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
}
|
||||
</ng-template>
|
||||
Slow Download Rules
|
||||
<span class="accordion-header-title">
|
||||
Slow Download Rules
|
||||
@if (sectionHasErrors(5)) {
|
||||
<i class="pi pi-exclamation-triangle accordion-error-icon" title="Coverage gaps detected"></i>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<!-- Coverage Analysis Warning -->
|
||||
@@ -599,8 +586,8 @@
|
||||
placeholder="My Stall Rule"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'name', 'maxlength')" class="p-error">Name cannot exceed 100 characters</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'name', 'maxlength')" class="form-error-text">Name cannot exceed 100 characters</small>
|
||||
</div>
|
||||
|
||||
<div class="field flex flex-row">
|
||||
@@ -630,9 +617,9 @@
|
||||
buttonLayout="horizontal"
|
||||
class="w-full"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxStrikes', 'required')" class="p-error">Max strikes is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxStrikes', 'min')" class="p-error">Min value is 3</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxStrikes', 'max')" class="p-error">Max value is 5000</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxStrikes', 'required')" class="form-error-text">Max strikes is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxStrikes', 'min')" class="form-error-text">Min value is 3</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxStrikes', 'max')" class="form-error-text">Max value is 5000</small>
|
||||
<small class="form-helper-text">Number of strikes before action is taken</small>
|
||||
</div>
|
||||
|
||||
@@ -652,7 +639,7 @@
|
||||
placeholder="Select privacy type"
|
||||
class="w-full"
|
||||
></p-select>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'privacyType', 'required')" class="p-error">Privacy type is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'privacyType', 'required')" class="form-error-text">Privacy type is required</small>
|
||||
<small class="form-helper-text">Which torrent types this rule applies to</small>
|
||||
</div>
|
||||
|
||||
@@ -673,9 +660,9 @@
|
||||
placeholder="Percentage"
|
||||
class="w-full"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'minCompletionPercentage', 'required')" class="p-error">Percentage is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'minCompletionPercentage', 'min')" class="p-error">Percentage cannot be negative</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'minCompletionPercentage', 'max')" class="p-error">Percentage cannot exceed 100</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'minCompletionPercentage', 'required')" class="form-error-text">Percentage is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'minCompletionPercentage', 'min')" class="form-error-text">Percentage cannot be negative</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'minCompletionPercentage', 'max')" class="form-error-text">Percentage cannot exceed 100</small>
|
||||
<small class="form-helper-text">Apply the rule once completion percentage exceeds this value</small>
|
||||
<small class="form-helper-text">Example: A value of 20 includes items above 20% (20 is not included)</small>
|
||||
<small class="form-helper-text">A value of 0 includes items at 0% and above</small>
|
||||
@@ -698,10 +685,10 @@
|
||||
placeholder="Percentage"
|
||||
class="w-full"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxCompletionPercentage', 'required')" class="p-error">Percentage is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxCompletionPercentage', 'min')" class="p-error">Percentage cannot be negative</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxCompletionPercentage', 'max')" class="p-error">Percentage cannot exceed 100</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxCompletionPercentage', 'minGreaterThanMax')" class="p-error">Max percentage must be greater than or equal to Min percentage</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxCompletionPercentage', 'required')" class="form-error-text">Percentage is required</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxCompletionPercentage', 'min')" class="form-error-text">Percentage cannot be negative</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxCompletionPercentage', 'max')" class="form-error-text">Percentage cannot exceed 100</small>
|
||||
<small *ngIf="hasModalError(stallRuleForm, 'maxCompletionPercentage', 'minGreaterThanMax')" class="form-error-text">Max percentage must be greater than or equal to Min percentage</small>
|
||||
<small class="form-helper-text">Apply the rule to items with a completion percentage less than or equal to this value</small>
|
||||
<small class="form-helper-text">Example: A value of 80 includes items at 80% and below</small>
|
||||
</div>
|
||||
@@ -800,8 +787,8 @@
|
||||
placeholder="My Slow Rule"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'name', 'maxlength')" class="p-error">Name cannot exceed 100 characters</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'name', 'maxlength')" class="form-error-text">Name cannot exceed 100 characters</small>
|
||||
</div>
|
||||
|
||||
<div class="field flex flex-row">
|
||||
@@ -831,9 +818,9 @@
|
||||
buttonLayout="horizontal"
|
||||
class="w-full"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxStrikes', 'required')" class="p-error">Max strikes is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxStrikes', 'min')" class="p-error">Min value is 3</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxStrikes', 'max')" class="p-error">Max value is 5000</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxStrikes', 'required')" class="form-error-text">Max strikes is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxStrikes', 'min')" class="form-error-text">Min value is 3</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxStrikes', 'max')" class="form-error-text">Max value is 5000</small>
|
||||
<small class="form-helper-text">Number of strikes before action is taken</small>
|
||||
</div>
|
||||
|
||||
@@ -851,7 +838,7 @@
|
||||
type="speed"
|
||||
>
|
||||
</app-byte-size-input>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'minSpeed', 'required')" class="p-error">Minimum speed is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'minSpeed', 'required')" class="form-error-text">Minimum speed is required</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -869,8 +856,8 @@
|
||||
[step]="1"
|
||||
class="w-full"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxTimeHours', 'required')" class="p-error">Maximum time is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxTimeHours', 'min')" class="p-error">Min value is 0</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxTimeHours', 'required')" class="form-error-text">Maximum time is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxTimeHours', 'min')" class="form-error-text">Min value is 0</small>
|
||||
<small class="form-helper-text">Maximum time allowed for slow downloads (0 means disabled)</small>
|
||||
</div>
|
||||
|
||||
@@ -890,7 +877,7 @@
|
||||
placeholder="Select privacy type"
|
||||
class="w-full"
|
||||
></p-select>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'privacyType', 'required')" class="p-error">Privacy type is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'privacyType', 'required')" class="form-error-text">Privacy type is required</small>
|
||||
<small class="form-helper-text">Which torrent types this rule applies to</small>
|
||||
</div>
|
||||
|
||||
@@ -910,9 +897,9 @@
|
||||
suffix="%"
|
||||
class="w-full"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'minCompletionPercentage', 'required')" class="p-error">Percentage is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'minCompletionPercentage', 'min')" class="p-error">Percentage cannot be negative</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'minCompletionPercentage', 'max')" class="p-error">Percentage cannot exceed 100</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'minCompletionPercentage', 'required')" class="form-error-text">Percentage is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'minCompletionPercentage', 'min')" class="form-error-text">Percentage cannot be negative</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'minCompletionPercentage', 'max')" class="form-error-text">Percentage cannot exceed 100</small>
|
||||
<small class="form-helper-text">Apply the rule once completion percentage exceeds this value (0 still includes exactly 0%)</small>
|
||||
</div>
|
||||
|
||||
@@ -932,10 +919,10 @@
|
||||
suffix="%"
|
||||
class="w-full"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxCompletionPercentage', 'required')" class="p-error">Percentage is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxCompletionPercentage', 'min')" class="p-error">Percentage cannot be negative</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxCompletionPercentage', 'max')" class="p-error">Percentage cannot exceed 100</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxCompletionPercentage', 'minGreaterThanMax')" class="p-error">Max percentage must be greater than or equal to Min percentage</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxCompletionPercentage', 'required')" class="form-error-text">Percentage is required</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxCompletionPercentage', 'min')" class="form-error-text">Percentage cannot be negative</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxCompletionPercentage', 'max')" class="form-error-text">Percentage cannot exceed 100</small>
|
||||
<small *ngIf="hasModalError(slowRuleForm, 'maxCompletionPercentage', 'minGreaterThanMax')" class="form-error-text">Max percentage must be greater than or equal to Min percentage</small>
|
||||
<small class="form-helper-text">Apply the rule up to and including this completion percentage</small>
|
||||
</div>
|
||||
|
||||
@@ -1007,4 +994,6 @@
|
||||
</p-dialog>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<p-confirmDialog></p-confirmDialog>
|
||||
<p-confirmDialog
|
||||
rejectButtonStyleClass="p-button-text">
|
||||
</p-confirmDialog>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@use '../styles/settings-shared.scss';
|
||||
@use '../styles/arr-shared.scss';
|
||||
@use '../styles/item-list-styles.scss';
|
||||
@use '../styles/accordion-error-indicator.scss';
|
||||
@use '../settings-page/settings-page.component.scss';
|
||||
|
||||
:host ::ng-deep {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PatternMode } from "../../shared/models/queue-cleaner-config.model";
|
||||
import { SettingsCardComponent } from "../components/settings-card/settings-card.component";
|
||||
import { ByteSizeInputComponent } from "../../shared/components/byte-size-input/byte-size-input.component";
|
||||
import { MobileAutocompleteComponent } from "../../shared/components/mobile-autocomplete/mobile-autocomplete.component";
|
||||
import { hasIndividuallyDirtyFormErrors } from "../../core/utils/form-validation.util";
|
||||
|
||||
// PrimeNG Components
|
||||
import { CardModule } from "primeng/card";
|
||||
@@ -30,7 +31,6 @@ import { MessageService, ConfirmationService } from "primeng/api";
|
||||
import { NotificationService } from "../../core/services/notification.service";
|
||||
import { DocumentationService } from "../../core/services/documentation.service";
|
||||
import { SelectModule } from "primeng/select";
|
||||
import { AutoCompleteModule } from "primeng/autocomplete";
|
||||
import { DropdownModule } from "primeng/dropdown";
|
||||
import { TooltipModule } from "primeng/tooltip";
|
||||
import { DialogModule } from "primeng/dialog";
|
||||
@@ -70,7 +70,6 @@ interface RuleCoverage {
|
||||
TagModule,
|
||||
ByteSizeInputComponent,
|
||||
SelectModule,
|
||||
AutoCompleteModule,
|
||||
DropdownModule,
|
||||
TooltipModule,
|
||||
DialogModule,
|
||||
@@ -155,6 +154,15 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
stallRuleModalVisible = false;
|
||||
slowRuleModalVisible = false;
|
||||
|
||||
// Track the previous pattern mode state to detect when user is trying to change to Exclude
|
||||
private previousPatternMode = PatternMode.Include;
|
||||
|
||||
// Track the previous failed import max strikes value to detect when user is trying to enable it
|
||||
private previousFailedImportMaxStrikes = 0;
|
||||
|
||||
// Flag to track if form has been initially loaded to avoid showing dialog on page load
|
||||
private formInitialized = false;
|
||||
|
||||
// Rule forms
|
||||
stallRuleForm: FormGroup;
|
||||
slowRuleForm: FormGroup;
|
||||
@@ -560,7 +568,7 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
skipIfNotFoundInClient: [{ value: true, disabled: true }],
|
||||
patterns: [{ value: [], disabled: true }],
|
||||
patternMode: [{ value: PatternMode.Include, disabled: true }],
|
||||
}),
|
||||
}, { validators: this.includePatternsRequiredValidator() }),
|
||||
|
||||
downloadingMetadataMaxStrikes: [{ value: 0, disabled: true }, [Validators.required, Validators.min(0), Validators.max(5000)]],
|
||||
});
|
||||
@@ -625,12 +633,33 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
|
||||
// Then update all other dependent form control states
|
||||
this.updateFormControlDisabledStates(correctedConfig);
|
||||
|
||||
|
||||
// Store original values for dirty checking
|
||||
this.storeOriginalValues();
|
||||
|
||||
// Track the pattern mode for confirmation dialog logic
|
||||
this.previousPatternMode = correctedConfig.failedImport?.patternMode || PatternMode.Include;
|
||||
|
||||
// Track the failed import max strikes for confirmation dialog logic
|
||||
this.previousFailedImportMaxStrikes = correctedConfig.failedImport?.maxStrikes || 0;
|
||||
|
||||
// Mark form as initialized to enable confirmation dialogs for user actions
|
||||
this.formInitialized = true;
|
||||
|
||||
// Mark form as pristine since we've just loaded the data
|
||||
this.queueCleanerForm.markAsPristine();
|
||||
|
||||
// Immediately show validation errors for patterns if Include mode is selected with no patterns
|
||||
const failedImportGroup = this.queueCleanerForm.get('failedImport');
|
||||
const patternsControl = this.queueCleanerForm.get('failedImport.patterns');
|
||||
if (failedImportGroup && patternsControl) {
|
||||
// Trigger validation
|
||||
failedImportGroup.updateValueAndValidity();
|
||||
// If there's a validation error, mark the field as touched to display it immediately
|
||||
if (patternsControl.errors?.['patternsRequired']) {
|
||||
patternsControl.markAsTouched();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -817,10 +846,53 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
if (failedImportMaxStrikesControl) {
|
||||
failedImportMaxStrikesControl.valueChanges.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((strikes) => {
|
||||
this.updateFailedImportDependentControls(strikes);
|
||||
// Only show confirmation dialog if form is initialized and user is trying to enable (>= 3)
|
||||
if (this.formInitialized && strikes >= 3 && this.previousFailedImportMaxStrikes < 3) {
|
||||
this.showFailedImportMaxStrikesConfirmationDialog(strikes);
|
||||
} else {
|
||||
// Update tracked state normally
|
||||
this.previousFailedImportMaxStrikes = strikes;
|
||||
this.updateFailedImportDependentControls(strikes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for changes to the 'failedImport.patternMode' control
|
||||
const patternModeControl = this.queueCleanerForm.get('failedImport.patternMode');
|
||||
if (patternModeControl) {
|
||||
patternModeControl.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((patternMode: PatternMode) => {
|
||||
// Only show confirmation dialog if form is initialized and user is trying to change to Exclude
|
||||
if (this.formInitialized && patternMode === PatternMode.Exclude && this.previousPatternMode !== PatternMode.Exclude) {
|
||||
this.showPatternModeExcludeConfirmationDialog();
|
||||
} else {
|
||||
// Update tracked state normally
|
||||
this.previousPatternMode = patternMode;
|
||||
}
|
||||
|
||||
// Trigger validation on the failedImport form group to update patterns validation
|
||||
const failedImportGroup = this.queueCleanerForm.get('failedImport');
|
||||
if (failedImportGroup) {
|
||||
failedImportGroup.updateValueAndValidity();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for changes to the 'failedImport.patterns' control to trigger validation
|
||||
const patternsControl = this.queueCleanerForm.get('failedImport.patterns');
|
||||
if (patternsControl) {
|
||||
patternsControl.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
// Trigger validation on the failedImport form group
|
||||
const failedImportGroup = this.queueCleanerForm.get('failedImport');
|
||||
if (failedImportGroup) {
|
||||
failedImportGroup.updateValueAndValidity();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for changes to the schedule type to ensure dropdown isn't empty
|
||||
const scheduleTypeControl = this.queueCleanerForm.get('jobSchedule.type');
|
||||
if (scheduleTypeControl) {
|
||||
@@ -1041,6 +1113,19 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
this.queueCleanerForm.get("failedImport")?.get("patterns")?.disable(options);
|
||||
this.queueCleanerForm.get("failedImport")?.get("patternMode")?.disable(options);
|
||||
}
|
||||
|
||||
// Trigger validation on the failedImport form group after enabling/disabling controls
|
||||
const failedImportGroup = this.queueCleanerForm.get('failedImport');
|
||||
const patternsControl = this.queueCleanerForm.get('failedImport.patterns');
|
||||
if (failedImportGroup) {
|
||||
failedImportGroup.updateValueAndValidity();
|
||||
|
||||
// If we just enabled the patterns control and it has a validation error, mark it as touched
|
||||
// so the error appears immediately
|
||||
if (enable && patternsControl?.errors?.['patternsRequired']) {
|
||||
patternsControl.markAsTouched();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1226,11 +1311,13 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
}
|
||||
|
||||
if (typeof minValue === 'number' && typeof maxValue === 'number' && maxValue < minValue) {
|
||||
// Set error on the max control only (for UI display)
|
||||
const existingErrors = maxControl.errors ?? {};
|
||||
if (!existingErrors['minGreaterThanMax']) {
|
||||
maxControl.setErrors({ ...existingErrors, minGreaterThanMax: true });
|
||||
}
|
||||
return { minGreaterThanMax: true };
|
||||
// Don't return an error - we've already set it on the control directly
|
||||
return null;
|
||||
}
|
||||
|
||||
this.clearMinMaxError(maxControl);
|
||||
@@ -1247,6 +1334,59 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
control.setErrors(Object.keys(remaining).length ? remaining : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validator to ensure patterns array is not empty when patternMode is Include
|
||||
*/
|
||||
private includePatternsRequiredValidator(): ValidatorFn {
|
||||
return (group: AbstractControl): ValidationErrors | null => {
|
||||
const patternModeControl = group.get('patternMode');
|
||||
const patternsControl = group.get('patterns');
|
||||
|
||||
if (!patternModeControl || !patternsControl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't validate disabled controls - clear any existing errors
|
||||
if (patternsControl.disabled) {
|
||||
this.clearPatternsRequiredError(patternsControl);
|
||||
return null;
|
||||
}
|
||||
|
||||
const patternMode = patternModeControl.value;
|
||||
const patterns = patternsControl.value;
|
||||
|
||||
// Only validate if pattern mode is Include
|
||||
if (patternMode === PatternMode.Include) {
|
||||
// Check if patterns array is empty or null
|
||||
if (!patterns || !Array.isArray(patterns) || patterns.length === 0) {
|
||||
// Set error on the patterns control only
|
||||
const existingErrors = patternsControl.errors ?? {};
|
||||
if (!existingErrors['patternsRequired']) {
|
||||
patternsControl.setErrors({ ...existingErrors, patternsRequired: true });
|
||||
}
|
||||
// Don't return an error - we've already set it on the control directly
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the error if validation passes
|
||||
this.clearPatternsRequiredError(patternsControl);
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the patternsRequired error from the control
|
||||
*/
|
||||
private clearPatternsRequiredError(control: AbstractControl): void {
|
||||
if (!control.errors || !control.errors['patternsRequired']) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { patternsRequired, ...remaining } = control.errors;
|
||||
control.setErrors(Object.keys(remaining).length ? remaining : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the default values for the stall rule form
|
||||
*/
|
||||
@@ -1298,4 +1438,79 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea
|
||||
return 'Public and Private';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an accordion section has validation errors
|
||||
* @param sectionIndex The accordion panel index
|
||||
* @returns True if the section has validation errors
|
||||
*/
|
||||
sectionHasErrors(sectionIndex: number): boolean {
|
||||
switch (sectionIndex) {
|
||||
case 0: // Failed Import Settings
|
||||
return hasIndividuallyDirtyFormErrors(this.queueCleanerForm.get('failedImport'));
|
||||
case 2: // Downloading Metadata Settings
|
||||
return hasIndividuallyDirtyFormErrors(this.queueCleanerForm.get('downloadingMetadataMaxStrikes'));
|
||||
case 4: // Stall Rules - has errors if coverage gaps exist
|
||||
return this.stallRulesCoverage.hasGaps;
|
||||
case 5: // Slow Rules - has errors if coverage gaps exist
|
||||
return this.slowRulesCoverage.hasGaps;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show confirmation dialog when changing pattern mode to Exclude
|
||||
*/
|
||||
private showPatternModeExcludeConfirmationDialog(): void {
|
||||
this.confirmationService.confirm({
|
||||
header: 'Switch to Exclude Pattern Mode',
|
||||
message: 'The Exclude Pattern Mode is <b>very aggressive</b> and will <b>remove all failed imports</b> that are not matched by the Excluded Patterns.<br/><br/>Are you sure you want to proceed?',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-check',
|
||||
rejectIcon: 'pi pi-times',
|
||||
acceptLabel: 'Yes, Switch to Exclude',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptButtonStyleClass: 'p-button-warning',
|
||||
accept: () => {
|
||||
// User confirmed, update tracked state
|
||||
this.previousPatternMode = PatternMode.Exclude;
|
||||
},
|
||||
reject: () => {
|
||||
// User cancelled, revert the select button without triggering value change
|
||||
const patternModeControl = this.queueCleanerForm.get('failedImport.patternMode');
|
||||
if (patternModeControl) {
|
||||
patternModeControl.setValue(this.previousPatternMode, { emitEvent: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show confirmation dialog when enabling failed import max strikes (>= 3)
|
||||
*/
|
||||
private showFailedImportMaxStrikesConfirmationDialog(newStrikesValue: number): void {
|
||||
this.confirmationService.confirm({
|
||||
header: 'Enable Failed Import Processing',
|
||||
message: 'If you are using <b>private torrent trackers</b>, please ensure that your download clients have been configured and enabled, otherwise you may <b>risk having private torrents deleted before seeding</b> the minimum required amount.<br/><br/>Are you sure you want to enable Failed Import processing?',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-check',
|
||||
rejectIcon: 'pi pi-times',
|
||||
acceptLabel: 'Yes, Enable',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptButtonStyleClass: 'p-button-warning',
|
||||
accept: () => {
|
||||
// User confirmed, update tracked state and apply changes
|
||||
this.previousFailedImportMaxStrikes = newStrikesValue;
|
||||
this.updateFailedImportDependentControls(newStrikesValue);
|
||||
},
|
||||
reject: () => {
|
||||
// User cancelled, revert the value without triggering value change
|
||||
const maxStrikesControl = this.queueCleanerForm.get('failedImport.maxStrikes');
|
||||
if (maxStrikesControl) {
|
||||
maxStrikesControl.setValue(this.previousFailedImportMaxStrikes, { emitEvent: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,20 +31,18 @@
|
||||
<form [formGroup]="globalForm" class="p-fluid">
|
||||
<div class="field-row">
|
||||
<label class="field-label">Failed Import Max Strikes</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="form-error-text">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="form-error-text">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +182,7 @@
|
||||
placeholder="My Radarr Instance"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -197,9 +195,9 @@
|
||||
placeholder="http://localhost:7878"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="p-error">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="p-error">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="p-error">URL must use http or https protocol</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="form-error-text">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="form-error-text">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="form-error-text">URL must use http or https protocol</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -212,7 +210,7 @@
|
||||
placeholder="Your Radarr API key"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="p-error">API key is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="form-error-text">API key is required</small>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -31,20 +31,18 @@
|
||||
<form [formGroup]="globalForm" class="p-fluid">
|
||||
<div class="field-row">
|
||||
<label class="field-label">Failed Import Max Strikes</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="form-error-text">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="form-error-text">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +182,7 @@
|
||||
placeholder="My Readarr Instance"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -197,9 +195,9 @@
|
||||
placeholder="http://localhost:8787"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="p-error">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="p-error">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="p-error">URL must use http or https protocol</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="form-error-text">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="form-error-text">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="form-error-text">URL must use http or https protocol</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -212,7 +210,7 @@
|
||||
placeholder="Your Readarr API key"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="p-error">API key is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="form-error-text">API key is required</small>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -48,22 +48,26 @@
|
||||
font-weight: 500;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
width: 70%;
|
||||
|
||||
.form-helper-text {
|
||||
display: block;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
input, .p-select, .p-autocomplete, .p-inputnumber {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field-input {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.form-helper-text {
|
||||
display: block;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.form-error-text {
|
||||
display: block;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: red;
|
||||
}
|
||||
|
||||
/* Card styling */
|
||||
::ng-deep {
|
||||
|
||||
@@ -31,20 +31,18 @@
|
||||
<form [formGroup]="globalForm" class="p-fluid">
|
||||
<div class="field-row">
|
||||
<label class="field-label">Failed Import Max Strikes</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="form-error-text">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="form-error-text">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +182,7 @@
|
||||
placeholder="My Sonarr Instance"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -197,9 +195,9 @@
|
||||
placeholder="http://localhost:8989"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="p-error">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="p-error">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="p-error">URL must use http or https protocol</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="form-error-text">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="form-error-text">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="form-error-text">URL must use http or https protocol</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -212,7 +210,7 @@
|
||||
placeholder="Your Sonarr API key"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="p-error">API key is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="form-error-text">API key is required</small>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Shared styles for accordion error indicators
|
||||
// Used across multiple settings pages to show validation errors on closed accordion panels
|
||||
|
||||
:host ::ng-deep {
|
||||
// Accordion header title with error indicator
|
||||
.accordion-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// Error icon styling
|
||||
.accordion-error-icon {
|
||||
color: var(--red-500);
|
||||
font-size: 1rem;
|
||||
margin-left: 0.5rem;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
// Subtle pulse animation for error icon
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,15 +88,6 @@
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.field-input {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-instances-message {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/* Confirmation Dialog Customization */
|
||||
/* Shared styles for PrimeNG confirmation dialogs across settings components */
|
||||
|
||||
::ng-deep .p-confirmdialog {
|
||||
width: 450px;
|
||||
|
||||
.p-dialog-content {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.p-confirm-dialog-icon {
|
||||
margin: 0;
|
||||
font-size: 3rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.p-confirm-dialog-message {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../styles/confirmation-dialog.scss';
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
@@ -27,6 +29,7 @@
|
||||
.field-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
|
||||
.field-info-icon {
|
||||
flex-shrink: 0;
|
||||
@@ -99,35 +102,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Form layout and structure */
|
||||
// .field-row {
|
||||
// display: flex;
|
||||
// margin-bottom: 1.25rem;
|
||||
// align-items: flex-start;
|
||||
|
||||
// &:last-child {
|
||||
// margin-bottom: 0;
|
||||
// }
|
||||
// }
|
||||
|
||||
.field-label {
|
||||
flex: 0 0 230px;
|
||||
// padding-top: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
flex: 1;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.form-helper-text {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Schedule input specific styles */
|
||||
.schedule-input {
|
||||
display: flex;
|
||||
@@ -156,26 +130,4 @@
|
||||
gap: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
// .field-row {
|
||||
// flex-direction: column;
|
||||
// }
|
||||
|
||||
.field-label {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.p-error {
|
||||
color: red;
|
||||
}
|
||||
@@ -31,20 +31,18 @@
|
||||
<form [formGroup]="globalForm" class="p-fluid">
|
||||
<div class="field-row">
|
||||
<label class="field-label">Failed Import Max Strikes</label>
|
||||
<div>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
</div>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="p-error">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="p-error">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="p-error">Value cannot exceed 5000</small>
|
||||
<div class="field-input">
|
||||
<p-inputNumber
|
||||
formControlName="failedImportMaxStrikes"
|
||||
[min]="-1"
|
||||
[showButtons]="true"
|
||||
buttonLayout="horizontal"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
></p-inputNumber>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'required')" class="form-error-text">This field is required</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'min')" class="form-error-text">Value cannot be less than -1</small>
|
||||
<small *ngIf="hasError('failedImportMaxStrikes', 'max')" class="form-error-text">Value cannot exceed 5000</small>
|
||||
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +182,7 @@
|
||||
placeholder="My Whisparr Instance"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="p-error">Name is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="form-error-text">Name is required</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -197,9 +195,9 @@
|
||||
placeholder="http://localhost:6969"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="p-error">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="p-error">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="p-error">URL must use http or https protocol</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="form-error-text">URL is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="form-error-text">URL must be a valid URL</small>
|
||||
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="form-error-text">URL must use http or https protocol</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -212,7 +210,7 @@
|
||||
placeholder="Your Whisparr API key"
|
||||
class="w-full"
|
||||
/>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="p-error">API key is required</small>
|
||||
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="form-error-text">API key is required</small>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -8,11 +8,4 @@
|
||||
min-width: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form-helper-text {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<div class="mobile-autocomplete-container">
|
||||
<div class="input-with-button" [class.has-uncommitted-input]="hasUncommittedInput && !disabled">
|
||||
<input
|
||||
type="text"
|
||||
pInputText
|
||||
#inputField
|
||||
[placeholder]="placeholder"
|
||||
[ngModel]="currentInputValue"
|
||||
(ngModelChange)="onInputChange($event)"
|
||||
(keyup.enter)="addItemAndClearInput(inputField)"
|
||||
(blur)="onInputBlur()"
|
||||
[class.ng-invalid]="hasUncommittedInput && !disabled && (touched || currentInputValue.length > 0)"
|
||||
[class.ng-dirty]="hasUncommittedInput && !disabled"
|
||||
class="mobile-input"
|
||||
[disabled]="disabled"
|
||||
/>
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
icon="pi pi-plus"
|
||||
class="p-button-sm add-button"
|
||||
(click)="addItemAndClearInput(inputField)"
|
||||
[title]="'Add ' + placeholder"
|
||||
[disabled]="disabled || !inputField.value.trim()"
|
||||
></button>
|
||||
</div>
|
||||
<small *ngIf="hasUncommittedInput && !disabled && (touched || currentInputValue.length > 0)" class="form-error-text">
|
||||
Press Enter or click + to add this item
|
||||
</small>
|
||||
<div class="chips-container" *ngIf="value && value.length > 0">
|
||||
<p-chip
|
||||
*ngFor="let item of value; let i = index"
|
||||
[label]="item"
|
||||
[removable]="!disabled"
|
||||
(onRemove)="removeItem(i)"
|
||||
[ngClass]="{'chip-disabled': disabled}"
|
||||
class="mb-2 mr-2"
|
||||
></p-chip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,4 +1,7 @@
|
||||
/* Mobile-friendly autocomplete styles */
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-autocomplete-container {
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
@@ -6,9 +9,26 @@
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&.has-uncommitted-input {
|
||||
.mobile-input {
|
||||
border-color: var(--red-500, #ef4444);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--red-600, #dc2626);
|
||||
box-shadow: 0 0 0 0.2rem rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-input {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&.ng-invalid.ng-dirty,
|
||||
&.ng-invalid.ng-touched {
|
||||
border-color: var(--red-500, #ef4444);
|
||||
}
|
||||
}
|
||||
|
||||
.add-button {
|
||||
@@ -23,19 +43,16 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
::ng-deep .chip-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
|
||||
.p-chip {
|
||||
background: var(--surface-200) !important;
|
||||
color: var(--text-color-secondary) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive design - show mobile component on mobile devices */
|
||||
@media (max-width: 768px) {
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide mobile component on larger screens */
|
||||
@media (min-width: 769px) {
|
||||
:host {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, Input, Output, EventEmitter, forwardRef } from '@angular/core';
|
||||
import { Component, Input, forwardRef, ViewChild, ElementRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS, FormsModule, AbstractControl, ValidationErrors, Validator } from '@angular/forms';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { ChipModule } from 'primeng/chip';
|
||||
@@ -20,51 +20,42 @@ import { ChipModule } from 'primeng/chip';
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => MobileAutocompleteComponent),
|
||||
multi: true
|
||||
},
|
||||
{
|
||||
provide: NG_VALIDATORS,
|
||||
useExisting: forwardRef(() => MobileAutocompleteComponent),
|
||||
multi: true
|
||||
}
|
||||
],
|
||||
template: `
|
||||
<div class="mobile-autocomplete-container">
|
||||
<div class="input-with-button">
|
||||
<input
|
||||
type="text"
|
||||
pInputText
|
||||
#inputField
|
||||
[placeholder]="placeholder"
|
||||
(keyup.enter)="addItem(inputField.value); inputField.value = ''"
|
||||
class="mobile-input"
|
||||
/>
|
||||
<button
|
||||
pButton
|
||||
type="button"
|
||||
icon="pi pi-plus"
|
||||
class="p-button-sm add-button"
|
||||
(click)="addItem(inputField.value); inputField.value = ''"
|
||||
[title]="'Add ' + placeholder"
|
||||
></button>
|
||||
</div>
|
||||
<div class="chips-container" *ngIf="value && value.length > 0">
|
||||
<p-chip
|
||||
*ngFor="let item of value; let i = index"
|
||||
[label]="item"
|
||||
[removable]="true"
|
||||
(onRemove)="removeItem(i)"
|
||||
class="mb-2 mr-2"
|
||||
></p-chip>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
templateUrl: './mobile-autocomplete.component.html',
|
||||
styleUrls: ['./mobile-autocomplete.component.scss']
|
||||
})
|
||||
export class MobileAutocompleteComponent implements ControlValueAccessor {
|
||||
export class MobileAutocompleteComponent implements ControlValueAccessor, Validator {
|
||||
@Input() placeholder: string = 'Add item and press Enter';
|
||||
@Input() multiple: boolean = true;
|
||||
|
||||
@ViewChild('inputField', { static: false }) inputField?: ElementRef<HTMLInputElement>;
|
||||
|
||||
value: string[] = [];
|
||||
disabled: boolean = false;
|
||||
currentInputValue: string = '';
|
||||
hasUncommittedInput: boolean = false;
|
||||
touched: boolean = false;
|
||||
|
||||
// ControlValueAccessor implementation
|
||||
private onChange = (value: string[]) => {};
|
||||
private onTouched = () => {};
|
||||
private onValidatorChange = () => {};
|
||||
|
||||
onInputChange(value: string): void {
|
||||
this.currentInputValue = value;
|
||||
this.hasUncommittedInput = value.trim().length > 0;
|
||||
this.onValidatorChange();
|
||||
}
|
||||
|
||||
onInputBlur(): void {
|
||||
this.touched = true;
|
||||
this.onTouched();
|
||||
this.onValidatorChange();
|
||||
}
|
||||
|
||||
writeValue(value: string[]): void {
|
||||
this.value = value || [];
|
||||
@@ -80,22 +71,49 @@ export class MobileAutocompleteComponent implements ControlValueAccessor {
|
||||
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled = isDisabled;
|
||||
|
||||
// Clear uncommitted input when becoming disabled
|
||||
if (isDisabled && this.hasUncommittedInput) {
|
||||
this.currentInputValue = '';
|
||||
this.hasUncommittedInput = false;
|
||||
this.onValidatorChange();
|
||||
}
|
||||
}
|
||||
|
||||
validate(control: AbstractControl): ValidationErrors | null {
|
||||
// Don't report validation errors when disabled
|
||||
if (this.hasUncommittedInput && !this.disabled) {
|
||||
return { uncommittedInput: { value: this.currentInputValue } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
registerOnValidatorChange(fn: () => void): void {
|
||||
this.onValidatorChange = fn;
|
||||
}
|
||||
|
||||
addItem(item: string): void {
|
||||
if (item && item.trim() && !this.disabled) {
|
||||
const trimmedItem = item.trim();
|
||||
|
||||
// Check if item already exists
|
||||
|
||||
if (!this.value.includes(trimmedItem)) {
|
||||
const newValue = [...this.value, trimmedItem];
|
||||
this.value = newValue;
|
||||
this.onChange(this.value);
|
||||
this.onTouched();
|
||||
}
|
||||
|
||||
this.currentInputValue = '';
|
||||
this.hasUncommittedInput = false;
|
||||
this.onValidatorChange();
|
||||
}
|
||||
}
|
||||
|
||||
addItemAndClearInput(inputField: HTMLInputElement): void {
|
||||
this.addItem(inputField.value);
|
||||
inputField.value = '';
|
||||
this.onInputChange('');
|
||||
}
|
||||
|
||||
removeItem(index: number): void {
|
||||
if (!this.disabled) {
|
||||
const newValue = this.value.filter((_, i) => i !== index);
|
||||
@@ -104,4 +122,4 @@ export class MobileAutocompleteComponent implements ControlValueAccessor {
|
||||
this.onTouched();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ services:
|
||||
- "11011:11011"
|
||||
volumes:
|
||||
- /path/to/config:/config
|
||||
# Mount your downloads directory if needed
|
||||
- /path/to/downloads:/downloads
|
||||
environment:
|
||||
- PORT=11011
|
||||
- BASE_PATH=
|
||||
@@ -105,6 +107,7 @@ services:
|
||||
| Container Path | Description |
|
||||
|----------------|-------------|
|
||||
| `/config` | Configuration files, log files and database |
|
||||
| `/downloads` | (Optional) Mount your downloads directory if using [Unlinked download settings](/docs/configuration/download-cleaner/?unlinked-download-settings) |
|
||||
|
||||
<Note>
|
||||
Replace `/path/to/config` with your desired configuration directory path on the host system.
|
||||
|
||||
28
docs/package-lock.json
generated
28
docs/package-lock.json
generated
@@ -5653,9 +5653,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -8351,9 +8352,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter/node_modules/js-yaml": {
|
||||
"version": "3.14.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
|
||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
@@ -9425,9 +9427,10 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
@@ -12037,9 +12040,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
|
||||
"integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.2.tgz",
|
||||
"integrity": "sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==",
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
}
|
||||
|
||||
@@ -3223,9 +3223,9 @@ boxen@^7.0.0:
|
||||
wrap-ansi "^8.1.0"
|
||||
|
||||
brace-expansion@^1.1.7:
|
||||
version "1.1.11"
|
||||
resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz"
|
||||
integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
|
||||
version "1.1.12"
|
||||
resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz"
|
||||
integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==
|
||||
dependencies:
|
||||
balanced-match "^1.0.0"
|
||||
concat-map "0.0.1"
|
||||
@@ -5510,17 +5510,17 @@ joi@^17.9.2:
|
||||
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||
|
||||
js-yaml@^3.13.1:
|
||||
version "3.14.1"
|
||||
resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz"
|
||||
integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==
|
||||
version "3.14.2"
|
||||
resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz"
|
||||
integrity sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==
|
||||
dependencies:
|
||||
argparse "^1.0.7"
|
||||
esprima "^4.0.0"
|
||||
|
||||
js-yaml@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz"
|
||||
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
|
||||
version "4.1.1"
|
||||
resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz"
|
||||
integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
@@ -6587,9 +6587,9 @@ node-emoji@^2.1.0:
|
||||
skin-tone "^2.0.0"
|
||||
|
||||
node-forge@^1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz"
|
||||
integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==
|
||||
version "1.3.2"
|
||||
resolved "https://registry.npmjs.org/node-forge/-/node-forge-1.3.2.tgz"
|
||||
integrity sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==
|
||||
|
||||
node-releases@^2.0.26:
|
||||
version "2.0.26"
|
||||
|
||||
Reference in New Issue
Block a user