Compare commits

..

11 Commits

Author SHA1 Message Date
Flaminel
ac3be75082 Fix workflow dispatch defaulting to dev version (#379) 2025-11-30 22:53:23 +02:00
Flaminel
a1663b865a Improve workflow dispatch (#378) 2025-11-30 22:27:40 +02:00
Flaminel
c97a416d1e Fix windows workflow (#377) 2025-11-30 16:22:21 +02:00
Flaminel
d28ab42303 Fix frontend workflow using assets instead of cache (#376) 2025-11-30 15:48:53 +02:00
Flaminel
fbb2bba3b6 Update packages (#375) 2025-11-30 13:14:29 +02:00
Flaminel
08eda22587 Add test workflow and improve workflow parallelization (#369) 2025-11-25 23:05:28 +02:00
Flaminel
a4045eebd3 Add downloads volume to detailed installation docs (#365) 2025-11-22 22:15:37 +02:00
Flaminel
a57cbccbb4 Improve UI validations (#366) 2025-11-22 22:14:50 +02:00
Flaminel
2221f118bb Fix qBittorrent tracker check (#363) 2025-11-09 19:03:48 +02:00
Flaminel
2cc3eb4ebb Fix ignored downloads not checking for certain fields (#362) 2025-11-09 18:24:26 +02:00
Flaminel
3a064a22bd Remove hardcoded app status timeout (#356) 2025-11-03 18:38:09 +02:00
72 changed files with 5401 additions and 3310 deletions

View 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 }}

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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
View 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
View 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}"

View File

@@ -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
![Version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fcleanuparr-status.pages.dev%2Fstatus.json&query=%24.version&logo=git&label=version&color=blue)
![Total Downloads](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2FCleanuparr%2FCleanuparr%2Fcleanuparr&query=%24.downloadCount&style=flat&logo=docker&label=Total%20Downloads&color=blue)
[![Tests](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml)
[![Discord](https://img.shields.io/discord/1306721212587573389?color=7289DA&label=Discord&style=for-the-badge&logo=discord)](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.

View File

@@ -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

View File

@@ -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>

View File

@@ -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();

View File

@@ -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);

View File

@@ -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();

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
</Project>

View File

@@ -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>

View File

@@ -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()
{

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)
{

View File

@@ -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();

View File

@@ -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>

View File

@@ -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)

View File

@@ -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))

View File

@@ -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}");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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',

View 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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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;
}
}
});
}
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>

View File

@@ -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">

View File

@@ -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';

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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) -->

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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 });
}
}
});
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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
View File

@@ -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"
}

View File

@@ -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"