diff --git a/.github/ISSUE_TEMPLATE/1-bug.yml b/.github/ISSUE_TEMPLATE/1-bug.yml index ee50073b..731cc971 100644 --- a/.github/ISSUE_TEMPLATE/1-bug.yml +++ b/.github/ISSUE_TEMPLATE/1-bug.yml @@ -6,7 +6,7 @@ body: - type: markdown attributes: value: | - Thanks for taking the time to improve cleanuperr! + Thanks for taking the time to improve Cleanuparr! - type: checkboxes id: init attributes: @@ -14,7 +14,7 @@ body: options: - label: Reviewed the documentation. required: true - - label: Ensured I am using ghcr.io/flmorg/cleanuperr docker repository. + - label: Ensured I am using ghcr.io/Cleanuparr/Cleanuparr docker repository. required: true - label: Ensured I am using the latest version. required: true diff --git a/.github/ISSUE_TEMPLATE/2-feature.yml b/.github/ISSUE_TEMPLATE/2-feature.yml index 2496f7ff..1d6bcd95 100644 --- a/.github/ISSUE_TEMPLATE/2-feature.yml +++ b/.github/ISSUE_TEMPLATE/2-feature.yml @@ -6,7 +6,7 @@ body: - type: markdown attributes: value: | - Thanks for taking the time to improve cleanuperr! + Thanks for taking the time to improve Cleanuparr! - type: textarea id: description attributes: diff --git a/.github/ISSUE_TEMPLATE/3-help.yml b/.github/ISSUE_TEMPLATE/3-help.yml index 2e7828c4..5dc31004 100644 --- a/.github/ISSUE_TEMPLATE/3-help.yml +++ b/.github/ISSUE_TEMPLATE/3-help.yml @@ -14,7 +14,7 @@ body: options: - label: Reviewed the documentation. required: true - - label: Ensured I am using ghcr.io/flmorg/cleanuperr docker repository. + - label: Ensured I am using ghcr.io/Cleanuparr/Cleanuparr docker repository. required: true - label: Ensured I am using the latest version. required: true diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml new file mode 100644 index 00000000..0816a111 --- /dev/null +++ b/.github/workflows/build-docker.yml @@ -0,0 +1,125 @@ +name: Build Docker Images + +on: + push: + tags: + - "v*.*.*" + pull_request: + paths: + - 'code/**' + workflow_dispatch: + workflow_call: + +jobs: + build_app: + runs-on: ubuntu-latest + steps: + + - name: Set github context + timeout-minutes: 1 + run: | + echo 'githubRepository=${{ github.repository }}' >> $GITHUB_ENV + echo 'githubSha=${{ github.sha }}' >> $GITHUB_ENV + echo 'githubRef=${{ github.ref }}' >> $GITHUB_ENV + echo 'githubHeadRef=${{ github.head_ref }}' >> $GITHUB_ENV + + - name: Initialize build info + timeout-minutes: 1 + run: | + githubHeadRef=${{ env.githubHeadRef }} + latestDockerTag="" + versionDockerTag="" + version="0.0.1" + + if [[ "$githubRef" =~ ^"refs/tags/" ]]; then + branch=${githubRef##*/} + latestDockerTag="latest" + versionDockerTag=${branch#v} + version=${branch#v} + else + # Determine if this run is for the main branch or another branch + if [[ -z "$githubHeadRef" ]]; then + # Main branch + githubRef=${{ env.githubRef }} + branch=${githubRef##*/} + versionDockerTag="$branch" + else + # Pull request + branch=$githubHeadRef + versionDockerTag="$branch" + fi + fi + + githubTags="" + + if [ -n "$latestDockerTag" ]; then + githubTags="$githubTags,ghcr.io/cleanuparr:$latestDockerTag" + fi + + if [ -n "$versionDockerTag" ]; then + githubTags="$githubTags,ghcr.io/cleanuparr:$versionDockerTag" + fi + + # set env vars + echo "branch=$branch" >> $GITHUB_ENV + echo "githubTags=$githubTags" >> $GITHUB_ENV + echo "versionDockerTag=$versionDockerTag" >> $GITHUB_ENV + echo "version=$version" >> $GITHUB_ENV + + - name: Get vault secrets + uses: hashicorp/vault-action@v2 + with: + url: ${{ secrets.VAULT_HOST }} + method: approle + roleId: ${{ secrets.VAULT_ROLE_ID }} + secretId: ${{ secrets.VAULT_SECRET_ID }} + secrets: + secrets/data/docker username | DOCKER_USERNAME; + secrets/data/docker password | DOCKER_PASSWORD; + secrets/data/github repo_readonly_pat | REPO_READONLY_PAT; + secrets/data/github packages_pat | PACKAGES_PAT + + - name: Checkout target repository + uses: actions/checkout@v4 + timeout-minutes: 1 + with: + repository: ${{ env.githubRepository }} + ref: ${{ env.branch }} + token: ${{ env.REPO_READONLY_PAT }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + timeout-minutes: 5 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push docker image + timeout-minutes: 15 + uses: docker/build-push-action@v6 + with: + context: ${{ github.workspace }}/code + file: ${{ github.workspace }}/code/Dockerfile + provenance: false + labels: | + commit=sha-${{ env.githubSha }} + version=${{ env.versionDockerTag }} + build-args: | + VERSION=${{ env.version }} + PACKAGES_USERNAME=${{ env.PACKAGES_USERNAME }} + PACKAGES_PAT=${{ env.PACKAGES_PAT }} + outputs: | + type=image + platforms: | + linux/amd64 + linux/arm64 + push: true + tags: | + ${{ env.githubTags }} \ No newline at end of file diff --git a/.github/workflows/build-executable.yml b/.github/workflows/build-executable.yml new file mode 100644 index 00000000..ed26f570 --- /dev/null +++ b/.github/workflows/build-executable.yml @@ -0,0 +1,177 @@ +name: Build Executables + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + workflow_call: + +jobs: + build: + runs-on: ubuntu-latest + 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 + 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 + + echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV + echo "githubRepositoryName=${repoFullName#*/}" >> $GITHUB_ENV + echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV + echo "appVersion=$appVersion" >> $GITHUB_ENV + echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV + + - name: Get vault secrets + uses: hashicorp/vault-action@v2 + with: + url: ${{ secrets.VAULT_HOST }} + method: approle + roleId: ${{ secrets.VAULT_ROLE_ID }} + secretId: ${{ secrets.VAULT_SECRET_ID }} + secrets: + secrets/data/github repo_readonly_pat | REPO_READONLY_PAT; + secrets/data/github packages_pat | PACKAGES_PAT + + - name: Checkout target repository + uses: actions/checkout@v4 + timeout-minutes: 1 + with: + repository: ${{ env.githubRepository }} + ref: ${{ github.ref_name }} + token: ${{ env.REPO_READONLY_PAT }} + + - name: Setup 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: 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 restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj + + - name: Copy frontend to backend wwwroot + run: | + mkdir -p code/backend/${{ env.executableName }}/wwwroot + cp -r code/frontend/dist/ui/browser/* code/backend/${{ env.executableName }}/wwwroot/ + + - name: Build 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 linux-x64 + run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime linux-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false + + - name: Build linux-arm64 + run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime linux-arm64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false + + - name: Build osx-x64 + run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime osx-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false + + - name: Build osx-arm64 + run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime osx-arm64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false + + - name: Create sample configuration files + run: | + # Create a sample appsettings.json for each platform + cat > sample-config.json << 'EOF' + { + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" + } + EOF + + # Copy to each build directory + cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64/appsettings.json + cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64/appsettings.json + cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64/appsettings.json + cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64/appsettings.json + cp sample-config.json artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64/appsettings.json + + - name: Zip win-x64 + run: | + cd ./artifacts + zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64/ + + - name: Zip linux-x64 + run: | + cd ./artifacts + zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64/ + + - name: Zip linux-arm64 + run: | + cd ./artifacts + zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64/ + + - name: Zip osx-x64 + run: | + cd ./artifacts + zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64/ + + - name: Zip osx-arm64 + run: | + cd ./artifacts + zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64/ + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: cleanuparr-executables + path: | + ./artifacts/*.zip + retention-days: 30 + + - name: Release + if: startsWith(github.ref, 'refs/tags/') + id: release + uses: softprops/action-gh-release@v2 + with: + name: ${{ env.releaseVersion }} + tag_name: ${{ env.releaseVersion }} + repository: ${{ env.githubRepository }} + token: ${{ env.REPO_READONLY_PAT }} + make_latest: true + fail_on_unmatched_files: true + target_commitish: main + generate_release_notes: true + files: | + ./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64.zip + ./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64.zip + ./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64.zip + ./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64.zip + ./artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64.zip \ No newline at end of file diff --git a/.github/workflows/build-macos-arm-installer.yml b/.github/workflows/build-macos-arm-installer.yml new file mode 100644 index 00000000..fbf9ed6a --- /dev/null +++ b/.github/workflows/build-macos-arm-installer.yml @@ -0,0 +1,376 @@ +name: Build macOS ARM Installer + +permissions: + contents: write + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + workflow_call: + +jobs: + build-macos-arm-installer: + name: Build macOS ARM Installer + runs-on: macos-14 # ARM runner for Apple Silicon + + steps: + - name: Set variables + run: | + repoFullName=${{ github.repository }} + ref=${{ github.ref }} + + # Handle both tag events and manual dispatch + if [[ "$ref" =~ ^refs/tags/ ]]; then + releaseVersion=${ref##refs/tags/} + appVersion=${releaseVersion#v} + else + # For manual dispatch, use a default version + releaseVersion="dev-$(date +%Y%m%d-%H%M%S)" + appVersion="0.0.1-dev" + fi + + repositoryName=${repoFullName#*/} + + echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV + echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV + echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV + echo "appVersion=$appVersion" >> $GITHUB_ENV + echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV + + - name: Get vault secrets + uses: hashicorp/vault-action@v2 + with: + url: ${{ secrets.VAULT_HOST }} + method: approle + roleId: ${{ secrets.VAULT_ROLE_ID }} + secretId: ${{ secrets.VAULT_SECRET_ID }} + secrets: + secrets/data/github repo_readonly_pat | REPO_READONLY_PAT; + secrets/data/github packages_pat | PACKAGES_PAT + + - name: Checkout repository + uses: actions/checkout@v4 + with: + repository: ${{ env.githubRepository }} + ref: ${{ github.ref_name }} + token: ${{ env.REPO_READONLY_PAT }} + fetch-depth: 0 + + - name: Setup Node.js for frontend build + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: code/frontend/package-lock.json + + - name: Build frontend + run: | + cd code/frontend + npm ci + npm run build + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Restore .NET dependencies + run: | + dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json + dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj + + - name: Build macOS ARM executable + run: | + # Clean any existing output directory + rm -rf dist + mkdir -p dist/temp + + # Build to a temporary location + dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \ + -c Release \ + --runtime osx-arm64 \ + --self-contained true \ + -o dist/temp \ + /p:PublishSingleFile=true \ + /p:Version=${{ env.appVersion }} \ + /p:DebugType=None \ + /p:DebugSymbols=false \ + /p:UseAppHost=true \ + /p:EnableMacOSCodeSign=false \ + /p:CodeSignOnCopy=false \ + /p:_CodeSignDuringBuild=false \ + /p:PublishTrimmed=false \ + /p:TrimMode=link + + # Create proper app bundle structure + mkdir -p dist/Cleanuparr.app/Contents/MacOS + + # Copy the built executable (note: AssemblyName is "Cleanuparr" not "Cleanuparr.Api") + cp dist/temp/Cleanuparr dist/Cleanuparr.app/Contents/MacOS/Cleanuparr + + # Copy frontend directly to where it belongs in the app bundle + mkdir -p dist/Cleanuparr.app/Contents/MacOS/wwwroot + cp -r code/frontend/dist/ui/browser/* dist/Cleanuparr.app/Contents/MacOS/wwwroot/ + + # Copy any additional runtime files if they exist + if [ -d "dist/temp" ]; then + find dist/temp -name "*.dylib" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true + find dist/temp -name "createdump" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true + fi + + - name: Post-build setup + run: | + # Make sure the executable is actually executable + chmod +x dist/Cleanuparr.app/Contents/MacOS/Cleanuparr + + # Remove any .pdb files that might have been created + find dist/Cleanuparr.app/Contents/MacOS -name "*.pdb" -delete 2>/dev/null || true + + echo "Checking architecture of built binary:" + file dist/Cleanuparr.app/Contents/MacOS/Cleanuparr + if command -v lipo >/dev/null 2>&1; then + lipo -info dist/Cleanuparr.app/Contents/MacOS/Cleanuparr + fi + + echo "Files in MacOS directory:" + ls -la dist/Cleanuparr.app/Contents/MacOS/ + + - name: Create macOS app bundle structure + run: | + # Create proper app bundle structure + mkdir -p dist/Cleanuparr.app/Contents/{MacOS,Resources,Frameworks} + + # Convert ICO to ICNS for macOS app bundle + if command -v iconutil >/dev/null 2>&1; then + # Create iconset directory structure + mkdir -p Cleanuparr.iconset + + # Use existing PNG files from Logo directory for different sizes + cp Logo/16.png Cleanuparr.iconset/icon_16x16.png + cp Logo/32.png Cleanuparr.iconset/icon_16x16@2x.png + cp Logo/32.png Cleanuparr.iconset/icon_32x32.png + cp Logo/64.png Cleanuparr.iconset/icon_32x32@2x.png + cp Logo/128.png Cleanuparr.iconset/icon_128x128.png + cp Logo/256.png Cleanuparr.iconset/icon_128x128@2x.png + cp Logo/256.png Cleanuparr.iconset/icon_256x256.png + cp Logo/512.png Cleanuparr.iconset/icon_256x256@2x.png + cp Logo/512.png Cleanuparr.iconset/icon_512x512.png + cp Logo/1024.png Cleanuparr.iconset/icon_512x512@2x.png + + # Create ICNS file + iconutil -c icns Cleanuparr.iconset -o dist/Cleanuparr.app/Contents/Resources/Cleanuparr.icns + + # Clean up iconset directory + rm -rf Cleanuparr.iconset + fi + + # Create Launch Daemon plist + cat > dist/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist << EOF + + + + + Label + com.cleanuparr.daemon + ProgramArguments + + /Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr + + RunAtLoad + + KeepAlive + + StandardOutPath + /var/log/cleanuparr.log + StandardErrorPath + /var/log/cleanuparr.error.log + WorkingDirectory + /Applications/Cleanuparr.app/Contents/MacOS + EnvironmentVariables + + HTTP_PORTS + 11011 + + + + EOF + + # Create Info.plist with proper configuration + cat > dist/Cleanuparr.app/Contents/Info.plist << EOF + + + + + CFBundleExecutable + Cleanuparr + CFBundleIdentifier + com.Cleanuparr + CFBundleName + Cleanuparr + CFBundleDisplayName + Cleanuparr + CFBundleVersion + ${{ env.appVersion }} + CFBundleShortVersionString + ${{ env.appVersion }} + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + APPL + CFBundleSignature + CLNR + CFBundleIconFile + Cleanuparr + NSHighResolutionCapable + + NSRequiresAquaSystemAppearance + + LSMinimumSystemVersion + 11.0 + LSApplicationCategoryType + public.app-category.productivity + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + LSBackgroundOnly + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + + EOF + + # Clean up temp directory + rm -rf dist/temp + + - name: Create PKG installer + run: | + # Create preinstall script to handle existing installations + mkdir -p scripts + cat > scripts/preinstall << 'EOF' + #!/bin/bash + + # Stop and unload existing launch daemon if it exists + if launchctl list | grep -q "com.cleanuparr.daemon"; then + launchctl stop com.cleanuparr.daemon 2>/dev/null || true + launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true + fi + + # Stop any running instances of Cleanuparr + pkill -f "Cleanuparr" || true + sleep 2 + + # Remove old installation if it exists + if [[ -d "/Applications/Cleanuparr.app" ]]; then + rm -rf "/Applications/Cleanuparr.app" + fi + + # Remove old launch daemon plist if it exists + if [[ -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist" ]]; then + rm -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist" + fi + + exit 0 + EOF + + chmod +x scripts/preinstall + + # Create postinstall script + cat > scripts/postinstall << 'EOF' + #!/bin/bash + + # Set proper permissions for the app bundle + chmod -R 755 /Applications/Cleanuparr.app + chmod +x /Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr + + # Install the launch daemon + cp /Applications/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist /Library/LaunchDaemons/ + chown root:wheel /Library/LaunchDaemons/com.cleanuparr.daemon.plist + chmod 644 /Library/LaunchDaemons/com.cleanuparr.daemon.plist + + # Load and start the service + launchctl load /Library/LaunchDaemons/com.cleanuparr.daemon.plist + launchctl start com.cleanuparr.daemon + + # Wait a moment for service to start + sleep 3 + + # Display as system notification + osascript -e 'display notification "Cleanuparr service started! Visit http://localhost:11011 in your browser." with title "Installation Complete"' 2>/dev/null || true + + exit 0 + EOF + + chmod +x scripts/postinstall + + # Create uninstall script (optional, for user reference) + cat > scripts/uninstall_cleanuparr.sh << 'EOF' + #!/bin/bash + # Cleanuparr Uninstall Script + # Run this script with sudo to completely remove Cleanuparr + + echo "Stopping Cleanuparr service..." + launchctl stop com.cleanuparr.daemon 2>/dev/null || true + launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true + + echo "Removing service files..." + rm -f /Library/LaunchDaemons/com.cleanuparr.daemon.plist + + echo "Removing application..." + rm -rf /Applications/Cleanuparr.app + + echo "Removing logs..." + rm -f /var/log/cleanuparr.log + rm -f /var/log/cleanuparr.error.log + + echo "Cleanuparr has been completely removed." + echo "Note: Configuration files in /Applications/Cleanuparr.app/Contents/MacOS/config/ have been removed with the app." + EOF + + chmod +x scripts/uninstall_cleanuparr.sh + + # Copy uninstall script to app bundle for user access + cp scripts/uninstall_cleanuparr.sh dist/Cleanuparr.app/Contents/Resources/ + + # Determine package name + if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then + pkg_name="Cleanuparr-${{ env.appVersion }}-macos-arm64.pkg" + else + pkg_name="Cleanuparr-${{ env.appVersion }}-macos-arm64-dev.pkg" + fi + + # Create PKG installer with better metadata + pkgbuild --root dist/ \ + --scripts scripts/ \ + --identifier com.Cleanuparr \ + --version ${{ env.appVersion }} \ + --install-location /Applications \ + --ownership preserve \ + ${pkg_name} + + echo "pkgName=${pkg_name}" >> $GITHUB_ENV + + - name: Upload installer as artifact + uses: actions/upload-artifact@v4 + with: + name: Cleanuparr-macos-arm64-installer + path: '${{ env.pkgName }}' + retention-days: 30 + + - name: Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + name: ${{ env.releaseVersion }} + tag_name: ${{ env.releaseVersion }} + repository: ${{ env.githubRepository }} + token: ${{ env.REPO_READONLY_PAT }} + make_latest: true + files: | + ${{ env.pkgName }} \ No newline at end of file diff --git a/.github/workflows/build-macos-intel-installer.yml b/.github/workflows/build-macos-intel-installer.yml new file mode 100644 index 00000000..8cad0e35 --- /dev/null +++ b/.github/workflows/build-macos-intel-installer.yml @@ -0,0 +1,376 @@ +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 + + + + + Label + com.cleanuparr.daemon + ProgramArguments + + /Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr + + RunAtLoad + + KeepAlive + + StandardOutPath + /var/log/cleanuparr.log + StandardErrorPath + /var/log/cleanuparr.error.log + WorkingDirectory + /Applications/Cleanuparr.app/Contents/MacOS + EnvironmentVariables + + HTTP_PORTS + 11011 + + + + EOF + + # Create Info.plist with proper configuration + cat > dist/Cleanuparr.app/Contents/Info.plist << EOF + + + + + CFBundleExecutable + Cleanuparr + CFBundleIdentifier + com.Cleanuparr + CFBundleName + Cleanuparr + CFBundleDisplayName + Cleanuparr + CFBundleVersion + ${{ env.appVersion }} + CFBundleShortVersionString + ${{ env.appVersion }} + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + APPL + CFBundleSignature + CLNR + CFBundleIconFile + Cleanuparr + NSHighResolutionCapable + + NSRequiresAquaSystemAppearance + + LSMinimumSystemVersion + 10.15 + LSApplicationCategoryType + public.app-category.productivity + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + LSBackgroundOnly + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + + 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 + + - name: Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + name: ${{ env.releaseVersion }} + tag_name: ${{ env.releaseVersion }} + repository: ${{ env.githubRepository }} + token: ${{ env.REPO_READONLY_PAT }} + make_latest: true + files: | + ${{ env.pkgName }} \ No newline at end of file diff --git a/.github/workflows/build-windows-installer.yml b/.github/workflows/build-windows-installer.yml new file mode 100644 index 00000000..4843b15b --- /dev/null +++ b/.github/workflows/build-windows-installer.yml @@ -0,0 +1,171 @@ +name: Build Windows Installer + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + workflow_call: + +jobs: + build-windows-installer: + runs-on: windows-latest + + steps: + - name: Set variables + shell: pwsh + run: | + $repoFullName = "${{ github.repository }}" + $ref = "${{ github.ref }}" + + # Handle both tag events and manual dispatch + if ($ref -match "^refs/tags/") { + $releaseVersion = $ref -replace "refs/tags/", "" + $appVersion = $releaseVersion -replace "^v", "" + } else { + # For manual dispatch, use a default version + $releaseVersion = "dev-$(Get-Date -Format 'yyyyMMdd-HHmmss')" + $appVersion = "0.0.1-dev" + } + + $repositoryName = $repoFullName.Split("/")[1] + + echo "githubRepository=${{ github.repository }}" >> $env:GITHUB_ENV + echo "githubRepositoryName=$repositoryName" >> $env:GITHUB_ENV + echo "releaseVersion=$releaseVersion" >> $env:GITHUB_ENV + echo "appVersion=$appVersion" >> $env:GITHUB_ENV + echo "executableName=Cleanuparr.Api" >> $env:GITHUB_ENV + echo "APP_VERSION=$appVersion" >> $env:GITHUB_ENV + + - name: Get vault secrets + uses: hashicorp/vault-action@v2 + with: + url: ${{ secrets.VAULT_HOST }} + method: approle + roleId: ${{ secrets.VAULT_ROLE_ID }} + secretId: ${{ secrets.VAULT_SECRET_ID }} + secrets: + secrets/data/github repo_readonly_pat | REPO_READONLY_PAT; + secrets/data/github packages_pat | PACKAGES_PAT + + - name: Checkout repository + uses: actions/checkout@v4 + with: + repository: ${{ env.githubRepository }} + ref: ${{ github.ref_name }} + token: ${{ env.REPO_READONLY_PAT }} + + - name: 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: Copy frontend to backend wwwroot + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "code/backend/${{ env.executableName }}/wwwroot" + Copy-Item -Path "code/frontend/dist/ui/browser/*" -Destination "code/backend/${{ env.executableName }}/wwwroot/" -Recurse -Force + + - name: Build Windows executable + run: | + dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o dist /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugType=None /p:DebugSymbols=false + + - name: Create sample configuration + shell: pwsh + run: | + # Create config directory + New-Item -ItemType Directory -Force -Path "config" + + $config = @{ + "HTTP_PORTS" = 11011 + "BASE_PATH" = "/" + } + + $config | ConvertTo-Json | Out-File -FilePath "config/cleanuparr.json" -Encoding UTF8 + + - name: Setup Inno Setup + shell: pwsh + run: | + # Download and install Inno Setup + $url = "https://jrsoftware.org/download.php/is.exe" + $output = "innosetup-installer.exe" + + Invoke-WebRequest -Uri $url -OutFile $output + Start-Process -FilePath $output -ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART" -Wait + + # Add Inno Setup to PATH + $innoPath = "C:\Program Files (x86)\Inno Setup 6" + echo "$innoPath" >> $env:GITHUB_PATH + + - name: Verify LICENSE file exists + shell: pwsh + run: | + if (-not (Test-Path "LICENSE")) { + Write-Error "LICENSE file not found in repository root" + exit 1 + } + Write-Host "LICENSE file found successfully" + + - name: Build Windows installer + shell: pwsh + run: | + # Copy installer script to root + Copy-Item "installers/windows/cleanuparr-installer.iss" -Destination "cleanuparr-installer.iss" + + # The installer script has been pre-updated with proper icon and config paths + # No dynamic modifications needed as the base script now includes correct references + + # Run Inno Setup compiler + & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "cleanuparr-installer.iss" + + # Check if installer was created + if (Test-Path "installer/Cleanuparr_Setup.exe") { + Write-Host "Installer created successfully" + } else { + Write-Error "Installer creation failed" + exit 1 + } + + - name: Rename installer with version + shell: pwsh + run: | + $installerName = "Cleanuparr-${{ env.appVersion }}-Setup.exe" + Move-Item "installer/Cleanuparr_Setup.exe" "installer/$installerName" + echo "installerName=$installerName" >> $env:GITHUB_ENV + + - name: Upload installer artifact + uses: actions/upload-artifact@v4 + with: + name: Cleanuparr-windows-installer + path: installer/${{ env.installerName }} + retention-days: 30 + + - name: Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + name: ${{ env.releaseVersion }} + tag_name: ${{ env.releaseVersion }} + repository: ${{ env.githubRepository }} + token: ${{ env.REPO_READONLY_PAT }} + make_latest: true + files: | + installer/${{ env.installerName }} \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 6a5e4aad..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,14 +0,0 @@ -on: - workflow_dispatch: - workflow_call: - -jobs: - build: - uses: flmorg/universal-workflows-testing/.github/workflows/dotnet.build.app.yml@main - with: - dockerRepository: flaminel/cleanuperr - githubContext: ${{ toJSON(github) }} - outputName: cleanuperr - selfContained: false - baseImage: 9.0-bookworm-slim - secrets: inherit \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 2c632ff2..00000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,19 +0,0 @@ -on: - workflow_call: - workflow_dispatch: - push: - paths: - - 'chart/**' - branches: [ main ] - -jobs: - deploy: - uses: flmorg/universal-workflows/.github/workflows/chart.install.yml@main - with: - githubContext: ${{ toJSON(github) }} - chartRepo: oci://ghcr.io/flmorg - chartName: universal-chart - version: ^1.0.0 - valuesPath: chart/values.yaml - releaseName: main - secrets: inherit \ No newline at end of file diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml deleted file mode 100644 index 640e9382..00000000 --- a/.github/workflows/pipeline.yml +++ /dev/null @@ -1,20 +0,0 @@ -on: - push: - tags: - - "v*.*.*" - # paths: - # - 'code/**' - # branches: [ main ] - pull_request: - paths: - - 'code/**' - -jobs: - build: - uses: flmorg/cleanuperr/.github/workflows/build.yml@main - secrets: inherit - - # deploy: - # needs: [ build ] - # uses: flmorg/cleanuperr/.github/workflows/deploy.yml@main - # secrets: inherit \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37b246d3..d287b856 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,164 @@ +name: Release Build + on: push: tags: - "v*.*.*" + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 1.0.0)' + required: false + default: '' jobs: - release: - uses: flmorg/universal-workflows/.github/workflows/dotnet.release.yml@main - with: - githubContext: ${{ toJSON(github) }} - secrets: inherit \ No newline at end of file + # Validate release + validate: + runs-on: ubuntu-latest + outputs: + app_version: ${{ steps.version.outputs.app_version }} + release_version: ${{ steps.version.outputs.release_version }} + is_tag: ${{ steps.version.outputs.is_tag }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Get version info + id: version + run: | + if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then + # Tag event + release_version=${GITHUB_REF##refs/tags/} + app_version=${release_version#v} + is_tag=true + elif [[ -n "${{ github.event.inputs.version }}" ]]; then + # Manual workflow with version + app_version="${{ github.event.inputs.version }}" + release_version="v$app_version" + is_tag=false + else + # Manual workflow without version + 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 "๐Ÿท๏ธ Release Version: $release_version" + echo "๐Ÿ“ฑ App Version: $app_version" + echo "๐Ÿ”– Is Tag: $is_tag" + + # Build portable executables + build-executables: + needs: validate + uses: ./.github/workflows/build_executable.yml + secrets: inherit + + # Build Windows installer + build-windows-installer: + needs: validate + uses: ./.github/workflows/build-windows-installer.yml + secrets: inherit + + # Build macOS Intel installer + build-macos-intel: + needs: validate + uses: ./.github/workflows/build-macos-intel-installer.yml + secrets: inherit + + # Build macOS ARM installer + build-macos-arm: + needs: validate + uses: ./.github/workflows/build-macos-arm-installer.yml + secrets: inherit + + # Create GitHub release + create-release: + needs: [validate, build-executables, build-windows-installer, build-macos-intel, build-macos-arm] + runs-on: ubuntu-latest + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + + steps: + - name: Get vault secrets + uses: hashicorp/vault-action@v2 + with: + url: ${{ secrets.VAULT_HOST }} + method: approle + roleId: ${{ secrets.VAULT_ROLE_ID }} + secretId: ${{ secrets.VAULT_SECRET_ID }} + secrets: + secrets/data/github repo_readonly_pat | REPO_READONLY_PAT + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: ./artifacts + + - name: List downloaded artifacts + run: | + echo "๐Ÿ“ฆ Downloaded artifacts:" + find ./artifacts -type f -name "*.zip" -o -name "*.pkg" -o -name "*.exe" | sort + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + name: Cleanuparr ${{ needs.validate.outputs.release_version }} + tag_name: ${{ needs.validate.outputs.release_version }} + token: ${{ env.REPO_READONLY_PAT }} + make_latest: true + generate_release_notes: true + prerelease: ${{ contains(needs.validate.outputs.app_version, '-') }} + files: | + ./artifacts/**/*.zip + ./artifacts/**/*.pkg + ./artifacts/**/*.exe + + # Summary job + summary: + needs: [validate, build-executables, build-windows-installer, build-macos-intel, build-macos-arm] + runs-on: ubuntu-latest + if: always() + + steps: + - name: Build Summary + run: | + 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 "" >> $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 + + echo "" >> $GITHUB_STEP_SUMMARY + echo "๐ŸŽ‰ **Build completed!**" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/Logo/favicon.ico b/Logo/favicon.ico new file mode 100644 index 00000000..c82f0265 Binary files /dev/null and b/Logo/favicon.ico differ diff --git a/Logo/cleanuperr.svg b/Logo/logo.svg similarity index 100% rename from Logo/cleanuperr.svg rename to Logo/logo.svg diff --git a/README.md b/README.md index 74ddd25e..6c3df31e 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,35 @@ _Love this project? Give it a โญ๏ธ and let others know!_ -# cleanuperr Cleanuperr +# Cleanuparr Cleanuparr [![Discord](https://img.shields.io/discord/1306721212587573389?color=7289DA&label=Discord&style=for-the-badge&logo=discord)](https://discord.gg/SCtMCgtsc4) -Cleanuperr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, and supported download clients like qBittorrent. It removes incomplete or blocked downloads, updates queues, and enforces blacklists or whitelists to manage file selection. After removing blocked content, Cleanuperr can also trigger a search to replace the deleted shows/movies. +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. -Cleanuperr was created primarily to address malicious files, such as `*.lnk` or `*.zipx`, that were getting stuck in Sonarr/Radarr and required manual intervention. Some of the reddit posts that made Cleanuperr come to life can be found [here](https://www.reddit.com/r/sonarr/comments/1gqnx16/psa_sonarr_downloaded_a_virus/), [here](https://www.reddit.com/r/sonarr/comments/1gqwklr/sonar_downloaded_a_mkv_file_which_looked_like_a/), [here](https://www.reddit.com/r/sonarr/comments/1gpw2wa/downloaded_waiting_to_import/) and [here](https://www.reddit.com/r/sonarr/comments/1gpi344/downloads_not_importing_no_files_found/). +Cleanuparr was created primarily to address malicious files, such as `*.lnk` or `*.zipx`, that were getting stuck in Sonarr/Radarr and required manual intervention. Some of the reddit posts that made Cleanuparr come to life can be found [here](https://www.reddit.com/r/sonarr/comments/1gqnx16/psa_sonarr_downloaded_a_virus/), [here](https://www.reddit.com/r/sonarr/comments/1gqwklr/sonar_downloaded_a_mkv_file_which_looked_like_a/), [here](https://www.reddit.com/r/sonarr/comments/1gpw2wa/downloaded_waiting_to_import/) and [here](https://www.reddit.com/r/sonarr/comments/1gpi344/downloads_not_importing_no_files_found/). > [!IMPORTANT] > **Features:** > - Strike system to mark bad downloads. > - Remove and block downloads that reached a maximum number of strikes. -> - Remove and block downloads that are **failing to be imported** by the arrs. [configuration](https://flmorg.github.io/cleanuperr/docs/configuration/queue-cleaner/import-failed) -> - Remove and block downloads that are **stalled** or in **metadata downloading** state. [configuration](https://flmorg.github.io/cleanuperr/docs/configuration/queue-cleaner/stalled) -> - Remove and block downloads that have a **low download speed** or **high estimated completion time**. [configuration](https://flmorg.github.io/cleanuperr/docs/configuration/queue-cleaner/slow) -> - Remove and block downloads blocked by qBittorrent or by Cleanuperr's **Content Blocker**. [configuration](https://flmorg.github.io/cleanuperr/docs/configuration/content-blocker/general) +> - Remove and block downloads that are **failing to be imported** by the arrs. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/queue-cleaner/import-failed) +> - Remove and block downloads that are **stalled** or in **metadata downloading** state. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/queue-cleaner/stalled) +> - Remove and block downloads that have a **low download speed** or **high estimated completion time**. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/queue-cleaner/slow) +> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Content Blocker**. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/content-blocker/general) > - Automatically trigger a search for downloads removed from the arrs. -> - Clean up downloads that have been **seeding** for a certain amount of time. [configuration](https://flmorg.github.io/cleanuperr/docs/configuration/download-cleaner/seeding) -> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support). [configuration](https://flmorg.github.io/cleanuperr/docs/configuration/download-cleaner/hardlinks) -> - Notify on strike or download removal. [configuration](https://flmorg.github.io/cleanuperr/docs/category/notifications) -> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuperr. +> - Clean up downloads that have been **seeding** for a certain amount of time. [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/download-cleaner/seeding) +> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support). [configuration](https://cleanuparr.github.io/cleanuparr/docs/configuration/download-cleaner/hardlinks) +> - Notify on strike or download removal. [configuration](https://cleanuparr.github.io/cleanuparr/docs/category/notifications) +> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr. -Cleanuperr supports both qBittorrent's built-in exclusion features and its own blocklist-based system. Binaries for all platforms are provided, along with Docker images for easy deployment. +Cleanuparr supports both qBittorrent's built-in exclusion features and its own blocklist-based system. Binaries for all platforms are provided, along with Docker images for easy deployment. ## Quick Start > [!NOTE] > > 1. **Docker (Recommended)** -> Pull the Docker image from `ghcr.io/flmorg/cleanuperr:latest`. +> Pull the Docker image from `ghcr.io/Cleanuparr/Cleanuparr:latest`. > > 2. **Unraid (for Unraid users)** > Use the Unraid Community App. @@ -39,13 +39,13 @@ Cleanuperr supports both qBittorrent's built-in exclusion features and its own b # Docs -Docs can be found [here](https://flmorg.github.io/cleanuperr/). +Docs can be found [here](https://Cleanuparr.github.io/Cleanuparr/). -# Cleanuperr Cleanuperr Huntarr +# Cleanuparr Cleanuparr Huntarr -Think of **Cleanuperr** as the janitor of your server; it keeps your download queue spotless, removes clutter, and blocks malicious files. Now imagine combining that with **Huntarr**, the compulsive librarian who finds missing and upgradable media to complete your collection +Think of **Cleanuparr** as the janitor of your server; it keeps your download queue spotless, removes clutter, and blocks malicious files. Now imagine combining that with **Huntarr**, the compulsive librarian who finds missing and upgradable media to complete your collection -While **Huntarr** fills in the blanks and improves what you already have, **Cleanuperr** makes sure that only clean downloads get through. If you're aiming for a reliable and self-sufficient setup, **Cleanuperr** and **Huntarr** will take your automated media stack to another level. +While **Huntarr** fills in the blanks and improves what you already have, **Cleanuparr** makes sure that only clean downloads get through. If you're aiming for a reliable and self-sufficient setup, **Cleanuparr** and **Huntarr** will take your automated media stack to another level. โžก๏ธ [**Huntarr**](https://github.com/plexguide/Huntarr.io) ![Huntarr](https://img.shields.io/github/stars/plexguide/Huntarr.io?style=social) diff --git a/chart/values.yaml b/chart/values.yaml deleted file mode 100644 index f6835a79..00000000 --- a/chart/values.yaml +++ /dev/null @@ -1,187 +0,0 @@ -deployment: - replicas: 1 - strategy: - type: RollingUpdate - maxSurge: 1 - maxUnavailable: 0 - containers: - - name: qbit - image: - repository: ghcr.io/flmorg/cleanuperr - tag: latest - env: - - name: DRY_RUN - value: "false" - - - name: LOGGING__LOGLEVEL - value: Verbose - - name: LOGGING__FILE__ENABLED - value: "true" - - name: LOGGING__FILE__PATH - value: /var/logs - - name: LOGGING__ENHANCED - value: "true" - - - name: TRIGGERS__QUEUECLEANER - value: 0 0/5 * * * ? - - name: TRIGGERS__CONTENTBLOCKER - value: 0 0/5 * * * ? - - - name: QUEUECLEANER__ENABLED - value: "true" - - name: QUEUECLEANER__RUNSEQUENTIALLY - value: "true" - - name: QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES - value: "3" - - name: QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE - value: "false" - - name: QUEUECLEANER__IMPORT_FAILED_DELETE_PRIVATE - value: "false" - - name: QUEUECLEANER__STALLED_MAX_STRIKES - value: "3" - - name: QUEUECLEANER__STALLED_IGNORE_PRIVATE - value: "false" - - name: QUEUECLEANER__STALLED_DELETE_PRIVATE - value: "false" - - - name: CONTENTBLOCKER__ENABLED - value: "true" - - name: CONTENTBLOCKER__IGNORE_PRIVATE - value: "true" - - name: CONTENTBLOCKER__DELETE_PRIVATE - value: "false" - - - name: DOWNLOADCLEANER__ENABLED - value: "true" - - name: DOWNLOADCLEANER__DELETE_PRIVATE - value: "true" - - name: DOWNLOADCLEANER__CATEGORIES__0__NAME - value: unlinked - - name: DOWNLOADCLEANER__CATEGORIES__0__MAX_SEED_TIME - value: "240" - - - name: DOWNLOADCLEANER__UNLINKED_TARGET_CATEGORY - value: unlinked - - name: DOWNLOADCLEANER__UNLINKED_IGNORED_ROOT_DIR - value: /downloads - - name: DOWNLOADCLEANER__UNLINKED_CATEGORIES__0 - value: sonarr-low - - name: DOWNLOADCLEANER__UNLINKED_CATEGORIES__1 - value: sonarr-high - - name: DOWNLOADCLEANER__UNLINKED_CATEGORIES__2 - value: radarr-low - - name: DOWNLOADCLEANER__UNLINKED_CATEGORIES__3 - value: radarr-high - - - name: DOWNLOAD_CLIENT - value: qbittorrent - - name: QBITTORRENT__URL - value: http://service.qbittorrent-videos.svc.cluster.local - - - name: SONARR__ENABLED - value: "true" - - name: SONARR__SEARCHTYPE - value: Episode - - name: SONARR__BLOCK__TYPE - value: blacklist - - name: SONARR__BLOCK__PATH - value: https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist - - name: SONARR__INSTANCES__0__URL - value: http://service.sonarr-low-res.svc.cluster.local - - name: SONARR__INSTANCES__1__URL - value: http://service.sonarr-high-res.svc.cluster.local - - - name: RADARR__ENABLED - value: "true" - - name: RADARR__BLOCK__TYPE - value: blacklist - - name: RADARR__BLOCK__PATH - value: https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist - - name: RADARR__INSTANCES__0__URL - value: http://service.radarr-low-res.svc.cluster.local - - name: RADARR__INSTANCES__1__URL - value: http://service.radarr-high-res.svc.cluster.local - - - name: NOTIFIARR__ON_IMPORT_FAILED_STRIKE - value: "true" - - name: NOTIFIARR__ON_STALLED_STRIKE - value: "true" - - name: NOTIFIARR__ON_QUEUE_ITEM_DELETED - value: "true" - - name: NOTIFIARR__ON_DOWNLOAD_CLEANED - value: "true" - - name: NOTIFIARR__CHANNEL_ID - value: "1340708411259748413" - envFromSecret: - - secretName: qbit-auth - envs: - - name: QBITTORRENT__USERNAME - key: QBIT_USER - - name: QBITTORRENT__PASSWORD - key: QBIT_PASS - - secretName: sonarr-auth - envs: - - name: SONARR__INSTANCES__0__APIKEY - key: SNRL_API_KEY - - name: SONARR__INSTANCES__1__APIKEY - key: SNRH_API_KEY - - secretName: radarr-auth - envs: - - name: RADARR__INSTANCES__0__APIKEY - key: RDRL_API_KEY - - name: RADARR__INSTANCES__1__APIKEY - key: RDRH_API_KEY - - secretName: notifiarr-auth - envs: - - name: NOTIFIARR__API_KEY - key: API_KEY - resources: - requests: - cpu: 0m - memory: 0Mi - limits: - cpu: 1000m - memory: 1000Mi - volumeMounts: - - name: storage - mountPath: /var/logs - subPath: cleanuperr/logs - - name: storage - mountPath: /downloads/general2 - subPath: media/downloads/general2 - - name: storage - mountPath: /downloads/cross-seed - subPath: media/downloads/cross-seed - volumes: - - name: storage - type: pvc - typeName: storage-pvc - -pvcs: - - name: storage-pvc - storageClassName: local-path-persistent - accessModes: - - ReadWriteOnce - size: 1Gi - volumeMode: Filesystem - -vaultSecrets: - - name: qbit-auth - path: secrets/qbittorrent - templates: - QBIT_USER: "{% .Secrets.username %}" - QBIT_PASS: "{% .Secrets.password %}" - - name: radarr-auth - path: secrets/radarr - templates: - RDRL_API_KEY: "{% .Secrets.low_api_key %}" - RDRH_API_KEY: "{% .Secrets.high_api_key %}" - - name: sonarr-auth - path: secrets/sonarr - templates: - SNRL_API_KEY: "{% .Secrets.low_api_key %}" - SNRH_API_KEY: "{% .Secrets.high_api_key %}" - - name: notifiarr-auth - path: secrets/notifiarr - templates: - API_KEY: "{% .Secrets.passthrough_api_key %}" \ No newline at end of file diff --git a/code/.dockerignore b/code/.dockerignore new file mode 100644 index 00000000..f95c9d74 --- /dev/null +++ b/code/.dockerignore @@ -0,0 +1,41 @@ +# Documentation +*.md +docs/ + +# Version control +.git/ +.gitignore + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Node.js +frontend/node_modules/ +frontend/dist/ +frontend/.angular/ + +# .NET +backend/bin/ +backend/obj/ +backend/*/bin/ +backend/*/obj/ +backend/.vs/ + +# Build artifacts +artifacts/ +dist/ + +# Test files +backend/**/*Tests/ +backend/**/Tests/ + +# Development files +docker-compose*.yml +test/ \ No newline at end of file diff --git a/code/Common/Attributes/DryRunSafeguardAttribute.cs b/code/Common/Attributes/DryRunSafeguardAttribute.cs deleted file mode 100644 index 2f5fa091..00000000 --- a/code/Common/Attributes/DryRunSafeguardAttribute.cs +++ /dev/null @@ -1,6 +0,0 @@ -๏ปฟnamespace Common.Attributes; - -[AttributeUsage(AttributeTargets.Method, Inherited = true)] -public class DryRunSafeguardAttribute : Attribute -{ -} \ No newline at end of file diff --git a/code/Common/Configuration/Arr/ArrConfig.cs b/code/Common/Configuration/Arr/ArrConfig.cs deleted file mode 100644 index 6863084c..00000000 --- a/code/Common/Configuration/Arr/ArrConfig.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Common.Configuration.ContentBlocker; -using Microsoft.Extensions.Configuration; - -namespace Common.Configuration.Arr; - -public abstract record ArrConfig -{ - public required bool Enabled { get; init; } - - public Block Block { get; init; } = new(); - - [ConfigurationKeyName("IMPORT_FAILED_MAX_STRIKES")] - public short ImportFailedMaxStrikes { get; init; } = -1; - - public required List Instances { get; init; } -} - -public readonly record struct Block -{ - public BlocklistType Type { get; init; } - - public string? Path { get; init; } -} \ No newline at end of file diff --git a/code/Common/Configuration/Arr/ArrInstance.cs b/code/Common/Configuration/Arr/ArrInstance.cs deleted file mode 100644 index eff7e62b..00000000 --- a/code/Common/Configuration/Arr/ArrInstance.cs +++ /dev/null @@ -1,8 +0,0 @@ -๏ปฟnamespace Common.Configuration.Arr; - -public sealed class ArrInstance -{ - public required Uri Url { get; set; } - - public required string ApiKey { get; set; } -} \ No newline at end of file diff --git a/code/Common/Configuration/Arr/LidarrConfig.cs b/code/Common/Configuration/Arr/LidarrConfig.cs deleted file mode 100644 index 33be0ec0..00000000 --- a/code/Common/Configuration/Arr/LidarrConfig.cs +++ /dev/null @@ -1,6 +0,0 @@ -๏ปฟnamespace Common.Configuration.Arr; - -public sealed record LidarrConfig : ArrConfig -{ - public const string SectionName = "Lidarr"; -} \ No newline at end of file diff --git a/code/Common/Configuration/Arr/RadarrConfig.cs b/code/Common/Configuration/Arr/RadarrConfig.cs deleted file mode 100644 index 6f24c570..00000000 --- a/code/Common/Configuration/Arr/RadarrConfig.cs +++ /dev/null @@ -1,6 +0,0 @@ -๏ปฟnamespace Common.Configuration.Arr; - -public sealed record RadarrConfig : ArrConfig -{ - public const string SectionName = "Radarr"; -} \ No newline at end of file diff --git a/code/Common/Configuration/Arr/SonarrConfig.cs b/code/Common/Configuration/Arr/SonarrConfig.cs deleted file mode 100644 index 97518cfb..00000000 --- a/code/Common/Configuration/Arr/SonarrConfig.cs +++ /dev/null @@ -1,8 +0,0 @@ -๏ปฟnamespace Common.Configuration.Arr; - -public sealed record SonarrConfig : ArrConfig -{ - public const string SectionName = "Sonarr"; - - public SonarrSearchType SearchType { get; init; } -} \ No newline at end of file diff --git a/code/Common/Configuration/ContentBlocker/ContentBlockerConfig.cs b/code/Common/Configuration/ContentBlocker/ContentBlockerConfig.cs deleted file mode 100644 index 4b071eff..00000000 --- a/code/Common/Configuration/ContentBlocker/ContentBlockerConfig.cs +++ /dev/null @@ -1,23 +0,0 @@ -๏ปฟusing Microsoft.Extensions.Configuration; - -namespace Common.Configuration.ContentBlocker; - -public sealed record ContentBlockerConfig : IJobConfig, IIgnoredDownloadsConfig -{ - public const string SectionName = "ContentBlocker"; - - public required bool Enabled { get; init; } - - [ConfigurationKeyName("IGNORE_PRIVATE")] - public bool IgnorePrivate { get; init; } - - [ConfigurationKeyName("DELETE_PRIVATE")] - public bool DeletePrivate { get; init; } - - [ConfigurationKeyName("IGNORED_DOWNLOADS_PATH")] - public string? IgnoredDownloadsPath { get; init; } - - public void Validate() - { - } -} \ No newline at end of file diff --git a/code/Common/Configuration/DownloadCleaner/CleanCategory.cs b/code/Common/Configuration/DownloadCleaner/CleanCategory.cs deleted file mode 100644 index 48574cfe..00000000 --- a/code/Common/Configuration/DownloadCleaner/CleanCategory.cs +++ /dev/null @@ -1,45 +0,0 @@ -๏ปฟusing Common.Exceptions; -using Microsoft.Extensions.Configuration; - -namespace Common.Configuration.DownloadCleaner; - -public sealed record CleanCategory : IConfig -{ - public required string Name { get; init; } - - /// - /// Max ratio before removing a download. - /// - [ConfigurationKeyName("MAX_RATIO")] - public required double MaxRatio { get; init; } = -1; - - /// - /// Min number of hours to seed before removing a download, if the ratio has been met. - /// - [ConfigurationKeyName("MIN_SEED_TIME")] - public required double MinSeedTime { get; init; } = 0; - - /// - /// Number of hours to seed before removing a download. - /// - [ConfigurationKeyName("MAX_SEED_TIME")] - public required double MaxSeedTime { get; init; } = -1; - - public void Validate() - { - if (string.IsNullOrWhiteSpace(Name)) - { - throw new ValidationException($"{nameof(Name)} can not be empty"); - } - - if (MaxRatio < 0 && MaxSeedTime < 0) - { - throw new ValidationException($"both {nameof(MaxRatio)} and {nameof(MaxSeedTime)} are disabled"); - } - - if (MinSeedTime < 0) - { - throw new ValidationException($"{nameof(MinSeedTime)} can not be negative"); - } - } -} \ No newline at end of file diff --git a/code/Common/Configuration/DownloadCleaner/DownloadCleanerConfig.cs b/code/Common/Configuration/DownloadCleaner/DownloadCleanerConfig.cs deleted file mode 100644 index 4bfd4100..00000000 --- a/code/Common/Configuration/DownloadCleaner/DownloadCleanerConfig.cs +++ /dev/null @@ -1,71 +0,0 @@ -๏ปฟusing Common.Exceptions; -using Microsoft.Extensions.Configuration; - -namespace Common.Configuration.DownloadCleaner; - -public sealed record DownloadCleanerConfig : IJobConfig, IIgnoredDownloadsConfig -{ - public const string SectionName = "DownloadCleaner"; - - public bool Enabled { get; init; } - - public List? Categories { get; init; } - - [ConfigurationKeyName("DELETE_PRIVATE")] - public bool DeletePrivate { get; init; } - - [ConfigurationKeyName("IGNORED_DOWNLOADS_PATH")] - public string? IgnoredDownloadsPath { get; init; } - - [ConfigurationKeyName("UNLINKED_TARGET_CATEGORY")] - public string UnlinkedTargetCategory { get; init; } = "cleanuperr-unlinked"; - - [ConfigurationKeyName("UNLINKED_USE_TAG")] - public bool UnlinkedUseTag { get; init; } - - [ConfigurationKeyName("UNLINKED_IGNORED_ROOT_DIR")] - public string UnlinkedIgnoredRootDir { get; init; } = string.Empty; - - [ConfigurationKeyName("UNLINKED_CATEGORIES")] - public List? UnlinkedCategories { get; init; } - - public void Validate() - { - if (!Enabled) - { - return; - } - - if (Categories?.GroupBy(x => x.Name).Any(x => x.Count() > 1) is true) - { - throw new ValidationException("duplicated clean categories found"); - } - - Categories?.ForEach(x => x.Validate()); - - if (string.IsNullOrEmpty(UnlinkedTargetCategory)) - { - return; - } - - if (UnlinkedCategories?.Count is null or 0) - { - throw new ValidationException("no unlinked categories configured"); - } - - if (UnlinkedCategories.Contains(UnlinkedTargetCategory)) - { - throw new ValidationException($"{SectionName.ToUpperInvariant()}__UNLINKED_TARGET_CATEGORY should not be present in {SectionName.ToUpperInvariant()}__UNLINKED_CATEGORIES"); - } - - if (UnlinkedCategories.Any(string.IsNullOrEmpty)) - { - throw new ValidationException("empty unlinked category filter found"); - } - - if (!string.IsNullOrEmpty(UnlinkedIgnoredRootDir) && !Directory.Exists(UnlinkedIgnoredRootDir)) - { - throw new ValidationException($"{UnlinkedIgnoredRootDir} root directory does not exist"); - } - } -} \ No newline at end of file diff --git a/code/Common/Configuration/DownloadClient/DelugeConfig.cs b/code/Common/Configuration/DownloadClient/DelugeConfig.cs deleted file mode 100644 index 59033a28..00000000 --- a/code/Common/Configuration/DownloadClient/DelugeConfig.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Common.Exceptions; -using Microsoft.Extensions.Configuration; - -namespace Common.Configuration.DownloadClient; - -public sealed record DelugeConfig : IConfig -{ - public const string SectionName = "Deluge"; - - public Uri? Url { get; init; } - - [ConfigurationKeyName("URL_BASE")] - public string UrlBase { get; init; } = string.Empty; - - public string? Password { get; init; } - - public void Validate() - { - if (Url is null) - { - throw new ValidationException($"{nameof(Url)} is empty"); - } - } -} \ No newline at end of file diff --git a/code/Common/Configuration/DownloadClient/DownloadClientConfig.cs b/code/Common/Configuration/DownloadClient/DownloadClientConfig.cs deleted file mode 100644 index 73b17087..00000000 --- a/code/Common/Configuration/DownloadClient/DownloadClientConfig.cs +++ /dev/null @@ -1,9 +0,0 @@ -๏ปฟusing Microsoft.Extensions.Configuration; - -namespace Common.Configuration.DownloadClient; - -public sealed record DownloadClientConfig -{ - [ConfigurationKeyName("DOWNLOAD_CLIENT")] - public Enums.DownloadClient DownloadClient { get; init; } = Enums.DownloadClient.None; -} \ No newline at end of file diff --git a/code/Common/Configuration/DownloadClient/QBitConfig.cs b/code/Common/Configuration/DownloadClient/QBitConfig.cs deleted file mode 100644 index 7de0fafb..00000000 --- a/code/Common/Configuration/DownloadClient/QBitConfig.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Common.Exceptions; -using Microsoft.Extensions.Configuration; - -namespace Common.Configuration.DownloadClient; - -public sealed class QBitConfig : IConfig -{ - public const string SectionName = "qBittorrent"; - - public Uri? Url { get; init; } - - [ConfigurationKeyName("URL_BASE")] - public string UrlBase { get; init; } = string.Empty; - - public string? Username { get; init; } - - public string? Password { get; init; } - - public void Validate() - { - if (Url is null) - { - throw new ValidationException($"{nameof(Url)} is empty"); - } - } -} \ No newline at end of file diff --git a/code/Common/Configuration/DownloadClient/TransmissionConfig.cs b/code/Common/Configuration/DownloadClient/TransmissionConfig.cs deleted file mode 100644 index 4d30b626..00000000 --- a/code/Common/Configuration/DownloadClient/TransmissionConfig.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Common.Exceptions; -using Microsoft.Extensions.Configuration; - -namespace Common.Configuration.DownloadClient; - -public record TransmissionConfig : IConfig -{ - public const string SectionName = "Transmission"; - - public Uri? Url { get; init; } - - [ConfigurationKeyName("URL_BASE")] - public string UrlBase { get; init; } = "transmission"; - - public string? Username { get; init; } - - public string? Password { get; init; } - - public void Validate() - { - if (Url is null) - { - throw new ValidationException($"{nameof(Url)} is empty"); - } - } -} \ No newline at end of file diff --git a/code/Common/Configuration/General/DryRunConfig.cs b/code/Common/Configuration/General/DryRunConfig.cs deleted file mode 100644 index 22b9c419..00000000 --- a/code/Common/Configuration/General/DryRunConfig.cs +++ /dev/null @@ -1,9 +0,0 @@ -๏ปฟusing Microsoft.Extensions.Configuration; - -namespace Common.Configuration.General; - -public sealed record DryRunConfig -{ - [ConfigurationKeyName("DRY_RUN")] - public bool IsDryRun { get; init; } -} \ No newline at end of file diff --git a/code/Common/Configuration/General/HttpConfig.cs b/code/Common/Configuration/General/HttpConfig.cs deleted file mode 100644 index 60c3f05b..00000000 --- a/code/Common/Configuration/General/HttpConfig.cs +++ /dev/null @@ -1,25 +0,0 @@ -๏ปฟusing Common.Enums; -using Common.Exceptions; -using Microsoft.Extensions.Configuration; - -namespace Common.Configuration.General; - -public sealed record HttpConfig : IConfig -{ - [ConfigurationKeyName("HTTP_MAX_RETRIES")] - public ushort MaxRetries { get; init; } - - [ConfigurationKeyName("HTTP_TIMEOUT")] - public ushort Timeout { get; init; } = 100; - - [ConfigurationKeyName("HTTP_VALIDATE_CERT")] - public CertificateValidationType CertificateValidation { get; init; } = CertificateValidationType.Enabled; - - public void Validate() - { - if (Timeout is 0) - { - throw new ValidationException("HTTP_TIMEOUT must be greater than 0"); - } - } -} \ No newline at end of file diff --git a/code/Common/Configuration/General/SearchConfig.cs b/code/Common/Configuration/General/SearchConfig.cs deleted file mode 100644 index 4439b187..00000000 --- a/code/Common/Configuration/General/SearchConfig.cs +++ /dev/null @@ -1,12 +0,0 @@ -๏ปฟusing Microsoft.Extensions.Configuration; - -namespace Common.Configuration.General; - -public sealed record SearchConfig -{ - [ConfigurationKeyName("SEARCH_ENABLED")] - public bool SearchEnabled { get; init; } = true; - - [ConfigurationKeyName("SEARCH_DELAY")] - public ushort SearchDelay { get; init; } = 30; -} \ No newline at end of file diff --git a/code/Common/Configuration/General/TriggersConfig.cs b/code/Common/Configuration/General/TriggersConfig.cs deleted file mode 100644 index 77aee797..00000000 --- a/code/Common/Configuration/General/TriggersConfig.cs +++ /dev/null @@ -1,12 +0,0 @@ -๏ปฟnamespace Common.Configuration.General; - -public sealed class TriggersConfig -{ - public const string SectionName = "Triggers"; - - public required string QueueCleaner { get; init; } - - public required string ContentBlocker { get; init; } - - public required string DownloadCleaner { get; init; } -} \ No newline at end of file diff --git a/code/Common/Configuration/IConfig.cs b/code/Common/Configuration/IConfig.cs deleted file mode 100644 index b653cb3d..00000000 --- a/code/Common/Configuration/IConfig.cs +++ /dev/null @@ -1,6 +0,0 @@ -๏ปฟnamespace Common.Configuration; - -public interface IConfig -{ - void Validate(); -} \ No newline at end of file diff --git a/code/Common/Configuration/IIgnoredDownloadsConfig.cs b/code/Common/Configuration/IIgnoredDownloadsConfig.cs deleted file mode 100644 index f08e445a..00000000 --- a/code/Common/Configuration/IIgnoredDownloadsConfig.cs +++ /dev/null @@ -1,6 +0,0 @@ -๏ปฟnamespace Common.Configuration; - -public interface IIgnoredDownloadsConfig -{ - string? IgnoredDownloadsPath { get; } -} \ No newline at end of file diff --git a/code/Common/Configuration/IJobConfig.cs b/code/Common/Configuration/IJobConfig.cs deleted file mode 100644 index 1ef7a8f1..00000000 --- a/code/Common/Configuration/IJobConfig.cs +++ /dev/null @@ -1,6 +0,0 @@ -๏ปฟnamespace Common.Configuration; - -public interface IJobConfig : IConfig -{ - bool Enabled { get; init; } -} \ No newline at end of file diff --git a/code/Common/Configuration/Logging/FileLogConfig.cs b/code/Common/Configuration/Logging/FileLogConfig.cs deleted file mode 100644 index 17d3ed77..00000000 --- a/code/Common/Configuration/Logging/FileLogConfig.cs +++ /dev/null @@ -1,12 +0,0 @@ -๏ปฟnamespace Common.Configuration.Logging; - -public class FileLogConfig : IConfig -{ - public bool Enabled { get; set; } - - public string Path { get; set; } = string.Empty; - - public void Validate() - { - } -} \ No newline at end of file diff --git a/code/Common/Configuration/Logging/LoggingConfig.cs b/code/Common/Configuration/Logging/LoggingConfig.cs deleted file mode 100644 index 187a3911..00000000 --- a/code/Common/Configuration/Logging/LoggingConfig.cs +++ /dev/null @@ -1,18 +0,0 @@ -๏ปฟusing Serilog.Events; - -namespace Common.Configuration.Logging; - -public class LoggingConfig : IConfig -{ - public const string SectionName = "Logging"; - - public LogEventLevel LogLevel { get; set; } - - public bool Enhanced { get; set; } - - public FileLogConfig? File { get; set; } - - public void Validate() - { - } -} \ No newline at end of file diff --git a/code/Common/Configuration/QueueCleaner/QueueCleanerConfig.cs b/code/Common/Configuration/QueueCleaner/QueueCleanerConfig.cs deleted file mode 100644 index b8f746b8..00000000 --- a/code/Common/Configuration/QueueCleaner/QueueCleanerConfig.cs +++ /dev/null @@ -1,119 +0,0 @@ -๏ปฟusing Common.CustomDataTypes; -using Common.Exceptions; -using Microsoft.Extensions.Configuration; - -namespace Common.Configuration.QueueCleaner; - -public sealed record QueueCleanerConfig : IJobConfig, IIgnoredDownloadsConfig -{ - public const string SectionName = "QueueCleaner"; - - public required bool Enabled { get; init; } - - public required bool RunSequentially { get; init; } - - [ConfigurationKeyName("IGNORED_DOWNLOADS_PATH")] - public string? IgnoredDownloadsPath { get; init; } - - [ConfigurationKeyName("IMPORT_FAILED_MAX_STRIKES")] - public ushort ImportFailedMaxStrikes { get; init; } - - [ConfigurationKeyName("IMPORT_FAILED_IGNORE_PRIVATE")] - public bool ImportFailedIgnorePrivate { get; init; } - - [ConfigurationKeyName("IMPORT_FAILED_DELETE_PRIVATE")] - public bool ImportFailedDeletePrivate { get; init; } - - [ConfigurationKeyName("IMPORT_FAILED_IGNORE_PATTERNS")] - public IReadOnlyList? ImportFailedIgnorePatterns { get; init; } - - [ConfigurationKeyName("STALLED_MAX_STRIKES")] - public ushort StalledMaxStrikes { get; init; } - - [ConfigurationKeyName("STALLED_RESET_STRIKES_ON_PROGRESS")] - public bool StalledResetStrikesOnProgress { get; init; } - - [ConfigurationKeyName("STALLED_IGNORE_PRIVATE")] - public bool StalledIgnorePrivate { get; init; } - - [ConfigurationKeyName("STALLED_DELETE_PRIVATE")] - public bool StalledDeletePrivate { get; init; } - - [ConfigurationKeyName("DOWNLOADING_METADATA_MAX_STRIKES")] - public ushort DownloadingMetadataMaxStrikes { get; init; } - - [ConfigurationKeyName("SLOW_MAX_STRIKES")] - public ushort SlowMaxStrikes { get; init; } - - [ConfigurationKeyName("SLOW_RESET_STRIKES_ON_PROGRESS")] - public bool SlowResetStrikesOnProgress { get; init; } - - [ConfigurationKeyName("SLOW_IGNORE_PRIVATE")] - public bool SlowIgnorePrivate { get; init; } - - [ConfigurationKeyName("SLOW_DELETE_PRIVATE")] - public bool SlowDeletePrivate { get; init; } - - [ConfigurationKeyName("SLOW_MIN_SPEED")] - public string SlowMinSpeed { get; init; } = string.Empty; - - public ByteSize SlowMinSpeedByteSize => string.IsNullOrEmpty(SlowMinSpeed) ? new ByteSize(0) : ByteSize.Parse(SlowMinSpeed); - - [ConfigurationKeyName("SLOW_MAX_TIME")] - public double SlowMaxTime { get; init; } - - [ConfigurationKeyName("SLOW_IGNORE_ABOVE_SIZE")] - public string SlowIgnoreAboveSize { get; init; } = string.Empty; - - public ByteSize? SlowIgnoreAboveSizeByteSize => string.IsNullOrEmpty(SlowIgnoreAboveSize) ? null : ByteSize.Parse(SlowIgnoreAboveSize); - - public void Validate() - { - if (ImportFailedMaxStrikes is > 0 and < 3) - { - throw new ValidationException($"the minimum value for {SectionName.ToUpperInvariant()}__IMPORT_FAILED_MAX_STRIKES must be 3"); - } - - if (StalledMaxStrikes is > 0 and < 3) - { - throw new ValidationException($"the minimum value for {SectionName.ToUpperInvariant()}__STALLED_MAX_STRIKES must be 3"); - } - - if (DownloadingMetadataMaxStrikes is > 0 and < 3) - { - throw new ValidationException($"the minimum value for {SectionName.ToUpperInvariant()}__DOWNLOADING_METADATA_MAX_STRIKES must be 3"); - } - - if (SlowMaxStrikes is > 0 and < 3) - { - throw new ValidationException($"the minimum value for {SectionName.ToUpperInvariant()}__SLOW_MAX_STRIKES must be 3"); - } - - if (SlowMaxStrikes > 0) - { - bool isSlowSpeedSet = !string.IsNullOrEmpty(SlowMinSpeed); - - if (isSlowSpeedSet && ByteSize.TryParse(SlowMinSpeed, out _) is false) - { - throw new ValidationException($"invalid value for {SectionName.ToUpperInvariant()}__SLOW_MIN_SPEED"); - } - - if (SlowMaxTime < 0) - { - throw new ValidationException($"invalid value for {SectionName.ToUpperInvariant()}__SLOW_MAX_TIME"); - } - - if (!isSlowSpeedSet && SlowMaxTime is 0) - { - throw new ValidationException($"either {SectionName.ToUpperInvariant()}__SLOW_MIN_SPEED or {SectionName.ToUpperInvariant()}__SLOW_MAX_STRIKES must be set"); - } - - bool isSlowIgnoreAboveSizeSet = !string.IsNullOrEmpty(SlowIgnoreAboveSize); - - if (isSlowIgnoreAboveSizeSet && ByteSize.TryParse(SlowIgnoreAboveSize, out _) is false) - { - throw new ValidationException($"invalid value for {SectionName.ToUpperInvariant()}__SLOW_IGNORE_ABOVE_SIZE"); - } - } - } -} \ No newline at end of file diff --git a/code/Common/Enums/CertificateValidationType.cs b/code/Common/Enums/CertificateValidationType.cs deleted file mode 100644 index bc935987..00000000 --- a/code/Common/Enums/CertificateValidationType.cs +++ /dev/null @@ -1,8 +0,0 @@ -๏ปฟnamespace Common.Enums; - -public enum CertificateValidationType -{ - Enabled = 0, - DisabledForLocalAddresses = 1, - Disabled = 2 -} \ No newline at end of file diff --git a/code/Common/Enums/DownloadClient.cs b/code/Common/Enums/DownloadClient.cs deleted file mode 100644 index 7c215064..00000000 --- a/code/Common/Enums/DownloadClient.cs +++ /dev/null @@ -1,10 +0,0 @@ -๏ปฟnamespace Common.Enums; - -public enum DownloadClient -{ - QBittorrent, - Deluge, - Transmission, - None, - Disabled -} \ No newline at end of file diff --git a/code/Dockerfile b/code/Dockerfile new file mode 100644 index 00000000..6b19ed15 --- /dev/null +++ b/code/Dockerfile @@ -0,0 +1,72 @@ +# Build Angular frontend +FROM --platform=$BUILDPLATFORM node:18-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 + +# Copy source code +COPY frontend/ . + +# Build with appropriate base-href and deploy-url +RUN npm run build + +# Build .NET backend +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build +ARG TARGETARCH +ARG VERSION=0.0.1 +ARG PACKAGES_USERNAME +ARG PACKAGES_PAT +WORKDIR /app +EXPOSE 11011 + +# Copy solution and project files first for better layer caching +# COPY backend/*.sln ./backend/ +# COPY backend/*/*.csproj ./backend/*/ + +# Copy source code +COPY backend/ ./backend/ + +# Restore dependencies +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 \ + -a $TARGETARCH \ + -c Release \ + -o /app/publish \ + /p:Version=${VERSION} \ + /p:PublishSingleFile=true \ + /p:DebugSymbols=false + +# Runtime stage +FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim + +# Install required packages for user management and timezone support +RUN apt-get update && apt-get install -y \ + tzdata \ + gosu \ + && rm -rf /var/lib/apt/lists/* + +ENV PUID=1000 \ + PGID=1000 \ + UMASK=022 \ + TZ=Etc/UTC \ + HTTP_PORTS=11011 + +# Fix FileSystemWatcher in Docker: https://github.com/dotnet/dotnet-docker/issues/3546 +ENV DOTNET_USE_POLLING_FILE_WATCHER=true + +WORKDIR /app + +# Copy backend +COPY --from=build /app/publish . +# Copy frontend to wwwroot +COPY --from=frontend-build /app/dist/ui/browser ./wwwroot +# Copy entrypoint script +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["./Cleanuparr"] \ No newline at end of file diff --git a/code/Domain/Enums/InstanceType.cs b/code/Domain/Enums/InstanceType.cs deleted file mode 100644 index 4ec96a4c..00000000 --- a/code/Domain/Enums/InstanceType.cs +++ /dev/null @@ -1,9 +0,0 @@ -๏ปฟnamespace Domain.Enums; - -public enum InstanceType -{ - Sonarr, - Radarr, - Lidarr, - Readarr -} \ No newline at end of file diff --git a/code/Domain/Models/Arr/Blocking/BlockedItem.cs b/code/Domain/Models/Arr/Blocking/BlockedItem.cs deleted file mode 100644 index b7d94687..00000000 --- a/code/Domain/Models/Arr/Blocking/BlockedItem.cs +++ /dev/null @@ -1,8 +0,0 @@ -๏ปฟnamespace Domain.Models.Arr.Blocking; - -public record BlockedItem -{ - public required string Hash { get; init; } - - public required Uri InstanceUrl { get; init; } -} \ No newline at end of file diff --git a/code/Domain/Models/Arr/Blocking/LidarrBlockedItem.cs b/code/Domain/Models/Arr/Blocking/LidarrBlockedItem.cs deleted file mode 100644 index 6a2b59af..00000000 --- a/code/Domain/Models/Arr/Blocking/LidarrBlockedItem.cs +++ /dev/null @@ -1,8 +0,0 @@ -๏ปฟnamespace Domain.Models.Arr.Blocking; - -public sealed record LidarrBlockedItem : BlockedItem -{ - public required long AlbumId { get; init; } - - public required long ArtistId { get; init; } -} \ No newline at end of file diff --git a/code/Domain/Models/Arr/Blocking/RadarrBlockedItem.cs b/code/Domain/Models/Arr/Blocking/RadarrBlockedItem.cs deleted file mode 100644 index 16532dab..00000000 --- a/code/Domain/Models/Arr/Blocking/RadarrBlockedItem.cs +++ /dev/null @@ -1,6 +0,0 @@ -๏ปฟnamespace Domain.Models.Arr.Blocking; - -public sealed record RadarrBlockedItem : BlockedItem -{ - public required long MovieId { get; init; } -} \ No newline at end of file diff --git a/code/Domain/Models/Arr/Blocking/SonarrBlockedItem.cs b/code/Domain/Models/Arr/Blocking/SonarrBlockedItem.cs deleted file mode 100644 index a42b0acb..00000000 --- a/code/Domain/Models/Arr/Blocking/SonarrBlockedItem.cs +++ /dev/null @@ -1,10 +0,0 @@ -๏ปฟnamespace Domain.Models.Arr.Blocking; - -public sealed record SonarrBlockedItem : BlockedItem -{ - public required long EpisodeId { get; init; } - - public required long SeasonNumber { get; init; } - - public required long SeriesId { get; init; } -} \ No newline at end of file diff --git a/code/Executable/DependencyInjection/ConfigurationDI.cs b/code/Executable/DependencyInjection/ConfigurationDI.cs deleted file mode 100644 index ee5cacb4..00000000 --- a/code/Executable/DependencyInjection/ConfigurationDI.cs +++ /dev/null @@ -1,28 +0,0 @@ -๏ปฟusing Common.Configuration.Arr; -using Common.Configuration.ContentBlocker; -using Common.Configuration.DownloadCleaner; -using Common.Configuration.DownloadClient; -using Common.Configuration.General; -using Common.Configuration.Logging; -using Common.Configuration.QueueCleaner; - -namespace Executable.DependencyInjection; - -public static class ConfigurationDI -{ - public static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) => - services - .Configure(configuration) - .Configure(configuration) - .Configure(configuration.GetSection(QueueCleanerConfig.SectionName)) - .Configure(configuration.GetSection(ContentBlockerConfig.SectionName)) - .Configure(configuration.GetSection(DownloadCleanerConfig.SectionName)) - .Configure(configuration) - .Configure(configuration.GetSection(QBitConfig.SectionName)) - .Configure(configuration.GetSection(DelugeConfig.SectionName)) - .Configure(configuration.GetSection(TransmissionConfig.SectionName)) - .Configure(configuration.GetSection(SonarrConfig.SectionName)) - .Configure(configuration.GetSection(RadarrConfig.SectionName)) - .Configure(configuration.GetSection(LidarrConfig.SectionName)) - .Configure(configuration.GetSection(LoggingConfig.SectionName)); -} \ No newline at end of file diff --git a/code/Executable/DependencyInjection/LoggingDI.cs b/code/Executable/DependencyInjection/LoggingDI.cs deleted file mode 100644 index eb1e4ad2..00000000 --- a/code/Executable/DependencyInjection/LoggingDI.cs +++ /dev/null @@ -1,81 +0,0 @@ -๏ปฟusing Common.Configuration.Logging; -using Domain.Enums; -using Infrastructure.Verticals.ContentBlocker; -using Infrastructure.Verticals.DownloadCleaner; -using Infrastructure.Verticals.QueueCleaner; -using Serilog; -using Serilog.Events; -using Serilog.Templates; -using Serilog.Templates.Themes; - -namespace Executable.DependencyInjection; - -public static class LoggingDI -{ - public static ILoggingBuilder AddLogging(this ILoggingBuilder builder, IConfiguration configuration) - { - LoggingConfig? config = configuration.GetSection(LoggingConfig.SectionName).Get(); - - if (!string.IsNullOrEmpty(config?.File?.Path) && !Directory.Exists(config.File.Path)) - { - try - { - Directory.CreateDirectory(config.File.Path); - } - catch (Exception exception) - { - throw new Exception($"log file path is not a valid directory | {config.File.Path}", exception); - } - } - - LoggerConfiguration logConfig = new(); - const string jobNameTemplate = "{#if JobName is not null} {Concat('[',JobName,']'),JOB_PAD}{#end}"; - const string instanceNameTemplate = "{#if InstanceName is not null} {Concat('[',InstanceName,']'),ARR_PAD}{#end}"; - const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{instanceNameTemplate} {{@m}}\n{{@x}}"; - const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{instanceNameTemplate} {{@m:lj}}\n{{@x}}"; - LogEventLevel level = LogEventLevel.Information; - List names = [nameof(ContentBlocker), nameof(QueueCleaner), nameof(DownloadCleaner)]; - int jobPadding = names.Max(x => x.Length) + 2; - names = [InstanceType.Sonarr.ToString(), InstanceType.Radarr.ToString(), InstanceType.Lidarr.ToString()]; - int arrPadding = names.Max(x => x.Length) + 2; - - string consoleTemplate = consoleOutputTemplate - .Replace("JOB_PAD", jobPadding.ToString()) - .Replace("ARR_PAD", arrPadding.ToString()); - string fileTemplate = fileOutputTemplate - .Replace("JOB_PAD", jobPadding.ToString()) - .Replace("ARR_PAD", arrPadding.ToString()); - - if (config is not null) - { - level = config.LogLevel; - - if (config.File?.Enabled is true) - { - logConfig.WriteTo.File( - path: Path.Combine(config.File.Path, "cleanuperr-.txt"), - formatter: new ExpressionTemplate(fileTemplate), - fileSizeLimitBytes: 10L * 1024 * 1024, - rollingInterval: RollingInterval.Day, - rollOnFileSizeLimit: true - ); - } - } - - Log.Logger = logConfig - .MinimumLevel.Is(level) - .MinimumLevel.Override("MassTransit", LogEventLevel.Warning) - .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information) - .MinimumLevel.Override("Microsoft.Extensions.Http", LogEventLevel.Warning) - .MinimumLevel.Override("Quartz", LogEventLevel.Warning) - .MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error) - .WriteTo.Console(new ExpressionTemplate(consoleTemplate)) - .Enrich.FromLogContext() - .Enrich.WithProperty("ApplicationName", "cleanuperr") - .CreateLogger(); - - return builder - .ClearProviders() - .AddSerilog(); - } -} \ No newline at end of file diff --git a/code/Executable/DependencyInjection/QuartzDI.cs b/code/Executable/DependencyInjection/QuartzDI.cs deleted file mode 100644 index 31b1ea40..00000000 --- a/code/Executable/DependencyInjection/QuartzDI.cs +++ /dev/null @@ -1,144 +0,0 @@ -๏ปฟusing Common.Configuration; -using Common.Configuration.ContentBlocker; -using Common.Configuration.DownloadCleaner; -using Common.Configuration.General; -using Common.Configuration.QueueCleaner; -using Common.Helpers; -using Executable.Jobs; -using Infrastructure.Verticals.ContentBlocker; -using Infrastructure.Verticals.DownloadCleaner; -using Infrastructure.Verticals.Jobs; -using Infrastructure.Verticals.QueueCleaner; -using Quartz; -using Quartz.Spi; - -namespace Executable.DependencyInjection; - -public static class QuartzDI -{ - public static IServiceCollection AddQuartzServices(this IServiceCollection services, IConfiguration configuration) => - services - .AddQuartz(q => - { - TriggersConfig? config = configuration - .GetRequiredSection(TriggersConfig.SectionName) - .Get(); - - if (config is null) - { - throw new NullReferenceException("triggers configuration is null"); - } - - q.AddJobs(configuration, config); - }) - .AddQuartzHostedService(opt => - { - opt.WaitForJobsToComplete = true; - }); - - private static void AddJobs( - this IServiceCollectionQuartzConfigurator q, - IConfiguration configuration, - TriggersConfig triggersConfig - ) - { - ContentBlockerConfig? contentBlockerConfig = configuration - .GetRequiredSection(ContentBlockerConfig.SectionName) - .Get(); - - q.AddJob(contentBlockerConfig, triggersConfig.ContentBlocker); - - QueueCleanerConfig? queueCleanerConfig = configuration - .GetRequiredSection(QueueCleanerConfig.SectionName) - .Get(); - - if (contentBlockerConfig?.Enabled is true && queueCleanerConfig is { Enabled: true, RunSequentially: true }) - { - q.AddJob(queueCleanerConfig, string.Empty); - q.AddJobListener(new JobChainingListener(nameof(ContentBlocker), nameof(QueueCleaner))); - } - else - { - q.AddJob(queueCleanerConfig, triggersConfig.QueueCleaner); - } - - DownloadCleanerConfig? downloadCleanerConfig = configuration - .GetRequiredSection(DownloadCleanerConfig.SectionName) - .Get(); - - q.AddJob(downloadCleanerConfig, triggersConfig.DownloadCleaner); - } - - private static void AddJob( - this IServiceCollectionQuartzConfigurator q, - IJobConfig? config, - string trigger - ) where T: GenericHandler - { - string typeName = typeof(T).Name; - - if (config is null) - { - throw new NullReferenceException($"{typeName} configuration is null"); - } - - if (!config.Enabled) - { - return; - } - - bool hasTrigger = trigger.Length > 0; - - q.AddJob>(opts => - { - opts.WithIdentity(typeName); - - if (!hasTrigger) - { - // jobs with no triggers need to be stored durably - opts.StoreDurably(); - } - }); - - // skip empty triggers - if (!hasTrigger) - { - return; - } - - IOperableTrigger triggerObj = (IOperableTrigger)TriggerBuilder.Create() - .WithIdentity("ExampleTrigger") - .StartNow() - .WithCronSchedule(trigger) - .Build(); - - IReadOnlyList nextFireTimes = TriggerUtils.ComputeFireTimes(triggerObj, null, 2); - TimeSpan triggerValue = nextFireTimes[1] - nextFireTimes[0]; - - if (triggerValue > Constants.TriggerMaxLimit) - { - throw new Exception($"{trigger} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours"); - } - - if (triggerValue > StaticConfiguration.TriggerValue) - { - StaticConfiguration.TriggerValue = triggerValue; - } - - q.AddTrigger(opts => - { - opts.ForJob(typeName) - .WithIdentity($"{typeName}-trigger") - .WithCronSchedule(trigger, x =>x.WithMisfireHandlingInstructionDoNothing()) - .StartNow(); - }); - - // Startup trigger - q.AddTrigger(opts => - { - opts.ForJob(typeName) - .WithIdentity($"{typeName}-startup-trigger") - .StartNow(); - }); - } -} \ No newline at end of file diff --git a/code/Executable/DependencyInjection/ServicesDI.cs b/code/Executable/DependencyInjection/ServicesDI.cs deleted file mode 100644 index e2e557de..00000000 --- a/code/Executable/DependencyInjection/ServicesDI.cs +++ /dev/null @@ -1,51 +0,0 @@ -๏ปฟusing Common.Configuration.ContentBlocker; -using Common.Configuration.DownloadCleaner; -using Common.Configuration.QueueCleaner; -using Infrastructure.Interceptors; -using Infrastructure.Providers; -using Infrastructure.Services; -using Infrastructure.Verticals.Arr; -using Infrastructure.Verticals.ContentBlocker; -using Infrastructure.Verticals.DownloadCleaner; -using Infrastructure.Verticals.DownloadClient; -using Infrastructure.Verticals.DownloadClient.Deluge; -using Infrastructure.Verticals.DownloadClient.QBittorrent; -using Infrastructure.Verticals.DownloadClient.Transmission; -using Infrastructure.Verticals.DownloadRemover; -using Infrastructure.Verticals.DownloadRemover.Interfaces; -using Infrastructure.Verticals.Files; -using Infrastructure.Verticals.ItemStriker; -using Infrastructure.Verticals.QueueCleaner; - -namespace Executable.DependencyInjection; - -public static class ServicesDI -{ - public static IServiceCollection AddServices(this IServiceCollection services) => - services - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() - .AddSingleton() - .AddSingleton>() - .AddSingleton>() - .AddSingleton>(); -} \ No newline at end of file diff --git a/code/Executable/Executable.csproj b/code/Executable/Executable.csproj deleted file mode 100644 index 79c6b329..00000000 --- a/code/Executable/Executable.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - - cleanuperr - net9.0 - 0.0.1 - enable - enable - dotnet-Executable-6108b2ba-f035-47bc-addf-aaf5e20da4b8 - - - - - - - - - - - - - - - - - - - - - - diff --git a/code/Executable/HostExtensions.cs b/code/Executable/HostExtensions.cs deleted file mode 100644 index 0aa8d795..00000000 --- a/code/Executable/HostExtensions.cs +++ /dev/null @@ -1,23 +0,0 @@ -๏ปฟusing System.Reflection; - -namespace Executable; - -public static class HostExtensions -{ - public static IHost Init(this IHost host) - { - ILogger logger = host.Services.GetRequiredService>(); - - Version? version = Assembly.GetExecutingAssembly().GetName().Version; - - logger.LogInformation( - version is null - ? "cleanuperr version not detected" - : $"cleanuperr v{version.Major}.{version.Minor}.{version.Build}" - ); - - logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName); - - return host; - } -} \ No newline at end of file diff --git a/code/Executable/Program.cs b/code/Executable/Program.cs deleted file mode 100644 index 5623667f..00000000 --- a/code/Executable/Program.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Executable; -using Executable.DependencyInjection; - -var builder = Host.CreateApplicationBuilder(args); - -builder.Services.AddInfrastructure(builder.Configuration); -builder.Logging.AddLogging(builder.Configuration); - -var host = builder.Build(); -host.Init(); - -host.Run(); \ No newline at end of file diff --git a/code/Executable/appsettings.Development.json b/code/Executable/appsettings.Development.json deleted file mode 100644 index 536b1ced..00000000 --- a/code/Executable/appsettings.Development.json +++ /dev/null @@ -1,151 +0,0 @@ -{ - "DRY_RUN": true, - "HTTP_MAX_RETRIES": 0, - "HTTP_TIMEOUT": 100, - "HTTP_VALIDATE_CERT": "enabled", - "Logging": { - "LogLevel": "Verbose", - "Enhanced": true, - "File": { - "Enabled": false, - "Path": "" - } - }, - "SEARCH_ENABLED": true, - "SEARCH_DELAY": 5, - "Triggers": { - "QueueCleaner": "0/10 * * * * ?", - "ContentBlocker": "0/10 * * * * ?", - "DownloadCleaner": "0/10 * * * * ?" - }, - "ContentBlocker": { - "Enabled": true, - "IGNORE_PRIVATE": true, - "DELETE_PRIVATE": false, - "IGNORED_DOWNLOADS_PATH": "../test/data/cleanuperr/ignored_downloads" - }, - "QueueCleaner": { - "Enabled": true, - "RunSequentially": true, - "IGNORED_DOWNLOADS_PATH": "../test/data/cleanuperr/ignored_downloads", - "IMPORT_FAILED_MAX_STRIKES": 3, - "IMPORT_FAILED_IGNORE_PRIVATE": true, - "IMPORT_FAILED_DELETE_PRIVATE": false, - "IMPORT_FAILED_IGNORE_PATTERNS": [ - "file is a sample" - ], - "STALLED_MAX_STRIKES": 3, - "STALLED_RESET_STRIKES_ON_PROGRESS": true, - "STALLED_IGNORE_PRIVATE": true, - "STALLED_DELETE_PRIVATE": false, - "DOWNLOADING_METADATA_MAX_STRIKES": 3, - "SLOW_MAX_STRIKES": 5, - "SLOW_RESET_STRIKES_ON_PROGRESS": true, - "SLOW_IGNORE_PRIVATE": false, - "SLOW_DELETE_PRIVATE": false, - "SLOW_MIN_SPEED": "1MB", - "SLOW_MAX_TIME": 20, - "SLOW_IGNORE_ABOVE_SIZE": "4GB" - }, - "DownloadCleaner": { - "Enabled": false, - "DELETE_PRIVATE": false, - "CATEGORIES": [ - { - "Name": "tv-sonarr", - "MAX_RATIO": -1, - "MIN_SEED_TIME": 0, - "MAX_SEED_TIME": 240 - } - ], - "UNLINKED_TARGET_CATEGORY": "cleanuperr-unlinked", - "UNLINKED_USE_TAG": false, - "UNLINKED_IGNORED_ROOT_DIR": "", - "UNLINKED_CATEGORIES": [ - "tv-sonarr", - "radarr" - ], - "IGNORED_DOWNLOADS_PATH": "../test/data/cleanuperr/ignored_downloads" - }, - "DOWNLOAD_CLIENT": "qbittorrent", - "qBittorrent": { - "Url": "http://localhost:8080", - "URL_BASE": "", - "Username": "test", - "Password": "testing" - }, - "Deluge": { - "Url": "http://localhost:8112", - "URL_BASE": "", - "Password": "testing" - }, - "Transmission": { - "Url": "http://localhost:9091", - "URL_BASE": "transmission", - "Username": "test", - "Password": "testing" - }, - "Sonarr": { - "Enabled": true, - "IMPORT_FAILED_MAX_STRIKES": -1, - "SearchType": "Episode", - "Block": { - "Type": "blacklist", - "Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist" - }, - "Instances": [ - { - "Url": "http://localhost:8989", - "ApiKey": "425d1e713f0c405cbbf359ac0502c1f4" - } - ] - }, - "Radarr": { - "Enabled": true, - "IMPORT_FAILED_MAX_STRIKES": -1, - "Block": { - "Type": "blacklist", - "Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist" - }, - "Instances": [ - { - "Url": "http://localhost:7878", - "ApiKey": "8b7454f668e54c5b8f44f56f93969761" - } - ] - }, - "Lidarr": { - "Enabled": true, - "IMPORT_FAILED_MAX_STRIKES": -1, - "Block": { - "Type": "blacklist", - "Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist" - }, - "Instances": [ - { - "Url": "http://localhost:8686", - "ApiKey": "7f677cfdc074414397af53dd633860c5" - } - ] - }, - "Notifiarr": { - "ON_IMPORT_FAILED_STRIKE": true, - "ON_STALLED_STRIKE": true, - "ON_SLOW_STRIKE": true, - "ON_QUEUE_ITEM_DELETED": true, - "ON_DOWNLOAD_CLEANED": true, - "ON_CATEGORY_CHANGED": true, - "API_KEY": "", - "CHANNEL_ID": "" - }, - "Apprise": { - "ON_IMPORT_FAILED_STRIKE": true, - "ON_STALLED_STRIKE": true, - "ON_SLOW_STRIKE": true, - "ON_QUEUE_ITEM_DELETED": true, - "ON_DOWNLOAD_CLEANED": true, - "ON_CATEGORY_CHANGED": true, - "URL": "http://localhost:8000", - "KEY": "" - } -} diff --git a/code/Executable/appsettings.json b/code/Executable/appsettings.json deleted file mode 100644 index 35bb7679..00000000 --- a/code/Executable/appsettings.json +++ /dev/null @@ -1,138 +0,0 @@ -{ - "DRY_RUN": false, - "HTTP_MAX_RETRIES": 0, - "HTTP_TIMEOUT": 100, - "HTTP_VALIDATE_CERT": "enabled", - "Logging": { - "LogLevel": "Information", - "Enhanced": true, - "File": { - "Enabled": false, - "Path": "" - } - }, - "SEARCH_ENABLED": true, - "SEARCH_DELAY": 30, - "Triggers": { - "QueueCleaner": "0 0/5 * * * ?", - "ContentBlocker": "0 0/5 * * * ?", - "DownloadCleaner": "0 0 * * * ?" - }, - "ContentBlocker": { - "Enabled": false, - "IGNORE_PRIVATE": false, - "IGNORED_DOWNLOADS_PATH": "" - }, - "QueueCleaner": { - "Enabled": false, - "RunSequentially": true, - "IGNORED_DOWNLOADS_PATH": "", - "IMPORT_FAILED_MAX_STRIKES": 0, - "IMPORT_FAILED_IGNORE_PRIVATE": false, - "IMPORT_FAILED_DELETE_PRIVATE": false, - "IMPORT_FAILED_IGNORE_PATTERNS": [], - "STALLED_MAX_STRIKES": 0, - "STALLED_RESET_STRIKES_ON_PROGRESS": false, - "STALLED_IGNORE_PRIVATE": false, - "STALLED_DELETE_PRIVATE": false, - "DOWNLOADING_METADATA_MAX_STRIKES": 0, - "SLOW_MAX_STRIKES": 0, - "SLOW_RESET_STRIKES_ON_PROGRESS": true, - "SLOW_IGNORE_PRIVATE": false, - "SLOW_DELETE_PRIVATE": false, - "SLOW_MIN_SPEED": "", - "SLOW_MAX_TIME": 0, - "SLOW_IGNORE_ABOVE_SIZE": "" - }, - "DownloadCleaner": { - "Enabled": false, - "DELETE_PRIVATE": false, - "CATEGORIES": [], - "UNLINKED_TARGET_CATEGORY": "cleanuperr-unlinked", - "UNLINKED_USE_TAG": false, - "UNLINKED_IGNORED_ROOT_DIR": "", - "UNLINKED_CATEGORIES": [], - "IGNORED_DOWNLOADS_PATH": "" - }, - "DOWNLOAD_CLIENT": "none", - "qBittorrent": { - "Url": "http://localhost:8080", - "URL_BASE": "", - "Username": "", - "Password": "" - }, - "Deluge": { - "Url": "http://localhost:8112", - "URL_BASE": "", - "Password": "testing" - }, - "Transmission": { - "Url": "http://localhost:9091", - "URL_BASE": "transmission", - "Username": "test", - "Password": "testing" - }, - "Sonarr": { - "Enabled": false, - "IMPORT_FAILED_MAX_STRIKES": -1, - "SearchType": "Episode", - "Block": { - "Type": "blacklist", - "Path": "" - }, - "Instances": [ - { - "Url": "http://localhost:8989", - "ApiKey": "" - } - ] - }, - "Radarr": { - "Enabled": false, - "IMPORT_FAILED_MAX_STRIKES": -1, - "Block": { - "Type": "blacklist", - "Path": "" - }, - "Instances": [ - { - "Url": "http://localhost:7878", - "ApiKey": "" - } - ] - }, - "Lidarr": { - "Enabled": false, - "IMPORT_FAILED_MAX_STRIKES": -1, - "Block": { - "Type": "blacklist", - "Path": "" - }, - "Instances": [ - { - "Url": "http://localhost:8686", - "ApiKey": "" - } - ] - }, - "Notifiarr": { - "ON_IMPORT_FAILED_STRIKE": false, - "ON_STALLED_STRIKE": false, - "ON_SLOW_STRIKE": false, - "ON_QUEUE_ITEM_DELETED": false, - "ON_DOWNLOAD_CLEANED": false, - "ON_CATEGORY_CHANGED": false, - "API_KEY": "", - "CHANNEL_ID": "" - }, - "Apprise": { - "ON_IMPORT_FAILED_STRIKE": false, - "ON_STALLED_STRIKE": false, - "ON_SLOW_STRIKE": false, - "ON_QUEUE_ITEM_DELETED": false, - "ON_DOWNLOAD_CLEANED": false, - "ON_CATEGORY_CHANGED": false, - "URL": "", - "KEY": "" - } -} diff --git a/code/Infrastructure.Tests/Verticals/DownloadClient/DownloadServiceFixture.cs b/code/Infrastructure.Tests/Verticals/DownloadClient/DownloadServiceFixture.cs deleted file mode 100644 index 5b89934a..00000000 --- a/code/Infrastructure.Tests/Verticals/DownloadClient/DownloadServiceFixture.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Common.Configuration.ContentBlocker; -using Common.Configuration.DownloadCleaner; -using Common.Configuration.QueueCleaner; -using Infrastructure.Interceptors; -using Infrastructure.Verticals.ContentBlocker; -using Infrastructure.Verticals.DownloadClient; -using Infrastructure.Verticals.Files; -using Infrastructure.Verticals.ItemStriker; -using Infrastructure.Verticals.Notifications; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using NSubstitute; - -namespace Infrastructure.Tests.Verticals.DownloadClient; - -public class DownloadServiceFixture : IDisposable -{ - public ILogger Logger { get; set; } - public IMemoryCache Cache { get; set; } - public IStriker Striker { get; set; } - - public DownloadServiceFixture() - { - Logger = Substitute.For>(); - Cache = Substitute.For(); - Striker = Substitute.For(); - } - - public TestDownloadService CreateSut( - QueueCleanerConfig? queueCleanerConfig = null, - ContentBlockerConfig? contentBlockerConfig = null - ) - { - queueCleanerConfig ??= new QueueCleanerConfig - { - Enabled = true, - RunSequentially = true, - StalledResetStrikesOnProgress = true, - StalledMaxStrikes = 3 - }; - - var queueCleanerOptions = Substitute.For>(); - queueCleanerOptions.Value.Returns(queueCleanerConfig); - - contentBlockerConfig ??= new ContentBlockerConfig - { - Enabled = true - }; - - var contentBlockerOptions = Substitute.For>(); - contentBlockerOptions.Value.Returns(contentBlockerConfig); - - var downloadCleanerOptions = Substitute.For>(); - downloadCleanerOptions.Value.Returns(new DownloadCleanerConfig()); - - var filenameEvaluator = Substitute.For(); - var notifier = Substitute.For(); - var dryRunInterceptor = Substitute.For(); - var hardlinkFileService = Substitute.For(); - - return new TestDownloadService( - Logger, - queueCleanerOptions, - contentBlockerOptions, - downloadCleanerOptions, - Cache, - filenameEvaluator, - Striker, - notifier, - dryRunInterceptor, - hardlinkFileService - ); - } - - public void Dispose() - { - // Cleanup if needed - } -} \ No newline at end of file diff --git a/code/Infrastructure.Tests/Verticals/DownloadClient/DownloadServiceTests.cs b/code/Infrastructure.Tests/Verticals/DownloadClient/DownloadServiceTests.cs deleted file mode 100644 index e69c21c2..00000000 --- a/code/Infrastructure.Tests/Verticals/DownloadClient/DownloadServiceTests.cs +++ /dev/null @@ -1,214 +0,0 @@ -๏ปฟusing Common.Configuration.DownloadCleaner; -using Domain.Enums; -using Domain.Models.Cache; -using Infrastructure.Helpers; -using Infrastructure.Verticals.Context; -using Infrastructure.Verticals.DownloadClient; -using NSubstitute; -using NSubstitute.ClearExtensions; -using Shouldly; - -namespace Infrastructure.Tests.Verticals.DownloadClient; - -public class DownloadServiceTests : IClassFixture -{ - private readonly DownloadServiceFixture _fixture; - - public DownloadServiceTests(DownloadServiceFixture fixture) - { - _fixture = fixture; - _fixture.Cache.ClearSubstitute(); - _fixture.Striker.ClearSubstitute(); - } - - public class ResetStrikesOnProgressTests : DownloadServiceTests - { - public ResetStrikesOnProgressTests(DownloadServiceFixture fixture) : base(fixture) - { - } - - [Fact] - public void WhenStalledStrikeDisabled_ShouldNotResetStrikes() - { - // Arrange - TestDownloadService sut = _fixture.CreateSut(queueCleanerConfig: new() - { - Enabled = true, - RunSequentially = true, - StalledResetStrikesOnProgress = false, - }); - - // Act - sut.ResetStalledStrikesOnProgress("test-hash", 100); - - // Assert - _fixture.Cache.ReceivedCalls().ShouldBeEmpty(); - } - - [Fact] - public void WhenProgressMade_ShouldResetStrikes() - { - // Arrange - const string hash = "test-hash"; - StalledCacheItem stalledCacheItem = new StalledCacheItem { Downloaded = 100 }; - - _fixture.Cache.TryGetValue(Arg.Any(), out Arg.Any()) - .Returns(x => - { - x[1] = stalledCacheItem; - return true; - }); - - TestDownloadService sut = _fixture.CreateSut(); - - // Act - sut.ResetStalledStrikesOnProgress(hash, 200); - - // Assert - _fixture.Cache.Received(1).Remove(CacheKeys.Strike(StrikeType.Stalled, hash)); - } - - [Fact] - public void WhenNoProgress_ShouldNotResetStrikes() - { - // Arrange - const string hash = "test-hash"; - StalledCacheItem stalledCacheItem = new StalledCacheItem { Downloaded = 200 }; - - _fixture.Cache - .TryGetValue(Arg.Any(), out Arg.Any()) - .Returns(x => - { - x[1] = stalledCacheItem; - return true; - }); - - TestDownloadService sut = _fixture.CreateSut(); - - // Act - sut.ResetStalledStrikesOnProgress(hash, 100); - - // Assert - _fixture.Cache.DidNotReceive().Remove(Arg.Any()); - } - } - - public class StrikeAndCheckLimitTests : DownloadServiceTests - { - public StrikeAndCheckLimitTests(DownloadServiceFixture fixture) : base(fixture) - { - } - } - - public class ShouldCleanDownloadTests : DownloadServiceTests - { - public ShouldCleanDownloadTests(DownloadServiceFixture fixture) : base(fixture) - { - ContextProvider.Set("downloadName", "test-download"); - } - - [Fact] - public void WhenRatioAndMinSeedTimeReached_ShouldReturnTrue() - { - // Arrange - CleanCategory category = new() - { - Name = "test", - MaxRatio = 1.0, - MinSeedTime = 1, - MaxSeedTime = -1 - }; - const double ratio = 1.5; - TimeSpan seedingTime = TimeSpan.FromHours(2); - - TestDownloadService sut = _fixture.CreateSut(); - - // Act - var result = sut.ShouldCleanDownload(ratio, seedingTime, category); - - // Assert - result.ShouldSatisfyAllConditions( - () => result.ShouldClean.ShouldBeTrue(), - () => result.Reason.ShouldBe(CleanReason.MaxRatioReached) - ); - } - - [Fact] - public void WhenRatioReachedAndMinSeedTimeNotReached_ShouldReturnFalse() - { - // Arrange - CleanCategory category = new() - { - Name = "test", - MaxRatio = 1.0, - MinSeedTime = 3, - MaxSeedTime = -1 - }; - const double ratio = 1.5; - TimeSpan seedingTime = TimeSpan.FromHours(2); - - TestDownloadService sut = _fixture.CreateSut(); - - // Act - var result = sut.ShouldCleanDownload(ratio, seedingTime, category); - - // Assert - result.ShouldSatisfyAllConditions( - () => result.ShouldClean.ShouldBeFalse(), - () => result.Reason.ShouldBe(CleanReason.None) - ); - } - - [Fact] - public void WhenMaxSeedTimeReached_ShouldReturnTrue() - { - // Arrange - CleanCategory category = new() - { - Name = "test", - MaxRatio = -1, - MinSeedTime = 0, - MaxSeedTime = 1 - }; - const double ratio = 0.5; - TimeSpan seedingTime = TimeSpan.FromHours(2); - - TestDownloadService sut = _fixture.CreateSut(); - - // Act - SeedingCheckResult result = sut.ShouldCleanDownload(ratio, seedingTime, category); - - // Assert - result.ShouldSatisfyAllConditions( - () => result.ShouldClean.ShouldBeTrue(), - () => result.Reason.ShouldBe(CleanReason.MaxSeedTimeReached) - ); - } - - [Fact] - public void WhenNeitherConditionMet_ShouldReturnFalse() - { - // Arrange - CleanCategory category = new() - { - Name = "test", - MaxRatio = 2.0, - MinSeedTime = 0, - MaxSeedTime = 3 - }; - const double ratio = 1.0; - TimeSpan seedingTime = TimeSpan.FromHours(1); - - TestDownloadService sut = _fixture.CreateSut(); - - // Act - var result = sut.ShouldCleanDownload(ratio, seedingTime, category); - - // Assert - result.ShouldSatisfyAllConditions( - () => result.ShouldClean.ShouldBeFalse(), - () => result.Reason.ShouldBe(CleanReason.None) - ); - } - } -} \ No newline at end of file diff --git a/code/Infrastructure.Tests/Verticals/DownloadClient/TestDownloadService.cs b/code/Infrastructure.Tests/Verticals/DownloadClient/TestDownloadService.cs deleted file mode 100644 index 5869ba14..00000000 --- a/code/Infrastructure.Tests/Verticals/DownloadClient/TestDownloadService.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.RegularExpressions; -using Common.Configuration.ContentBlocker; -using Common.Configuration.DownloadCleaner; -using Common.Configuration.QueueCleaner; -using Domain.Enums; -using Infrastructure.Interceptors; -using Infrastructure.Verticals.ContentBlocker; -using Infrastructure.Verticals.DownloadClient; -using Infrastructure.Verticals.Files; -using Infrastructure.Verticals.ItemStriker; -using Infrastructure.Verticals.Notifications; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Infrastructure.Tests.Verticals.DownloadClient; - -public class TestDownloadService : DownloadService -{ - public TestDownloadService( - ILogger logger, - IOptions queueCleanerConfig, - IOptions contentBlockerConfig, - IOptions downloadCleanerConfig, - IMemoryCache cache, - IFilenameEvaluator filenameEvaluator, - IStriker striker, - INotificationPublisher notifier, - IDryRunInterceptor dryRunInterceptor, - IHardLinkFileService hardLinkFileService - ) : base( - logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache, - filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService - ) - { - } - - public override void Dispose() { } - public override Task LoginAsync() => Task.CompletedTask; - public override Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads) => Task.FromResult(new DownloadCheckResult()); - public override Task BlockUnwantedFilesAsync(string hash, BlocklistType blocklistType, - ConcurrentBag patterns, ConcurrentBag regexes, IReadOnlyList ignoredDownloads) => Task.FromResult(new BlockFilesResult()); - public override Task DeleteDownload(string hash) => Task.CompletedTask; - public override Task CreateCategoryAsync(string name) => Task.CompletedTask; - public override Task?> GetSeedingDownloads() => Task.FromResult?>(null); - public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories) => null; - public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories) => null; - public override Task CleanDownloadsAsync(List? downloads, List categoriesToClean, HashSet excludedHashes, IReadOnlyList ignoredDownloads) => Task.CompletedTask; - public override Task ChangeCategoryForNoHardLinksAsync(List? downloads, HashSet excludedHashes, IReadOnlyList ignoredDownloads) => Task.CompletedTask; - // Expose protected methods for testing - public new void ResetStalledStrikesOnProgress(string hash, long downloaded) => base.ResetStalledStrikesOnProgress(hash, downloaded); - public new SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, CleanCategory category) => base.ShouldCleanDownload(ratio, seedingTime, category); -} \ No newline at end of file diff --git a/code/Infrastructure/Infrastructure.csproj b/code/Infrastructure/Infrastructure.csproj deleted file mode 100644 index 0811a862..00000000 --- a/code/Infrastructure/Infrastructure.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - net9.0 - enable - enable - - - - - - - - - - - - - - - - - - - - diff --git a/code/Infrastructure/Providers/IgnoredDownloadsProvider.cs b/code/Infrastructure/Providers/IgnoredDownloadsProvider.cs deleted file mode 100644 index fe093b12..00000000 --- a/code/Infrastructure/Providers/IgnoredDownloadsProvider.cs +++ /dev/null @@ -1,82 +0,0 @@ -๏ปฟusing Common.Configuration; -using Infrastructure.Helpers; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Infrastructure.Providers; - -public sealed class IgnoredDownloadsProvider - where T : IIgnoredDownloadsConfig -{ - private readonly ILogger> _logger; - private IIgnoredDownloadsConfig _config; - private readonly IMemoryCache _cache; - private DateTime _lastModified = DateTime.MinValue; - - public IgnoredDownloadsProvider(ILogger> logger, IOptionsMonitor config, IMemoryCache cache) - { - _config = config.CurrentValue; - config.OnChange((newValue) => _config = newValue); - _logger = logger; - _cache = cache; - - if (string.IsNullOrEmpty(_config.IgnoredDownloadsPath)) - { - return; - } - - if (!File.Exists(_config.IgnoredDownloadsPath)) - { - throw new FileNotFoundException("file not found", _config.IgnoredDownloadsPath); - } - } - - public async Task> GetIgnoredDownloads() - { - if (string.IsNullOrEmpty(_config.IgnoredDownloadsPath)) - { - return Array.Empty(); - } - - FileInfo fileInfo = new(_config.IgnoredDownloadsPath); - - if (fileInfo.LastWriteTime > _lastModified || - !_cache.TryGetValue(CacheKeys.IgnoredDownloads(typeof(T).Name), out IReadOnlyList? ignoredDownloads) || - ignoredDownloads is null) - { - _lastModified = fileInfo.LastWriteTime; - - return await LoadFile(); - } - - return ignoredDownloads; - } - - private async Task> LoadFile() - { - try - { - if (string.IsNullOrEmpty(_config.IgnoredDownloadsPath)) - { - return Array.Empty(); - } - - string[] ignoredDownloads = (await File.ReadAllLinesAsync(_config.IgnoredDownloadsPath)) - .Where(x => !string.IsNullOrWhiteSpace(x)) - .ToArray(); - - _cache.Set(CacheKeys.IgnoredDownloads(typeof(T).Name), ignoredDownloads); - - _logger.LogInformation("ignored downloads reloaded"); - - return ignoredDownloads; - } - catch (Exception exception) - { - _logger.LogError(exception, "error while reading ignored downloads file | {file}", _config.IgnoredDownloadsPath); - } - - return Array.Empty(); - } -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Arr/Interfaces/IArrClient.cs b/code/Infrastructure/Verticals/Arr/Interfaces/IArrClient.cs deleted file mode 100644 index e2cd654d..00000000 --- a/code/Infrastructure/Verticals/Arr/Interfaces/IArrClient.cs +++ /dev/null @@ -1,19 +0,0 @@ -๏ปฟusing Common.Configuration.Arr; -using Domain.Enums; -using Domain.Models.Arr; -using Domain.Models.Arr.Queue; - -namespace Infrastructure.Verticals.Arr.Interfaces; - -public interface IArrClient -{ - Task GetQueueItemsAsync(ArrInstance arrInstance, int page); - - Task ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes); - - Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason); - - Task SearchItemsAsync(ArrInstance arrInstance, HashSet? items); - - bool IsRecordValid(QueueRecord record); -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Arr/Interfaces/ILidarrClient.cs b/code/Infrastructure/Verticals/Arr/Interfaces/ILidarrClient.cs deleted file mode 100644 index 9a5cb3b0..00000000 --- a/code/Infrastructure/Verticals/Arr/Interfaces/ILidarrClient.cs +++ /dev/null @@ -1,5 +0,0 @@ -๏ปฟnamespace Infrastructure.Verticals.Arr.Interfaces; - -public interface ILidarrClient : IArrClient -{ -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Arr/Interfaces/IRadarrClient.cs b/code/Infrastructure/Verticals/Arr/Interfaces/IRadarrClient.cs deleted file mode 100644 index 71b0cff0..00000000 --- a/code/Infrastructure/Verticals/Arr/Interfaces/IRadarrClient.cs +++ /dev/null @@ -1,5 +0,0 @@ -๏ปฟnamespace Infrastructure.Verticals.Arr.Interfaces; - -public interface IRadarrClient : IArrClient -{ -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Arr/Interfaces/ISonarrClient.cs b/code/Infrastructure/Verticals/Arr/Interfaces/ISonarrClient.cs deleted file mode 100644 index 7863f7f7..00000000 --- a/code/Infrastructure/Verticals/Arr/Interfaces/ISonarrClient.cs +++ /dev/null @@ -1,5 +0,0 @@ -๏ปฟnamespace Infrastructure.Verticals.Arr.Interfaces; - -public interface ISonarrClient : IArrClient -{ -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/ContentBlocker/BlocklistProvider.cs b/code/Infrastructure/Verticals/ContentBlocker/BlocklistProvider.cs deleted file mode 100644 index 31e88ff4..00000000 --- a/code/Infrastructure/Verticals/ContentBlocker/BlocklistProvider.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Text.RegularExpressions; -using Common.Configuration.Arr; -using Common.Configuration.ContentBlocker; -using Common.Helpers; -using Domain.Enums; -using Infrastructure.Helpers; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Infrastructure.Verticals.ContentBlocker; - -public sealed class BlocklistProvider -{ - private readonly ILogger _logger; - private readonly SonarrConfig _sonarrConfig; - private readonly RadarrConfig _radarrConfig; - private readonly LidarrConfig _lidarrConfig; - private readonly HttpClient _httpClient; - private readonly IMemoryCache _cache; - private bool _initialized; - - public BlocklistProvider( - ILogger logger, - IOptions sonarrConfig, - IOptions radarrConfig, - IOptions lidarrConfig, - IMemoryCache cache, - IHttpClientFactory httpClientFactory - ) - { - _logger = logger; - _sonarrConfig = sonarrConfig.Value; - _radarrConfig = radarrConfig.Value; - _lidarrConfig = lidarrConfig.Value; - _cache = cache; - _httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName); - } - - public async Task LoadBlocklistsAsync() - { - if (_initialized) - { - _logger.LogTrace("blocklists already loaded"); - return; - } - - try - { - await LoadPatternsAndRegexesAsync(_sonarrConfig, InstanceType.Sonarr); - await LoadPatternsAndRegexesAsync(_radarrConfig, InstanceType.Radarr); - await LoadPatternsAndRegexesAsync(_lidarrConfig, InstanceType.Lidarr); - - _initialized = true; - } - catch - { - _logger.LogError("failed to load blocklists"); - throw; - } - } - - public BlocklistType GetBlocklistType(InstanceType instanceType) - { - _cache.TryGetValue(CacheKeys.BlocklistType(instanceType), out BlocklistType? blocklistType); - - return blocklistType ?? BlocklistType.Blacklist; - } - - public ConcurrentBag GetPatterns(InstanceType instanceType) - { - _cache.TryGetValue(CacheKeys.BlocklistPatterns(instanceType), out ConcurrentBag? patterns); - - return patterns ?? []; - } - - public ConcurrentBag GetRegexes(InstanceType instanceType) - { - _cache.TryGetValue(CacheKeys.BlocklistRegexes(instanceType), out ConcurrentBag? regexes); - - return regexes ?? []; - } - - private async Task LoadPatternsAndRegexesAsync(ArrConfig arrConfig, InstanceType instanceType) - { - if (!arrConfig.Enabled) - { - return; - } - - if (string.IsNullOrEmpty(arrConfig.Block.Path)) - { - return; - } - - string[] filePatterns = await ReadContentAsync(arrConfig.Block.Path); - - long startTime = Stopwatch.GetTimestamp(); - ParallelOptions options = new() { MaxDegreeOfParallelism = 5 }; - const string regexId = "regex:"; - ConcurrentBag patterns = []; - ConcurrentBag regexes = []; - - Parallel.ForEach(filePatterns, options, pattern => - { - if (!pattern.StartsWith(regexId)) - { - patterns.Add(pattern); - return; - } - - pattern = pattern[regexId.Length..]; - - try - { - Regex regex = new(pattern, RegexOptions.Compiled); - regexes.Add(regex); - } - catch (ArgumentException) - { - _logger.LogWarning("invalid regex | {pattern}", pattern); - } - }); - - TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime); - - _cache.Set(CacheKeys.BlocklistType(instanceType), arrConfig.Block.Type); - _cache.Set(CacheKeys.BlocklistPatterns(instanceType), patterns); - _cache.Set(CacheKeys.BlocklistRegexes(instanceType), regexes); - - _logger.LogDebug("loaded {count} patterns", patterns.Count); - _logger.LogDebug("loaded {count} regexes", regexes.Count); - _logger.LogDebug("blocklist loaded in {elapsed} ms | {path}", elapsed.TotalMilliseconds, arrConfig.Block.Path); - } - - private async Task ReadContentAsync(string path) - { - if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) - { - // http(s) url - return await ReadFromUrlAsync(path); - } - - if (File.Exists(path)) - { - // local file path - return await File.ReadAllLinesAsync(path); - } - - throw new ArgumentException($"blocklist not found | {path}"); - } - - private async Task ReadFromUrlAsync(string url) - { - using HttpResponseMessage response = await _httpClient.GetAsync(url); - response.EnsureSuccessStatusCode(); - - return (await response.Content.ReadAsStringAsync()) - .Split(['\r','\n'], StringSplitOptions.RemoveEmptyEntries); - } -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/ContentBlocker/ContentBlocker.cs b/code/Infrastructure/Verticals/ContentBlocker/ContentBlocker.cs deleted file mode 100644 index 56ce043e..00000000 --- a/code/Infrastructure/Verticals/ContentBlocker/ContentBlocker.cs +++ /dev/null @@ -1,158 +0,0 @@ -๏ปฟusing System.Collections.Concurrent; -using System.Text.RegularExpressions; -using Common.Configuration.Arr; -using Common.Configuration.ContentBlocker; -using Common.Configuration.DownloadClient; -using Domain.Enums; -using Domain.Models.Arr; -using Domain.Models.Arr.Queue; -using Infrastructure.Helpers; -using Infrastructure.Providers; -using Infrastructure.Verticals.Arr; -using Infrastructure.Verticals.Arr.Interfaces; -using Infrastructure.Verticals.Context; -using Infrastructure.Verticals.DownloadClient; -using Infrastructure.Verticals.DownloadRemover.Models; -using Infrastructure.Verticals.Jobs; -using Infrastructure.Verticals.Notifications; -using MassTransit; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using LogContext = Serilog.Context.LogContext; - -namespace Infrastructure.Verticals.ContentBlocker; - -public sealed class ContentBlocker : GenericHandler -{ - private readonly ContentBlockerConfig _config; - private readonly BlocklistProvider _blocklistProvider; - private readonly IgnoredDownloadsProvider _ignoredDownloadsProvider; - - public ContentBlocker( - ILogger logger, - IOptions config, - IOptions downloadClientConfig, - IOptions sonarrConfig, - IOptions radarrConfig, - IOptions lidarrConfig, - IMemoryCache cache, - IBus messageBus, - ArrClientFactory arrClientFactory, - ArrQueueIterator arrArrQueueIterator, - BlocklistProvider blocklistProvider, - DownloadServiceFactory downloadServiceFactory, - INotificationPublisher notifier, - IgnoredDownloadsProvider ignoredDownloadsProvider - ) : base( - logger, downloadClientConfig, - sonarrConfig, radarrConfig, lidarrConfig, - cache, messageBus, arrClientFactory, arrArrQueueIterator, downloadServiceFactory, - notifier - ) - { - _config = config.Value; - _blocklistProvider = blocklistProvider; - _ignoredDownloadsProvider = ignoredDownloadsProvider; - } - - public override async Task ExecuteAsync() - { - if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None or Common.Enums.DownloadClient.Disabled) - { - _logger.LogWarning("download client is not set"); - return; - } - - bool blocklistIsConfigured = _sonarrConfig.Enabled && !string.IsNullOrEmpty(_sonarrConfig.Block.Path) || - _radarrConfig.Enabled && !string.IsNullOrEmpty(_radarrConfig.Block.Path) || - _lidarrConfig.Enabled && !string.IsNullOrEmpty(_lidarrConfig.Block.Path); - - if (!blocklistIsConfigured) - { - _logger.LogWarning("no blocklist is configured"); - return; - } - - await _blocklistProvider.LoadBlocklistsAsync(); - await base.ExecuteAsync(); - } - - protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config) - { - IReadOnlyList ignoredDownloads = await _ignoredDownloadsProvider.GetIgnoredDownloads(); - - using var _ = LogContext.PushProperty("InstanceName", instanceType.ToString()); - - IArrClient arrClient = _arrClientFactory.GetClient(instanceType); - BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType); - ConcurrentBag patterns = _blocklistProvider.GetPatterns(instanceType); - ConcurrentBag regexes = _blocklistProvider.GetRegexes(instanceType); - - await _arrArrQueueIterator.Iterate(arrClient, instance, async items => - { - var groups = items - .GroupBy(x => x.DownloadId) - .ToList(); - - foreach (var group in groups) - { - QueueRecord record = group.First(); - - if (record.Protocol is not "torrent") - { - continue; - } - - if (string.IsNullOrEmpty(record.DownloadId)) - { - _logger.LogDebug("skip | download id is null for {title}", record.Title); - continue; - } - - if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase)) - { - _logger.LogInformation("skip | {title} | ignored", record.Title); - continue; - } - - string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url); - - if (_cache.TryGetValue(downloadRemovalKey, out bool _)) - { - _logger.LogDebug("skip | already marked for removal | {title}", record.Title); - continue; - } - - _logger.LogDebug("searching unwanted files for {title}", record.Title); - - BlockFilesResult result = await _downloadService - .BlockUnwantedFilesAsync(record.DownloadId, blocklistType, patterns, regexes, ignoredDownloads); - - if (!result.ShouldRemove) - { - continue; - } - - _logger.LogDebug("all files are marked as unwanted | {hash}", record.Title); - - bool removeFromClient = true; - - if (result.IsPrivate && !_config.DeletePrivate) - { - removeFromClient = false; - } - - await PublishQueueItemRemoveRequest( - downloadRemovalKey, - instanceType, - instance, - record, - group.Count() > 1, - removeFromClient, - DeleteReason.AllFilesBlocked - ); - } - }); - } -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadCleaner/DownloadCleaner.cs b/code/Infrastructure/Verticals/DownloadCleaner/DownloadCleaner.cs deleted file mode 100644 index c06a5e3e..00000000 --- a/code/Infrastructure/Verticals/DownloadCleaner/DownloadCleaner.cs +++ /dev/null @@ -1,155 +0,0 @@ -using Common.Configuration.Arr; -using Common.Configuration.DownloadCleaner; -using Common.Configuration.DownloadClient; -using Domain.Enums; -using Domain.Models.Arr.Queue; -using Infrastructure.Providers; -using Infrastructure.Verticals.Arr; -using Infrastructure.Verticals.Arr.Interfaces; -using Infrastructure.Verticals.DownloadClient; -using Infrastructure.Verticals.Jobs; -using Infrastructure.Verticals.Notifications; -using MassTransit; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using LogContext = Serilog.Context.LogContext; - -namespace Infrastructure.Verticals.DownloadCleaner; - -public sealed class DownloadCleaner : GenericHandler -{ - private readonly DownloadCleanerConfig _config; - private readonly IgnoredDownloadsProvider _ignoredDownloadsProvider; - private readonly HashSet _excludedHashes = []; - - private static bool _hardLinkCategoryCreated; - - public DownloadCleaner( - ILogger logger, - IOptions config, - IOptions downloadClientConfig, - IOptions sonarrConfig, - IOptions radarrConfig, - IOptions lidarrConfig, - IMemoryCache cache, - IBus messageBus, - ArrClientFactory arrClientFactory, - ArrQueueIterator arrArrQueueIterator, - DownloadServiceFactory downloadServiceFactory, - INotificationPublisher notifier, - IgnoredDownloadsProvider ignoredDownloadsProvider - ) : base( - logger, downloadClientConfig, - sonarrConfig, radarrConfig, lidarrConfig, - cache, messageBus, arrClientFactory, arrArrQueueIterator, downloadServiceFactory, - notifier - ) - { - _config = config.Value; - _config.Validate(); - _ignoredDownloadsProvider = ignoredDownloadsProvider; - } - - public override async Task ExecuteAsync() - { - if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None or Common.Enums.DownloadClient.Disabled) - { - _logger.LogWarning("download client is not set"); - return; - } - - bool isUnlinkedEnabled = !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)); - return; - } - - IReadOnlyList ignoredDownloads = await _ignoredDownloadsProvider.GetIgnoredDownloads(); - - await _downloadService.LoginAsync(); - List? downloads = await _downloadService.GetSeedingDownloads(); - - if (downloads?.Count is null or 0) - { - _logger.LogDebug("no seeding downloads found"); - return; - } - - _logger.LogTrace("found {count} seeding downloads", downloads.Count); - - List? downloadsToChangeCategory = null; - - if (isUnlinkedEnabled) - { - if (!_hardLinkCategoryCreated) - { - if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.QBittorrent && !_config.UnlinkedUseTag) - { - _logger.LogDebug("creating category {cat}", _config.UnlinkedTargetCategory); - await _downloadService.CreateCategoryAsync(_config.UnlinkedTargetCategory); - } - - _hardLinkCategoryCreated = true; - } - - downloadsToChangeCategory = _downloadService.FilterDownloadsToChangeCategoryAsync(downloads, _config.UnlinkedCategories); - } - - // wait for the downloads to appear in the arr queue - await Task.Delay(10 * 1000); - - await ProcessArrConfigAsync(_sonarrConfig, InstanceType.Sonarr, true); - await ProcessArrConfigAsync(_radarrConfig, InstanceType.Radarr, true); - await ProcessArrConfigAsync(_lidarrConfig, InstanceType.Lidarr, true); - - if (isUnlinkedEnabled) - { - _logger.LogTrace("found {count} potential downloads to change category", downloadsToChangeCategory?.Count); - await _downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory, _excludedHashes, ignoredDownloads); - _logger.LogTrace("finished changing category"); - } - - if (_config.Categories?.Count is null or 0) - { - return; - } - - List? downloadsToClean = _downloadService.FilterDownloadsToBeCleanedAsync(downloads, _config.Categories); - - // release unused objects - downloads = null; - - _logger.LogTrace("found {count} potential downloads to clean", downloadsToClean?.Count); - await _downloadService.CleanDownloadsAsync(downloadsToClean, _config.Categories, _excludedHashes, ignoredDownloads); - _logger.LogTrace("finished cleaning downloads"); - } - - protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config) - { - using var _ = LogContext.PushProperty("InstanceName", instanceType.ToString()); - - IArrClient arrClient = _arrClientFactory.GetClient(instanceType); - - await _arrArrQueueIterator.Iterate(arrClient, instance, async items => - { - var groups = items - .Where(x => !string.IsNullOrEmpty(x.DownloadId)) - .GroupBy(x => x.DownloadId) - .ToList(); - - foreach (QueueRecord record in groups.Select(group => group.First())) - { - _excludedHashes.Add(record.DownloadId.ToLowerInvariant()); - } - }); - } - - public override void Dispose() - { - _downloadService.Dispose(); - } -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadClient/Deluge/DelugeService.cs b/code/Infrastructure/Verticals/DownloadClient/Deluge/DelugeService.cs deleted file mode 100644 index 7bf30636..00000000 --- a/code/Infrastructure/Verticals/DownloadClient/Deluge/DelugeService.cs +++ /dev/null @@ -1,548 +0,0 @@ -using System.Collections.Concurrent; -using System.Globalization; -using System.Text.RegularExpressions; -using Common.Attributes; -using Common.Configuration.ContentBlocker; -using Common.Configuration.DownloadCleaner; -using Common.Configuration.DownloadClient; -using Common.Configuration.QueueCleaner; -using Common.CustomDataTypes; -using Common.Exceptions; -using Domain.Enums; -using Domain.Models.Deluge.Response; -using Infrastructure.Extensions; -using Infrastructure.Interceptors; -using Infrastructure.Verticals.ContentBlocker; -using Infrastructure.Verticals.Context; -using Infrastructure.Verticals.Files; -using Infrastructure.Verticals.ItemStriker; -using Infrastructure.Verticals.Notifications; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Infrastructure.Verticals.DownloadClient.Deluge; - -public class DelugeService : DownloadService, IDelugeService -{ - private readonly DelugeClient _client; - - public DelugeService( - ILogger logger, - IOptions config, - IHttpClientFactory httpClientFactory, - IOptions queueCleanerConfig, - IOptions contentBlockerConfig, - IOptions downloadCleanerConfig, - IMemoryCache cache, - IFilenameEvaluator filenameEvaluator, - IStriker striker, - INotificationPublisher notifier, - IDryRunInterceptor dryRunInterceptor, - IHardLinkFileService hardLinkFileService - ) : base( - logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache, - filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService - ) - { - config.Value.Validate(); - _client = new (config, httpClientFactory); - } - - public override async Task LoginAsync() - { - await _client.LoginAsync(); - - if (!await _client.IsConnected() && !await _client.Connect()) - { - throw new FatalException("Deluge WebUI is not connected to the daemon"); - } - } - - /// - public override async Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads) - { - hash = hash.ToLowerInvariant(); - - DelugeContents? contents = null; - DownloadCheckResult result = new(); - - DownloadStatus? download = await _client.GetTorrentStatus(hash); - - if (download?.Hash is null) - { - _logger.LogDebug("failed to find torrent {hash} in the download client", hash); - return result; - } - - result.IsPrivate = download.Private; - - if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads)) - { - _logger.LogInformation("skip | download is ignored | {name}", download.Name); - return result; - } - - try - { - contents = await _client.GetTorrentFiles(hash); - } - catch (Exception exception) - { - _logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash); - } - - - bool shouldRemove = contents?.Contents?.Count > 0; - - ProcessFiles(contents.Contents, (_, file) => - { - if (file.Priority > 0) - { - shouldRemove = false; - } - }); - - if (shouldRemove) - { - // remove if all files are unwanted - result.ShouldRemove = true; - result.DeleteReason = DeleteReason.AllFilesSkipped; - return result; - } - - // remove if download is stuck - (result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download); - - return result; - } - - /// - public override async Task BlockUnwantedFilesAsync(string hash, - BlocklistType blocklistType, - ConcurrentBag patterns, - ConcurrentBag regexes, IReadOnlyList ignoredDownloads) - { - hash = hash.ToLowerInvariant(); - - DownloadStatus? download = await _client.GetTorrentStatus(hash); - BlockFilesResult result = new(); - - if (download?.Hash is null) - { - _logger.LogDebug("failed to find torrent {hash} in the download client", hash); - return result; - } - - if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads)) - { - _logger.LogInformation("skip | download is ignored | {name}", download.Name); - return result; - } - - result.IsPrivate = download.Private; - - if (_contentBlockerConfig.IgnorePrivate && download.Private) - { - // ignore private trackers - _logger.LogDebug("skip files check | download is private | {name}", download.Name); - return result; - } - - DelugeContents? contents = null; - - try - { - contents = await _client.GetTorrentFiles(hash); - } - catch (Exception exception) - { - _logger.LogDebug(exception, "failed to find torrent {hash} in the download client", hash); - } - - if (contents is null) - { - return result; - } - - Dictionary priorities = []; - bool hasPriorityUpdates = false; - long totalFiles = 0; - long totalUnwantedFiles = 0; - - ProcessFiles(contents.Contents, (name, file) => - { - totalFiles++; - int priority = file.Priority; - - if (file.Priority is 0) - { - totalUnwantedFiles++; - } - - if (file.Priority is not 0 && !_filenameEvaluator.IsValid(name, blocklistType, patterns, regexes)) - { - totalUnwantedFiles++; - priority = 0; - hasPriorityUpdates = true; - _logger.LogInformation("unwanted file found | {file}", file.Path); - } - - priorities.Add(file.Index, priority); - }); - - if (!hasPriorityUpdates) - { - return result; - } - - _logger.LogDebug("changing priorities | torrent {hash}", hash); - - List sortedPriorities = priorities - .OrderBy(x => x.Key) - .Select(x => x.Value) - .ToList(); - - if (totalUnwantedFiles == totalFiles) - { - // Skip marking files as unwanted. The download will be removed completely. - result.ShouldRemove = true; - - return result; - } - - await _dryRunInterceptor.InterceptAsync(ChangeFilesPriority, hash, sortedPriorities); - - return result; - } - - public override async Task?> GetSeedingDownloads() - { - return (await _client.GetStatusForAllTorrents()) - ?.Where(x => !string.IsNullOrEmpty(x.Hash)) - .Where(x => x.State?.Equals("seeding", StringComparison.InvariantCultureIgnoreCase) is true) - .Cast() - .ToList(); - } - - public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories) => - downloads - ?.Cast() - .Where(x => categories.Any(cat => cat.Name.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase))) - .Cast() - .ToList(); - - public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories) => - downloads - ?.Cast() - .Where(x => !string.IsNullOrEmpty(x.Hash)) - .Where(x => categories.Any(cat => cat.Equals(x.Label, StringComparison.InvariantCultureIgnoreCase))) - .Cast() - .ToList(); - - /// - public override async Task CleanDownloadsAsync(List? downloads, List categoriesToClean, HashSet excludedHashes, - IReadOnlyList ignoredDownloads) - { - if (downloads?.Count is null or 0) - { - return; - } - - foreach (DownloadStatus download in downloads) - { - if (string.IsNullOrEmpty(download.Hash)) - { - continue; - } - - if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase))) - { - _logger.LogDebug("skip | download is used by an arr | {name}", download.Name); - continue; - } - - if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads)) - { - _logger.LogInformation("skip | download is ignored | {name}", download.Name); - continue; - } - - CleanCategory? category = categoriesToClean - .FirstOrDefault(x => x.Name.Equals(download.Label, StringComparison.InvariantCultureIgnoreCase)); - - if (category is null) - { - continue; - } - - if (!_downloadCleanerConfig.DeletePrivate && download.Private) - { - _logger.LogDebug("skip | download is private | {name}", download.Name); - continue; - } - - ContextProvider.Set("downloadName", download.Name); - ContextProvider.Set("hash", download.Hash); - - TimeSpan seedingTime = TimeSpan.FromSeconds(download.SeedingTime); - SeedingCheckResult result = ShouldCleanDownload(download.Ratio, seedingTime, category); - - if (!result.ShouldClean) - { - continue; - } - - await _dryRunInterceptor.InterceptAsync(DeleteDownload, download.Hash); - - _logger.LogInformation( - "download cleaned | {reason} reached | {name}", - result.Reason is CleanReason.MaxRatioReached - ? "MAX_RATIO & MIN_SEED_TIME" - : "MAX_SEED_TIME", - download.Name - ); - - await _notifier.NotifyDownloadCleaned(download.Ratio, seedingTime, category.Name, result.Reason); - } - } - - public override async Task CreateCategoryAsync(string name) - { - IReadOnlyList existingLabels = await _client.GetLabels(); - - if (existingLabels.Contains(name, StringComparer.InvariantCultureIgnoreCase)) - { - return; - } - - await _dryRunInterceptor.InterceptAsync(CreateLabel, name); - } - - public override async Task ChangeCategoryForNoHardLinksAsync(List? downloads, HashSet excludedHashes, IReadOnlyList ignoredDownloads) - { - if (downloads?.Count is null or 0) - { - return; - } - - if (!string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir)) - { - _hardLinkFileService.PopulateFileCounts(_downloadCleanerConfig.UnlinkedIgnoredRootDir); - } - - foreach (DownloadStatus download in downloads.Cast()) - { - if (string.IsNullOrEmpty(download.Hash) || string.IsNullOrEmpty(download.Name) || string.IsNullOrEmpty(download.Label)) - { - continue; - } - - if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase))) - { - _logger.LogDebug("skip | download is used by an arr | {name}", download.Name); - continue; - } - - if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads)) - { - _logger.LogInformation("skip | download is ignored | {name}", download.Name); - continue; - } - - ContextProvider.Set("downloadName", download.Name); - ContextProvider.Set("hash", download.Hash); - - DelugeContents? contents = null; - try - { - contents = await _client.GetTorrentFiles(download.Hash); - } - catch (Exception exception) - { - _logger.LogDebug(exception, "failed to find torrent files for {name}", download.Name); - continue; - } - - bool hasHardlinks = false; - - ProcessFiles(contents?.Contents, (_, file) => - { - string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.DownloadLocation, file.Path).Split(['\\', '/'])); - - if (file.Priority <= 0) - { - _logger.LogDebug("skip | file is not downloaded | {file}", filePath); - return; - } - - long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir)); - - if (hardlinkCount < 0) - { - _logger.LogDebug("skip | could not get file properties | {file}", filePath); - hasHardlinks = true; - return; - } - - if (hardlinkCount > 0) - { - hasHardlinks = true; - } - }); - - if (hasHardlinks) - { - _logger.LogDebug("skip | download has hardlinks | {name}", download.Name); - continue; - } - - await _dryRunInterceptor.InterceptAsync(ChangeLabel, download.Hash, _downloadCleanerConfig.UnlinkedTargetCategory); - - _logger.LogInformation("category changed for {name}", download.Name); - - await _notifier.NotifyCategoryChanged(download.Label, _downloadCleanerConfig.UnlinkedTargetCategory); - - download.Label = _downloadCleanerConfig.UnlinkedTargetCategory; - } - } - - /// - [DryRunSafeguard] - public override async Task DeleteDownload(string hash) - { - hash = hash.ToLowerInvariant(); - - await _client.DeleteTorrents([hash]); - } - - [DryRunSafeguard] - protected async Task CreateLabel(string name) - { - await _client.CreateLabel(name); - } - - [DryRunSafeguard] - protected virtual async Task ChangeFilesPriority(string hash, List sortedPriorities) - { - await _client.ChangeFilesPriority(hash, sortedPriorities); - } - - [DryRunSafeguard] - protected virtual async Task ChangeLabel(string hash, string newLabel) - { - await _client.SetTorrentLabel(hash, newLabel); - } - - private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(DownloadStatus status) - { - (bool ShouldRemove, DeleteReason Reason) result = await CheckIfSlow(status); - - if (result.ShouldRemove) - { - return result; - } - - return await CheckIfStuck(status); - } - - private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(DownloadStatus download) - { - if (_queueCleanerConfig.SlowMaxStrikes is 0) - { - return (false, DeleteReason.None); - } - - if (download.State is null || !download.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase)) - { - return (false, DeleteReason.None); - } - - if (download.DownloadSpeed <= 0) - { - return (false, DeleteReason.None); - } - - if (_queueCleanerConfig.SlowIgnorePrivate && download.Private) - { - // ignore private trackers - _logger.LogDebug("skip slow check | download is private | {name}", download.Name); - return (false, DeleteReason.None); - } - - if (download.Size > (_queueCleanerConfig.SlowIgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue)) - { - _logger.LogDebug("skip slow check | download is too large | {name}", download.Name); - return (false, DeleteReason.None); - } - - ByteSize minSpeed = _queueCleanerConfig.SlowMinSpeedByteSize; - ByteSize currentSpeed = new ByteSize(download.DownloadSpeed); - SmartTimeSpan maxTime = SmartTimeSpan.FromHours(_queueCleanerConfig.SlowMaxTime); - SmartTimeSpan currentTime = SmartTimeSpan.FromSeconds(download.Eta); - - return await CheckIfSlow( - download.Hash!, - download.Name!, - minSpeed, - currentSpeed, - maxTime, - currentTime - ); - } - - private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(DownloadStatus status) - { - if (_queueCleanerConfig.StalledMaxStrikes is 0) - { - return (false, DeleteReason.None); - } - - if (_queueCleanerConfig.StalledIgnorePrivate && status.Private) - { - // ignore private trackers - _logger.LogDebug("skip stalled check | download is private | {name}", status.Name); - return (false, DeleteReason.None); - } - - if (status.State is null || !status.State.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase)) - { - return (false, DeleteReason.None); - } - - if (status.Eta > 0) - { - return (false, DeleteReason.None); - } - - ResetStalledStrikesOnProgress(status.Hash!, status.TotalDone); - - return (await _striker.StrikeAndCheckLimit(status.Hash!, status.Name!, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled), DeleteReason.Stalled); - } - - private static void ProcessFiles(Dictionary? contents, Action processFile) - { - if (contents is null) - { - return; - } - - foreach (var (name, data) in contents) - { - switch (data.Type) - { - case "file": - processFile(name, data); - break; - case "dir" when data.Contents is not null: - // Recurse into subdirectories - ProcessFiles(data.Contents, processFile); - break; - } - } - } - - public override void Dispose() - { - } -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadClient/Deluge/IDelugeService.cs b/code/Infrastructure/Verticals/DownloadClient/Deluge/IDelugeService.cs deleted file mode 100644 index 0c516fb2..00000000 --- a/code/Infrastructure/Verticals/DownloadClient/Deluge/IDelugeService.cs +++ /dev/null @@ -1,5 +0,0 @@ -๏ปฟnamespace Infrastructure.Verticals.DownloadClient.Deluge; - -public interface IDelugeService : IDownloadService -{ -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadClient/DownloadServiceFactory.cs b/code/Infrastructure/Verticals/DownloadClient/DownloadServiceFactory.cs deleted file mode 100644 index 582b3441..00000000 --- a/code/Infrastructure/Verticals/DownloadClient/DownloadServiceFactory.cs +++ /dev/null @@ -1,31 +0,0 @@ -๏ปฟusing Common.Configuration.DownloadClient; -using Infrastructure.Verticals.DownloadClient.Deluge; -using Infrastructure.Verticals.DownloadClient.QBittorrent; -using Infrastructure.Verticals.DownloadClient.Transmission; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace Infrastructure.Verticals.DownloadClient; - -public sealed class DownloadServiceFactory -{ - private readonly IServiceProvider _serviceProvider; - private readonly Common.Enums.DownloadClient _downloadClient; - - public DownloadServiceFactory(IServiceProvider serviceProvider, IOptions downloadClientConfig) - { - _serviceProvider = serviceProvider; - _downloadClient = downloadClientConfig.Value.DownloadClient; - } - - public IDownloadService CreateDownloadClient() => - _downloadClient switch - { - Common.Enums.DownloadClient.QBittorrent => _serviceProvider.GetRequiredService(), - Common.Enums.DownloadClient.Deluge => _serviceProvider.GetRequiredService(), - Common.Enums.DownloadClient.Transmission => _serviceProvider.GetRequiredService(), - Common.Enums.DownloadClient.None => _serviceProvider.GetRequiredService(), - Common.Enums.DownloadClient.Disabled => _serviceProvider.GetRequiredService(), - _ => throw new ArgumentOutOfRangeException() - }; -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadClient/DummyDownloadService.cs b/code/Infrastructure/Verticals/DownloadClient/DummyDownloadService.cs deleted file mode 100644 index 86ada43e..00000000 --- a/code/Infrastructure/Verticals/DownloadClient/DummyDownloadService.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.RegularExpressions; -using Common.Configuration.ContentBlocker; -using Common.Configuration.DownloadCleaner; -using Common.Configuration.QueueCleaner; -using Infrastructure.Interceptors; -using Infrastructure.Verticals.ContentBlocker; -using Infrastructure.Verticals.Files; -using Infrastructure.Verticals.ItemStriker; -using Infrastructure.Verticals.Notifications; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Infrastructure.Verticals.DownloadClient; - -public class DummyDownloadService : DownloadService -{ - public DummyDownloadService( - ILogger logger, - IOptions queueCleanerConfig, - IOptions contentBlockerConfig, - IOptions downloadCleanerConfig, - IMemoryCache cache, - IFilenameEvaluator filenameEvaluator, - IStriker striker, - INotificationPublisher notifier, - IDryRunInterceptor dryRunInterceptor, - IHardLinkFileService hardLinkFileService - ) : base( - logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, - cache, filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService - ) - { - } - - public override void Dispose() - { - } - - public override Task LoginAsync() - { - return Task.CompletedTask; - } - - public override Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads) - { - throw new NotImplementedException(); - } - - public override Task BlockUnwantedFilesAsync(string hash, BlocklistType blocklistType, ConcurrentBag patterns, - ConcurrentBag regexes, IReadOnlyList ignoredDownloads) - { - throw new NotImplementedException(); - } - - public override Task?> GetSeedingDownloads() - { - throw new NotImplementedException(); - } - - public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories) - { - throw new NotImplementedException(); - } - - public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories) - { - throw new NotImplementedException(); - } - - public override Task CleanDownloadsAsync(List? downloads, List categoriesToClean, HashSet excludedHashes, IReadOnlyList ignoredDownloads) - { - throw new NotImplementedException(); - } - - public override Task ChangeCategoryForNoHardLinksAsync(List? downloads, HashSet excludedHashes, IReadOnlyList ignoredDownloads) - { - throw new NotImplementedException(); - } - - public override Task CreateCategoryAsync(string name) - { - throw new NotImplementedException(); - } - - public override Task DeleteDownload(string hash) - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadClient/QBittorrent/IQBitService.cs b/code/Infrastructure/Verticals/DownloadClient/QBittorrent/IQBitService.cs deleted file mode 100644 index 2f7a14bc..00000000 --- a/code/Infrastructure/Verticals/DownloadClient/QBittorrent/IQBitService.cs +++ /dev/null @@ -1,5 +0,0 @@ -๏ปฟnamespace Infrastructure.Verticals.DownloadClient.QBittorrent; - -public interface IQBitService : IDownloadService, IDisposable -{ -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs b/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs deleted file mode 100644 index f969f97e..00000000 --- a/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs +++ /dev/null @@ -1,599 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.RegularExpressions; -using Common.Attributes; -using Common.Configuration.ContentBlocker; -using Common.Configuration.DownloadCleaner; -using Common.Configuration.DownloadClient; -using Common.Configuration.QueueCleaner; -using Common.CustomDataTypes; -using Common.Helpers; -using Domain.Enums; -using Infrastructure.Extensions; -using Infrastructure.Interceptors; -using Infrastructure.Verticals.ContentBlocker; -using Infrastructure.Verticals.Context; -using Infrastructure.Verticals.Files; -using Infrastructure.Verticals.ItemStriker; -using Infrastructure.Verticals.Notifications; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using QBittorrent.Client; - -namespace Infrastructure.Verticals.DownloadClient.QBittorrent; - -public class QBitService : DownloadService, IQBitService -{ - private readonly QBitConfig _config; - private readonly QBittorrentClient _client; - - public QBitService( - ILogger logger, - IHttpClientFactory httpClientFactory, - IOptions config, - IOptions queueCleanerConfig, - IOptions contentBlockerConfig, - IOptions downloadCleanerConfig, - IMemoryCache cache, - IFilenameEvaluator filenameEvaluator, - IStriker striker, - INotificationPublisher notifier, - IDryRunInterceptor dryRunInterceptor, - IHardLinkFileService hardLinkFileService - ) : base( - logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache, - filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService - ) - { - _config = config.Value; - _config.Validate(); - UriBuilder uriBuilder = new(_config.Url); - uriBuilder.Path = string.IsNullOrEmpty(_config.UrlBase) - ? uriBuilder.Path - : $"{uriBuilder.Path.TrimEnd('/')}/{_config.UrlBase.TrimStart('/')}"; - _client = new(httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), uriBuilder.Uri); - } - - public override async Task LoginAsync() - { - if (string.IsNullOrEmpty(_config.Username) && string.IsNullOrEmpty(_config.Password)) - { - return; - } - - await _client.LoginAsync(_config.Username, _config.Password); - } - - /// - public override async Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads) - { - DownloadCheckResult result = new(); - TorrentInfo? download = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] })) - .FirstOrDefault(); - - if (download is null) - { - _logger.LogDebug("failed to find torrent {hash} in the download client", hash); - return result; - } - - IReadOnlyList trackers = await GetTrackersAsync(hash); - - if (ignoredDownloads.Count > 0 && - (download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)) is true)) - { - _logger.LogInformation("skip | download is ignored | {name}", download.Name); - return result; - } - - TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash); - - if (torrentProperties is null) - { - _logger.LogDebug("failed to find torrent properties {hash} in the download client", hash); - return result; - } - - result.IsPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) && - bool.TryParse(dictValue?.ToString(), out bool boolValue) - && boolValue; - - IReadOnlyList? files = await _client.GetTorrentContentsAsync(hash); - - if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip)) - { - result.ShouldRemove = true; - - // if all files were blocked by qBittorrent - if (download is { CompletionOn: not null, Downloaded: null or 0 }) - { - result.DeleteReason = DeleteReason.AllFilesSkippedByQBit; - return result; - } - - // remove if all files are unwanted - result.DeleteReason = DeleteReason.AllFilesSkipped; - return result; - } - - (result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download, result.IsPrivate); - - return result; - } - - /// - public override async Task BlockUnwantedFilesAsync(string hash, - BlocklistType blocklistType, - ConcurrentBag patterns, - ConcurrentBag regexes, - IReadOnlyList ignoredDownloads - ) - { - TorrentInfo? download = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] })) - .FirstOrDefault(); - BlockFilesResult result = new(); - - if (download is null) - { - _logger.LogDebug("failed to find torrent {hash} in the download client", hash); - return result; - } - - IReadOnlyList trackers = await GetTrackersAsync(hash); - - if (ignoredDownloads.Count > 0 && - (download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)) is true)) - { - _logger.LogInformation("skip | download is ignored | {name}", download.Name); - return result; - } - - TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash); - - if (torrentProperties is null) - { - _logger.LogDebug("failed to find torrent properties {hash} in the download client", hash); - return result; - } - - bool isPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) && - bool.TryParse(dictValue?.ToString(), out bool boolValue) - && boolValue; - - result.IsPrivate = isPrivate; - - if (_contentBlockerConfig.IgnorePrivate && isPrivate) - { - // ignore private trackers - _logger.LogDebug("skip files check | download is private | {name}", download.Name); - return result; - } - - IReadOnlyList? files = await _client.GetTorrentContentsAsync(hash); - - if (files is null) - { - return result; - } - - List unwantedFiles = []; - long totalFiles = 0; - long totalUnwantedFiles = 0; - - foreach (TorrentContent file in files) - { - if (!file.Index.HasValue) - { - continue; - } - - totalFiles++; - - if (file.Priority is TorrentContentPriority.Skip) - { - totalUnwantedFiles++; - continue; - } - - if (_filenameEvaluator.IsValid(file.Name, blocklistType, patterns, regexes)) - { - continue; - } - - _logger.LogInformation("unwanted file found | {file}", file.Name); - unwantedFiles.Add(file.Index.Value); - totalUnwantedFiles++; - } - - if (unwantedFiles.Count is 0) - { - return result; - } - - if (totalUnwantedFiles == totalFiles) - { - // Skip marking files as unwanted. The download will be removed completely. - result.ShouldRemove = true; - - return result; - } - - foreach (int fileIndex in unwantedFiles) - { - await _dryRunInterceptor.InterceptAsync(SkipFile, hash, fileIndex); - } - - return result; - } - - /// - public override async Task?> GetSeedingDownloads() => - (await _client.GetTorrentListAsync(new() - { - Filter = TorrentListFilter.Seeding - })) - ?.Where(x => !string.IsNullOrEmpty(x.Hash)) - .Cast() - .ToList(); - - /// - public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories) => - downloads - ?.Cast() - .Where(x => !string.IsNullOrEmpty(x.Hash)) - .Where(x => categories.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) - .Cast() - .ToList(); - - /// - public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories) => - downloads - ?.Cast() - .Where(x => !string.IsNullOrEmpty(x.Hash)) - .Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) - .Where(x => - { - if (_downloadCleanerConfig.UnlinkedUseTag) - { - return !x.Tags.Any(tag => tag.Equals(_downloadCleanerConfig.UnlinkedTargetCategory, StringComparison.InvariantCultureIgnoreCase)); - } - - return true; - }) - .Cast() - .ToList(); - - /// - public override async Task CleanDownloadsAsync(List? downloads, List categoriesToClean, - HashSet excludedHashes, IReadOnlyList ignoredDownloads) - { - if (downloads?.Count is null or 0) - { - return; - } - - foreach (TorrentInfo download in downloads) - { - if (string.IsNullOrEmpty(download.Hash)) - { - continue; - } - - if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase))) - { - _logger.LogDebug("skip | download is used by an arr | {name}", download.Name); - continue; - } - - IReadOnlyList trackers = await GetTrackersAsync(download.Hash); - - if (ignoredDownloads.Count > 0 && - (download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)))) - { - _logger.LogInformation("skip | download is ignored | {name}", download.Name); - continue; - } - - CleanCategory? category = categoriesToClean - .FirstOrDefault(x => download.Category.Equals(x.Name, StringComparison.InvariantCultureIgnoreCase)); - - if (category is null) - { - continue; - } - - if (!_downloadCleanerConfig.DeletePrivate) - { - TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(download.Hash); - - if (torrentProperties is null) - { - _logger.LogDebug("failed to find torrent properties in the download client | {name}", download.Name); - return; - } - - bool isPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) && - bool.TryParse(dictValue?.ToString(), out bool boolValue) - && boolValue; - - if (isPrivate) - { - _logger.LogDebug("skip | download is private | {name}", download.Name); - continue; - } - } - - ContextProvider.Set("downloadName", download.Name); - ContextProvider.Set("hash", download.Hash); - - SeedingCheckResult result = ShouldCleanDownload(download.Ratio, download.SeedingTime ?? TimeSpan.Zero, category); - - if (!result.ShouldClean) - { - continue; - } - - await _dryRunInterceptor.InterceptAsync(DeleteDownload, download.Hash); - - _logger.LogInformation( - "download cleaned | {reason} reached | {name}", - result.Reason is CleanReason.MaxRatioReached - ? "MAX_RATIO & MIN_SEED_TIME" - : "MAX_SEED_TIME", - download.Name - ); - - await _notifier.NotifyDownloadCleaned(download.Ratio, download.SeedingTime ?? TimeSpan.Zero, category.Name, result.Reason); - } - } - - public override async Task CreateCategoryAsync(string name) - { - IReadOnlyDictionary? existingCategories = await _client.GetCategoriesAsync(); - - if (existingCategories.Any(x => x.Value.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase))) - { - return; - } - - await _dryRunInterceptor.InterceptAsync(CreateCategory, name); - } - - public override async Task ChangeCategoryForNoHardLinksAsync(List? downloads, HashSet excludedHashes, IReadOnlyList ignoredDownloads) - { - if (downloads?.Count is null or 0) - { - return; - } - - if (!string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir)) - { - _hardLinkFileService.PopulateFileCounts(_downloadCleanerConfig.UnlinkedIgnoredRootDir); - } - - foreach (TorrentInfo download in downloads) - { - if (string.IsNullOrEmpty(download.Hash)) - { - continue; - } - - if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase))) - { - _logger.LogDebug("skip | download is used by an arr | {name}", download.Name); - continue; - } - - IReadOnlyList trackers = await GetTrackersAsync(download.Hash); - - if (ignoredDownloads.Count > 0 && - (download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)))) - { - _logger.LogInformation("skip | download is ignored | {name}", download.Name); - continue; - } - - IReadOnlyList? files = await _client.GetTorrentContentsAsync(download.Hash); - - if (files is null) - { - _logger.LogDebug("failed to find files for {name}", download.Name); - continue; - } - - ContextProvider.Set("downloadName", download.Name); - ContextProvider.Set("hash", download.Hash); - bool hasHardlinks = false; - - foreach (TorrentContent file in files) - { - if (!file.Index.HasValue) - { - _logger.LogDebug("skip | file index is null for {name}", download.Name); - hasHardlinks = true; - break; - } - - string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.SavePath, file.Name).Split(['\\', '/'])); - - if (file.Priority is TorrentContentPriority.Skip) - { - _logger.LogDebug("skip | file is not downloaded | {file}", filePath); - continue; - } - - long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir)); - - if (hardlinkCount < 0) - { - _logger.LogDebug("skip | could not get file properties | {file}", filePath); - hasHardlinks = true; - break; - } - - if (hardlinkCount > 0) - { - hasHardlinks = true; - break; - } - } - - if (hasHardlinks) - { - _logger.LogDebug("skip | download has hardlinks | {name}", download.Name); - continue; - } - - await _dryRunInterceptor.InterceptAsync(ChangeCategory, download.Hash, _downloadCleanerConfig.UnlinkedTargetCategory); - - if (_downloadCleanerConfig.UnlinkedUseTag) - { - _logger.LogInformation("tag added for {name}", download.Name); - } - else - { - _logger.LogInformation("category changed for {name}", download.Name); - download.Category = _downloadCleanerConfig.UnlinkedTargetCategory; - } - - await _notifier.NotifyCategoryChanged(download.Category, _downloadCleanerConfig.UnlinkedTargetCategory, _downloadCleanerConfig.UnlinkedUseTag); - } - } - - /// - [DryRunSafeguard] - public override async Task DeleteDownload(string hash) - { - await _client.DeleteAsync(hash, deleteDownloadedData: true); - } - - [DryRunSafeguard] - protected async Task CreateCategory(string name) - { - await _client.AddCategoryAsync(name); - } - - [DryRunSafeguard] - protected virtual async Task SkipFile(string hash, int fileIndex) - { - await _client.SetFilePriorityAsync(hash, fileIndex, TorrentContentPriority.Skip); - } - - [DryRunSafeguard] - protected virtual async Task ChangeCategory(string hash, string newCategory) - { - if (_downloadCleanerConfig.UnlinkedUseTag) - { - await _client.AddTorrentTagAsync([hash], newCategory); - return; - } - - await _client.SetTorrentCategoryAsync([hash], newCategory); - } - - public override void Dispose() - { - _client.Dispose(); - } - - private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(TorrentInfo torrent, bool isPrivate) - { - (bool ShouldRemove, DeleteReason Reason) result = await CheckIfSlow(torrent, isPrivate); - - if (result.ShouldRemove) - { - return result; - } - - return await CheckIfStuck(torrent, isPrivate); - } - - private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(TorrentInfo download, bool isPrivate) - { - if (_queueCleanerConfig.SlowMaxStrikes is 0) - { - return (false, DeleteReason.None); - } - - if (download.State is not (TorrentState.Downloading or TorrentState.ForcedDownload)) - { - return (false, DeleteReason.None); - } - - if (download.DownloadSpeed <= 0) - { - return (false, DeleteReason.None); - } - - if (_queueCleanerConfig.SlowIgnorePrivate && isPrivate) - { - // ignore private trackers - _logger.LogDebug("skip slow check | download is private | {name}", download.Name); - return (false, DeleteReason.None); - } - - if (download.Size > (_queueCleanerConfig.SlowIgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue)) - { - _logger.LogDebug("skip slow check | download is too large | {name}", download.Name); - return (false, DeleteReason.None); - } - - ByteSize minSpeed = _queueCleanerConfig.SlowMinSpeedByteSize; - ByteSize currentSpeed = new ByteSize(download.DownloadSpeed); - SmartTimeSpan maxTime = SmartTimeSpan.FromHours(_queueCleanerConfig.SlowMaxTime); - SmartTimeSpan currentTime = new SmartTimeSpan(download.EstimatedTime ?? TimeSpan.Zero); - - return await CheckIfSlow( - download.Hash, - download.Name, - minSpeed, - currentSpeed, - maxTime, - currentTime - ); - } - - private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(TorrentInfo torrent, bool isPrivate) - { - if (_queueCleanerConfig.StalledMaxStrikes is 0 && _queueCleanerConfig.DownloadingMetadataMaxStrikes is 0) - { - return (false, DeleteReason.None); - } - - if (torrent.State is not TorrentState.StalledDownload and not TorrentState.FetchingMetadata - and not TorrentState.ForcedFetchingMetadata) - { - // ignore other states - return (false, DeleteReason.None); - } - - if (_queueCleanerConfig.StalledMaxStrikes > 0 && torrent.State is TorrentState.StalledDownload) - { - if (_queueCleanerConfig.StalledIgnorePrivate && isPrivate) - { - // ignore private trackers - _logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name); - } - else - { - ResetStalledStrikesOnProgress(torrent.Hash, torrent.Downloaded ?? 0); - - return (await _striker.StrikeAndCheckLimit(torrent.Hash, torrent.Name, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled), DeleteReason.Stalled); - } - } - - if (_queueCleanerConfig.DownloadingMetadataMaxStrikes > 0 && torrent.State is not TorrentState.StalledDownload) - { - return (await _striker.StrikeAndCheckLimit(torrent.Hash, torrent.Name, _queueCleanerConfig.DownloadingMetadataMaxStrikes, StrikeType.DownloadingMetadata), DeleteReason.DownloadingMetadata); - } - - return (false, DeleteReason.None); - } - - private async Task> GetTrackersAsync(string hash) - { - return (await _client.GetTorrentTrackersAsync(hash)) - .Where(x => x.Url.Contains("**")) - .ToList(); - } -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadClient/Transmission/ITransmissionService.cs b/code/Infrastructure/Verticals/DownloadClient/Transmission/ITransmissionService.cs deleted file mode 100644 index 230e19f9..00000000 --- a/code/Infrastructure/Verticals/DownloadClient/Transmission/ITransmissionService.cs +++ /dev/null @@ -1,5 +0,0 @@ -๏ปฟnamespace Infrastructure.Verticals.DownloadClient.Transmission; - -public interface ITransmissionService : IDownloadService -{ -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadClient/Transmission/TransmissionService.cs b/code/Infrastructure/Verticals/DownloadClient/Transmission/TransmissionService.cs deleted file mode 100644 index 80ae704d..00000000 --- a/code/Infrastructure/Verticals/DownloadClient/Transmission/TransmissionService.cs +++ /dev/null @@ -1,548 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.RegularExpressions; -using Common.Attributes; -using Common.Configuration.ContentBlocker; -using Common.Configuration.DownloadCleaner; -using Common.Configuration.DownloadClient; -using Common.Configuration.QueueCleaner; -using Common.CustomDataTypes; -using Common.Helpers; -using Domain.Enums; -using Infrastructure.Extensions; -using Infrastructure.Interceptors; -using Infrastructure.Verticals.ContentBlocker; -using Infrastructure.Verticals.Context; -using Infrastructure.Verticals.Files; -using Infrastructure.Verticals.ItemStriker; -using Infrastructure.Verticals.Notifications; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Transmission.API.RPC; -using Transmission.API.RPC.Arguments; -using Transmission.API.RPC.Entity; - -namespace Infrastructure.Verticals.DownloadClient.Transmission; - -public class TransmissionService : DownloadService, ITransmissionService -{ - private readonly TransmissionConfig _config; - private readonly Client _client; - - private static readonly string[] Fields = - [ - TorrentFields.FILES, - TorrentFields.FILE_STATS, - TorrentFields.HASH_STRING, - TorrentFields.ID, - TorrentFields.ETA, - TorrentFields.NAME, - TorrentFields.STATUS, - TorrentFields.IS_PRIVATE, - TorrentFields.DOWNLOADED_EVER, - TorrentFields.DOWNLOAD_DIR, - TorrentFields.SECONDS_SEEDING, - TorrentFields.UPLOAD_RATIO, - TorrentFields.TRACKERS, - TorrentFields.RATE_DOWNLOAD, - TorrentFields.TOTAL_SIZE, - ]; - - public TransmissionService( - IHttpClientFactory httpClientFactory, - ILogger logger, - IOptions config, - IOptions queueCleanerConfig, - IOptions contentBlockerConfig, - IOptions downloadCleanerConfig, - IMemoryCache cache, - IFilenameEvaluator filenameEvaluator, - IStriker striker, - INotificationPublisher notifier, - IDryRunInterceptor dryRunInterceptor, - IHardLinkFileService hardLinkFileService - ) : base( - logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache, - filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService - ) - { - _config = config.Value; - _config.Validate(); - UriBuilder uriBuilder = new(_config.Url); - uriBuilder.Path = string.IsNullOrEmpty(_config.UrlBase) - ? $"{uriBuilder.Path.TrimEnd('/')}/rpc" - : $"{uriBuilder.Path.TrimEnd('/')}/{_config.UrlBase.TrimStart('/').TrimEnd('/')}/rpc"; - _client = new( - httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), - uriBuilder.Uri.ToString(), - login: _config.Username, - password: _config.Password - ); - } - - public override async Task LoginAsync() - { - await _client.GetSessionInformationAsync(); - } - - /// - public override async Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads) - { - DownloadCheckResult result = new(); - TorrentInfo? download = await GetTorrentAsync(hash); - - if (download is null) - { - _logger.LogDebug("failed to find torrent {hash} in the download client", hash); - return result; - } - - if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads)) - { - _logger.LogDebug("skip | download is ignored | {name}", download.Name); - return result; - } - - bool shouldRemove = download.FileStats?.Length > 0; - result.IsPrivate = download.IsPrivate ?? false; - - foreach (TransmissionTorrentFileStats? stats in download.FileStats ?? []) - { - if (!stats.Wanted.HasValue) - { - // if any files stats are missing, do not remove - shouldRemove = false; - } - - if (stats.Wanted.HasValue && stats.Wanted.Value) - { - // if any files are wanted, do not remove - shouldRemove = false; - } - } - - if (shouldRemove) - { - // remove if all files are unwanted - result.ShouldRemove = true; - result.DeleteReason = DeleteReason.AllFilesBlocked; - return result; - } - - // remove if download is stuck - (result.ShouldRemove, result.DeleteReason) = await EvaluateDownloadRemoval(download); - - return result; - } - - /// - public override async Task BlockUnwantedFilesAsync(string hash, - BlocklistType blocklistType, - ConcurrentBag patterns, - ConcurrentBag regexes, IReadOnlyList ignoredDownloads) - { - TorrentInfo? download = await GetTorrentAsync(hash); - BlockFilesResult result = new(); - - if (download?.FileStats is null || download.Files is null) - { - return result; - } - - if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads)) - { - _logger.LogDebug("skip | download is ignored | {name}", download.Name); - return result; - } - - bool isPrivate = download.IsPrivate ?? false; - result.IsPrivate = isPrivate; - - if (_contentBlockerConfig.IgnorePrivate && isPrivate) - { - // ignore private trackers - _logger.LogDebug("skip files check | download is private | {name}", download.Name); - return result; - } - - List unwantedFiles = []; - long totalFiles = 0; - long totalUnwantedFiles = 0; - - for (int i = 0; i < download.Files.Length; i++) - { - if (download.FileStats?[i].Wanted == null) - { - continue; - } - - totalFiles++; - - if (!download.FileStats[i].Wanted.Value) - { - totalUnwantedFiles++; - continue; - } - - if (_filenameEvaluator.IsValid(download.Files[i].Name, blocklistType, patterns, regexes)) - { - continue; - } - - _logger.LogInformation("unwanted file found | {file}", download.Files[i].Name); - unwantedFiles.Add(i); - totalUnwantedFiles++; - } - - if (unwantedFiles.Count is 0) - { - return result; - } - - if (totalUnwantedFiles == totalFiles) - { - // Skip marking files as unwanted. The download will be removed completely. - result.ShouldRemove = true; - - return result; - } - - _logger.LogDebug("changing priorities | torrent {hash}", hash); - - await _dryRunInterceptor.InterceptAsync(SetUnwantedFiles, download.Id, unwantedFiles.ToArray()); - - return result; - } - - public override async Task?> GetSeedingDownloads() => - (await _client.TorrentGetAsync(Fields)) - ?.Torrents - ?.Where(x => !string.IsNullOrEmpty(x.HashString)) - .Where(x => x.Status is 5 or 6) - .Cast() - .ToList(); - - /// - public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories) - { - return downloads - ? - .Cast() - .Where(x => categories - .Any(cat => cat.Name.Equals(x.GetCategory(), StringComparison.InvariantCultureIgnoreCase)) - ) - .Cast() - .ToList(); - } - - public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories) - { - return downloads - ?.Cast() - .Where(x => !string.IsNullOrEmpty(x.HashString)) - .Where(x => categories.Any(cat => cat.Equals(x.GetCategory(), StringComparison.InvariantCultureIgnoreCase))) - .Cast() - .ToList(); - } - - /// - public override async Task CleanDownloadsAsync(List? downloads, List categoriesToClean, - HashSet excludedHashes, IReadOnlyList ignoredDownloads) - { - if (downloads?.Count is null or 0) - { - return; - } - - foreach (TorrentInfo download in downloads) - { - if (string.IsNullOrEmpty(download.HashString)) - { - continue; - } - - if (excludedHashes.Any(x => x.Equals(download.HashString, StringComparison.InvariantCultureIgnoreCase))) - { - _logger.LogDebug("skip | download is used by an arr | {name}", download.Name); - continue; - } - - if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads)) - { - _logger.LogDebug("skip | download is ignored | {name}", download.Name); - continue; - } - - CleanCategory? category = categoriesToClean - .FirstOrDefault(x => - { - if (download.DownloadDir is null) - { - return false; - } - - return Path.GetFileName(Path.TrimEndingDirectorySeparator(download.DownloadDir)) - .Equals(x.Name, StringComparison.InvariantCultureIgnoreCase); - }); - - if (category is null) - { - continue; - } - - if (!_downloadCleanerConfig.DeletePrivate && download.IsPrivate is true) - { - _logger.LogDebug("skip | download is private | {name}", download.Name); - continue; - } - - ContextProvider.Set("downloadName", download.Name); - ContextProvider.Set("hash", download.HashString); - - TimeSpan seedingTime = TimeSpan.FromSeconds(download.SecondsSeeding ?? 0); - SeedingCheckResult result = ShouldCleanDownload(download.uploadRatio ?? 0, seedingTime, category); - - if (!result.ShouldClean) - { - continue; - } - - await _dryRunInterceptor.InterceptAsync(RemoveDownloadAsync, download.Id); - - _logger.LogInformation( - "download cleaned | {reason} reached | {name}", - result.Reason is CleanReason.MaxRatioReached - ? "MAX_RATIO & MIN_SEED_TIME" - : "MAX_SEED_TIME", - download.Name - ); - - await _notifier.NotifyDownloadCleaned(download.uploadRatio ?? 0, seedingTime, category.Name, result.Reason); - } - } - - public override async Task CreateCategoryAsync(string name) - { - await Task.CompletedTask; - } - - public override async Task ChangeCategoryForNoHardLinksAsync(List? downloads, HashSet excludedHashes, IReadOnlyList ignoredDownloads) - { - if (downloads?.Count is null or 0) - { - return; - } - - if (!string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir)) - { - _hardLinkFileService.PopulateFileCounts(_downloadCleanerConfig.UnlinkedIgnoredRootDir); - } - - foreach (TorrentInfo download in downloads.Cast()) - { - if (string.IsNullOrEmpty(download.HashString) || string.IsNullOrEmpty(download.Name) || download.DownloadDir == null) - { - continue; - } - - if (excludedHashes.Any(x => x.Equals(download.HashString, StringComparison.InvariantCultureIgnoreCase))) - { - _logger.LogDebug("skip | download is used by an arr | {name}", download.Name); - continue; - } - - if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads)) - { - _logger.LogDebug("skip | download is ignored | {name}", download.Name); - continue; - } - - ContextProvider.Set("downloadName", download.Name); - ContextProvider.Set("hash", download.HashString); - - bool hasHardlinks = false; - - if (download.Files is null || download.FileStats is null) - { - _logger.LogDebug("skip | download has no files | {name}", download.Name); - continue; - } - - for (int i = 0; i < download.Files.Length; i++) - { - TransmissionTorrentFiles file = download.Files[i]; - TransmissionTorrentFileStats stats = download.FileStats[i]; - - if (stats.Wanted is null or false || string.IsNullOrEmpty(file.Name)) - { - continue; - } - - string filePath = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.DownloadDir, file.Name).Split(['\\', '/'])); - - long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir)); - - if (hardlinkCount < 0) - { - _logger.LogDebug("skip | could not get file properties | {file}", filePath); - hasHardlinks = true; - break; - } - - if (hardlinkCount > 0) - { - hasHardlinks = true; - break; - } - } - - if (hasHardlinks) - { - _logger.LogDebug("skip | download has hardlinks | {name}", download.Name); - continue; - } - - string currentCategory = download.GetCategory(); - string newLocation = string.Join(Path.DirectorySeparatorChar, Path.Combine(download.DownloadDir, _downloadCleanerConfig.UnlinkedTargetCategory).Split(['\\', '/'])); - - await _dryRunInterceptor.InterceptAsync(ChangeDownloadLocation, download.Id, newLocation); - - _logger.LogInformation("category changed for {name}", download.Name); - - await _notifier.NotifyCategoryChanged(currentCategory, _downloadCleanerConfig.UnlinkedTargetCategory); - - download.DownloadDir = newLocation; - } - } - - [DryRunSafeguard] - protected virtual async Task ChangeDownloadLocation(long downloadId, string newLocation) - { - await _client.TorrentSetLocationAsync([downloadId], newLocation, true); - } - - public override async Task DeleteDownload(string hash) - { - TorrentInfo? torrent = await GetTorrentAsync(hash); - - if (torrent is null) - { - return; - } - - await _client.TorrentRemoveAsync([torrent.Id], true); - } - - public override void Dispose() - { - } - - [DryRunSafeguard] - protected virtual async Task RemoveDownloadAsync(long downloadId) - { - await _client.TorrentRemoveAsync([downloadId], true); - } - - [DryRunSafeguard] - protected virtual async Task SetUnwantedFiles(long downloadId, long[] unwantedFiles) - { - await _client.TorrentSetAsync(new TorrentSettings - { - Ids = [downloadId], - FilesUnwanted = unwantedFiles, - }); - } - - private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(TorrentInfo torrent) - { - (bool ShouldRemove, DeleteReason Reason) result = await CheckIfSlow(torrent); - - if (result.ShouldRemove) - { - return result; - } - - return await CheckIfStuck(torrent); - } - - private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(TorrentInfo download) - { - if (_queueCleanerConfig.SlowMaxStrikes is 0) - { - return (false, DeleteReason.None); - } - - if (download.Status is not 4) - { - // not in downloading state - return (false, DeleteReason.None); - } - - if (download.RateDownload <= 0) - { - return (false, DeleteReason.None); - } - - if (_queueCleanerConfig.SlowIgnorePrivate && download.IsPrivate is true) - { - // ignore private trackers - _logger.LogDebug("skip slow check | download is private | {name}", download.Name); - return (false, DeleteReason.None); - } - - if (download.TotalSize > (_queueCleanerConfig.SlowIgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue)) - { - _logger.LogDebug("skip slow check | download is too large | {name}", download.Name); - return (false, DeleteReason.None); - } - - ByteSize minSpeed = _queueCleanerConfig.SlowMinSpeedByteSize; - ByteSize currentSpeed = new ByteSize(download.RateDownload ?? long.MaxValue); - SmartTimeSpan maxTime = SmartTimeSpan.FromHours(_queueCleanerConfig.SlowMaxTime); - SmartTimeSpan currentTime = SmartTimeSpan.FromSeconds(download.Eta ?? 0); - - return await CheckIfSlow( - download.HashString!, - download.Name!, - minSpeed, - currentSpeed, - maxTime, - currentTime - ); - } - - private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(TorrentInfo download) - { - if (_queueCleanerConfig.StalledMaxStrikes is 0) - { - return (false, DeleteReason.None); - } - - if (download.Status is not 4) - { - // not in downloading state - return (false, DeleteReason.None); - } - - if (download.RateDownload > 0 || download.Eta > 0) - { - return (false, DeleteReason.None); - } - - if (_queueCleanerConfig.StalledIgnorePrivate && (download.IsPrivate ?? false)) - { - // ignore private trackers - _logger.LogDebug("skip stalled check | download is private | {name}", download.Name); - return (false, DeleteReason.None); - } - - ResetStalledStrikesOnProgress(download.HashString!, download.DownloadedEver ?? 0); - - return (await _striker.StrikeAndCheckLimit(download.HashString!, download.Name!, _queueCleanerConfig.StalledMaxStrikes, StrikeType.Stalled), DeleteReason.Stalled); - } - - private async Task GetTorrentAsync(string hash) => - (await _client.TorrentGetAsync(Fields, hash)) - ?.Torrents - ?.FirstOrDefault(); -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadRemover/Interfaces/IQueueItemRemover.cs b/code/Infrastructure/Verticals/DownloadRemover/Interfaces/IQueueItemRemover.cs deleted file mode 100644 index f7012e9d..00000000 --- a/code/Infrastructure/Verticals/DownloadRemover/Interfaces/IQueueItemRemover.cs +++ /dev/null @@ -1,9 +0,0 @@ -๏ปฟusing Domain.Models.Arr; -using Infrastructure.Verticals.DownloadRemover.Models; - -namespace Infrastructure.Verticals.DownloadRemover.Interfaces; - -public interface IQueueItemRemover -{ - Task RemoveQueueItemAsync(QueueItemRemoveRequest request) where T : SearchItem; -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadRemover/QueueItemRemover.cs b/code/Infrastructure/Verticals/DownloadRemover/QueueItemRemover.cs deleted file mode 100644 index 12376bbc..00000000 --- a/code/Infrastructure/Verticals/DownloadRemover/QueueItemRemover.cs +++ /dev/null @@ -1,66 +0,0 @@ -๏ปฟusing Common.Configuration.Arr; -using Common.Configuration.General; -using Domain.Enums; -using Domain.Models.Arr; -using Domain.Models.Arr.Queue; -using Infrastructure.Helpers; -using Infrastructure.Verticals.Arr; -using Infrastructure.Verticals.Context; -using Infrastructure.Verticals.DownloadRemover.Interfaces; -using Infrastructure.Verticals.DownloadRemover.Models; -using Infrastructure.Verticals.Notifications; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; - -namespace Infrastructure.Verticals.DownloadRemover; - -public sealed class QueueItemRemover : IQueueItemRemover -{ - private readonly SearchConfig _searchConfig; - private readonly IMemoryCache _cache; - private readonly ArrClientFactory _arrClientFactory; - private readonly INotificationPublisher _notifier; - - public QueueItemRemover( - IOptions searchConfig, - IMemoryCache cache, - ArrClientFactory arrClientFactory, - INotificationPublisher notifier - ) - { - _searchConfig = searchConfig.Value; - _cache = cache; - _arrClientFactory = arrClientFactory; - _notifier = notifier; - } - - public async Task RemoveQueueItemAsync(QueueItemRemoveRequest request) - where T : SearchItem - { - try - { - var arrClient = _arrClientFactory.GetClient(request.InstanceType); - await arrClient.DeleteQueueItemAsync(request.Instance, request.Record, request.RemoveFromClient, request.DeleteReason); - - // push to context - ContextProvider.Set(nameof(QueueRecord), request.Record); - ContextProvider.Set(nameof(ArrInstance) + nameof(ArrInstance.Url), request.Instance.Url); - ContextProvider.Set(nameof(InstanceType), request.InstanceType); - await _notifier.NotifyQueueItemDeleted(request.RemoveFromClient, request.DeleteReason); - - if (!_searchConfig.SearchEnabled) - { - return; - } - - await arrClient.SearchItemsAsync(request.Instance, [request.SearchItem]); - - // prevent tracker spamming - await Task.Delay(TimeSpan.FromSeconds(_searchConfig.SearchDelay)); - } - finally - { - _cache.Remove(CacheKeys.DownloadMarkedForRemoval(request.Record.DownloadId, request.Instance.Url)); - } - } -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Jobs/GenericHandler.cs b/code/Infrastructure/Verticals/Jobs/GenericHandler.cs deleted file mode 100644 index 7a21f46f..00000000 --- a/code/Infrastructure/Verticals/Jobs/GenericHandler.cs +++ /dev/null @@ -1,179 +0,0 @@ -using Common.Configuration.Arr; -using Common.Configuration.DownloadClient; -using Domain.Enums; -using Domain.Models.Arr; -using Domain.Models.Arr.Queue; -using Infrastructure.Verticals.Arr; -using Infrastructure.Verticals.DownloadClient; -using Infrastructure.Verticals.DownloadRemover.Models; -using Infrastructure.Verticals.Notifications; -using MassTransit; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Infrastructure.Verticals.Jobs; - -public abstract class GenericHandler : IHandler, IDisposable -{ - protected readonly ILogger _logger; - protected readonly DownloadClientConfig _downloadClientConfig; - protected readonly SonarrConfig _sonarrConfig; - protected readonly RadarrConfig _radarrConfig; - protected readonly LidarrConfig _lidarrConfig; - protected readonly IMemoryCache _cache; - protected readonly IBus _messageBus; - protected readonly ArrClientFactory _arrClientFactory; - protected readonly ArrQueueIterator _arrArrQueueIterator; - protected readonly IDownloadService _downloadService; - protected readonly INotificationPublisher _notifier; - - protected GenericHandler( - ILogger logger, - IOptions downloadClientConfig, - IOptions sonarrConfig, - IOptions radarrConfig, - IOptions lidarrConfig, - IMemoryCache cache, - IBus messageBus, - ArrClientFactory arrClientFactory, - ArrQueueIterator arrArrQueueIterator, - DownloadServiceFactory downloadServiceFactory, - INotificationPublisher notifier - ) - { - _logger = logger; - _downloadClientConfig = downloadClientConfig.Value; - _sonarrConfig = sonarrConfig.Value; - _radarrConfig = radarrConfig.Value; - _lidarrConfig = lidarrConfig.Value; - _cache = cache; - _messageBus = messageBus; - _arrClientFactory = arrClientFactory; - _arrArrQueueIterator = arrArrQueueIterator; - _downloadService = downloadServiceFactory.CreateDownloadClient(); - _notifier = notifier; - } - - public virtual async Task ExecuteAsync() - { - await _downloadService.LoginAsync(); - - await ProcessArrConfigAsync(_sonarrConfig, InstanceType.Sonarr); - await ProcessArrConfigAsync(_radarrConfig, InstanceType.Radarr); - await ProcessArrConfigAsync(_lidarrConfig, InstanceType.Lidarr); - } - - public virtual void Dispose() - { - _downloadService.Dispose(); - } - - protected abstract Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config); - - protected async Task ProcessArrConfigAsync(ArrConfig config, InstanceType instanceType, bool throwOnFailure = false) - { - if (!config.Enabled) - { - return; - } - - foreach (ArrInstance arrInstance in config.Instances) - { - try - { - await ProcessInstanceAsync(arrInstance, instanceType, config); - } - catch (Exception exception) - { - _logger.LogError(exception, "failed to clean {type} instance | {url}", instanceType, arrInstance.Url); - - if (throwOnFailure) - { - throw; - } - } - } - } - - protected async Task PublishQueueItemRemoveRequest( - string downloadRemovalKey, - InstanceType instanceType, - ArrInstance instance, - QueueRecord record, - bool isPack, - bool removeFromClient, - DeleteReason deleteReason - ) - { - if (instanceType is InstanceType.Sonarr) - { - QueueItemRemoveRequest removeRequest = new() - { - InstanceType = instanceType, - Instance = instance, - Record = record, - SearchItem = (SonarrSearchItem)GetRecordSearchItem(instanceType, record, isPack), - RemoveFromClient = removeFromClient, - DeleteReason = deleteReason - }; - - await _messageBus.Publish(removeRequest); - } - else - { - QueueItemRemoveRequest removeRequest = new() - { - InstanceType = instanceType, - Instance = instance, - Record = record, - SearchItem = GetRecordSearchItem(instanceType, record, isPack), - RemoveFromClient = removeFromClient, - DeleteReason = deleteReason - }; - - await _messageBus.Publish(removeRequest); - } - - _cache.Set(downloadRemovalKey, true); - _logger.LogInformation("item marked for removal | {title} | {url}", record.Title, instance.Url); - } - - protected SearchItem GetRecordSearchItem(InstanceType type, QueueRecord record, bool isPack = false) - { - return type switch - { - InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Episode && !isPack => new SonarrSearchItem - { - Id = record.EpisodeId, - SeriesId = record.SeriesId, - SearchType = SonarrSearchType.Episode - }, - InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Episode && isPack => new SonarrSearchItem - { - Id = record.SeasonNumber, - SeriesId = record.SeriesId, - SearchType = SonarrSearchType.Season - }, - InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Season => new SonarrSearchItem - { - Id = record.SeasonNumber, - SeriesId = record.SeriesId, - SearchType = SonarrSearchType.Series - }, - InstanceType.Sonarr when _sonarrConfig.SearchType is SonarrSearchType.Series => new SonarrSearchItem - { - Id = record.SeriesId - }, - InstanceType.Radarr => new SearchItem - { - Id = record.MovieId - }, - InstanceType.Lidarr => new SearchItem - { - Id = record.AlbumId - }, - _ => throw new NotImplementedException($"instance type {type} is not yet supported") - }; - } -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Jobs/IHandler.cs b/code/Infrastructure/Verticals/Jobs/IHandler.cs deleted file mode 100644 index 560241a6..00000000 --- a/code/Infrastructure/Verticals/Jobs/IHandler.cs +++ /dev/null @@ -1,6 +0,0 @@ -๏ปฟnamespace Infrastructure.Verticals.Jobs; - -public interface IHandler -{ - Task ExecuteAsync(); -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Apprise/IAppriseProxy.cs b/code/Infrastructure/Verticals/Notifications/Apprise/IAppriseProxy.cs deleted file mode 100644 index 534aa594..00000000 --- a/code/Infrastructure/Verticals/Notifications/Apprise/IAppriseProxy.cs +++ /dev/null @@ -1,6 +0,0 @@ -๏ปฟnamespace Infrastructure.Verticals.Notifications.Apprise; - -public interface IAppriseProxy -{ - Task SendNotification(ApprisePayload payload, AppriseConfig config); -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Models/FailedImportStrikeNotification.cs b/code/Infrastructure/Verticals/Notifications/Models/FailedImportStrikeNotification.cs deleted file mode 100644 index 3699bf12..00000000 --- a/code/Infrastructure/Verticals/Notifications/Models/FailedImportStrikeNotification.cs +++ /dev/null @@ -1,5 +0,0 @@ -๏ปฟnamespace Infrastructure.Verticals.Notifications.Models; - -public sealed record FailedImportStrikeNotification : ArrNotification -{ -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Models/QueueItemDeletedNotification.cs b/code/Infrastructure/Verticals/Notifications/Models/QueueItemDeletedNotification.cs deleted file mode 100644 index 5af2de3e..00000000 --- a/code/Infrastructure/Verticals/Notifications/Models/QueueItemDeletedNotification.cs +++ /dev/null @@ -1,5 +0,0 @@ -๏ปฟnamespace Infrastructure.Verticals.Notifications.Models; - -public sealed record QueueItemDeletedNotification : ArrNotification -{ -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Models/SlowStrikeNotification.cs b/code/Infrastructure/Verticals/Notifications/Models/SlowStrikeNotification.cs deleted file mode 100644 index 796443bd..00000000 --- a/code/Infrastructure/Verticals/Notifications/Models/SlowStrikeNotification.cs +++ /dev/null @@ -1,5 +0,0 @@ -๏ปฟnamespace Infrastructure.Verticals.Notifications.Models; - -public sealed record SlowStrikeNotification : ArrNotification -{ -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Models/StalledStrikeNotification.cs b/code/Infrastructure/Verticals/Notifications/Models/StalledStrikeNotification.cs deleted file mode 100644 index f194bc57..00000000 --- a/code/Infrastructure/Verticals/Notifications/Models/StalledStrikeNotification.cs +++ /dev/null @@ -1,5 +0,0 @@ -๏ปฟnamespace Infrastructure.Verticals.Notifications.Models; - -public sealed record StalledStrikeNotification : ArrNotification -{ -} \ No newline at end of file diff --git a/code/Infrastructure/Verticals/Notifications/Notifiarr/INotifiarrProxy.cs b/code/Infrastructure/Verticals/Notifications/Notifiarr/INotifiarrProxy.cs deleted file mode 100644 index d54c8faa..00000000 --- a/code/Infrastructure/Verticals/Notifications/Notifiarr/INotifiarrProxy.cs +++ /dev/null @@ -1,6 +0,0 @@ -๏ปฟnamespace Infrastructure.Verticals.Notifications.Notifiarr; - -public interface INotifiarrProxy -{ - Task SendNotification(NotifiarrPayload payload, NotifiarrConfig config); -} \ No newline at end of file diff --git a/code/Makefile b/code/Makefile new file mode 100644 index 00000000..1a8879fa --- /dev/null +++ b/code/Makefile @@ -0,0 +1,16 @@ +.DEFAULT_GOAL := no-default + +no-default: + $(error You must specify a make target) + +migrate-data: +ifndef name + $(error name is required. Usage: make migrate-data name=YourMigrationName) +endif + dotnet ef migrations add $(name) --context DataContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Data + +migrate-events: +ifndef name + $(error name is required. Usage: make migrate-events name=YourMigrationName) +endif + dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events \ No newline at end of file diff --git a/code/backend/Cleanuparr.Api/Cleanuparr.Api.csproj b/code/backend/Cleanuparr.Api/Cleanuparr.Api.csproj new file mode 100644 index 00000000..01113b53 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Cleanuparr.Api.csproj @@ -0,0 +1,49 @@ + + + + Cleanuparr + 0.0.1 + net9.0 + enable + enable + true + false + false + <_CodeSignDuringBuild>false + true + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + diff --git a/code/backend/Cleanuparr.Api/Controllers/ApiDocumentationController.cs b/code/backend/Cleanuparr.Api/Controllers/ApiDocumentationController.cs new file mode 100644 index 00000000..2ddfea3f --- /dev/null +++ b/code/backend/Cleanuparr.Api/Controllers/ApiDocumentationController.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Cleanuparr.Api.Controllers; + +[ApiController] +[Route("api")] +public class ApiDocumentationController : ControllerBase +{ + [HttpGet] + public IActionResult RedirectToSwagger() + { + return Redirect("/api/swagger"); + } +} diff --git a/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs b/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs new file mode 100644 index 00000000..4feb453a --- /dev/null +++ b/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs @@ -0,0 +1,1140 @@ +using Cleanuparr.Api.Models; +using Cleanuparr.Application.Features.Arr.Dtos; +using Cleanuparr.Application.Features.DownloadClient.Dtos; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Domain.Exceptions; +using Cleanuparr.Infrastructure.Helpers; +using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; +using Cleanuparr.Infrastructure.Logging; +using Cleanuparr.Infrastructure.Models; +using Cleanuparr.Infrastructure.Utilities; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.Configuration.ContentBlocker; +using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; +using Cleanuparr.Persistence.Models.Configuration.General; +using Cleanuparr.Persistence.Models.Configuration.Notification; +using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; +using Infrastructure.Services.Interfaces; +using Mapster; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace Cleanuparr.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ConfigurationController : ControllerBase +{ + private readonly ILogger _logger; + private readonly DataContext _dataContext; + private readonly LoggingConfigManager _loggingConfigManager; + private readonly IJobManagementService _jobManagementService; + private readonly MemoryCache _cache; + + public ConfigurationController( + ILogger logger, + DataContext dataContext, + LoggingConfigManager loggingConfigManager, + IJobManagementService jobManagementService, + MemoryCache cache + ) + { + _logger = logger; + _dataContext = dataContext; + _loggingConfigManager = loggingConfigManager; + _jobManagementService = jobManagementService; + _cache = cache; + } + + [HttpGet("queue_cleaner")] + public async Task GetQueueCleanerConfig() + { + await DataContext.Lock.WaitAsync(); + try + { + var config = await _dataContext.QueueCleanerConfigs + .AsNoTracking() + .FirstAsync(); + return Ok(config); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpGet("content_blocker")] + public async Task GetContentBlockerConfig() + { + await DataContext.Lock.WaitAsync(); + try + { + var config = await _dataContext.ContentBlockerConfigs + .AsNoTracking() + .FirstAsync(); + return Ok(config); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpGet("download_cleaner")] + public async Task GetDownloadCleanerConfig() + { + await DataContext.Lock.WaitAsync(); + try + { + var config = await _dataContext.DownloadCleanerConfigs + .Include(x => x.Categories) + .AsNoTracking() + .FirstAsync(); + return Ok(config); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpGet("download_client")] + public async Task GetDownloadClientConfig() + { + await DataContext.Lock.WaitAsync(); + try + { + var clients = await _dataContext.DownloadClients + .AsNoTracking() + .ToListAsync(); + + // Return in the expected format with clients wrapper + var config = new { clients = clients }; + return Ok(config); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPost("download_client")] + public async Task CreateDownloadClientConfig([FromBody] CreateDownloadClientDto newClient) + { + await DataContext.Lock.WaitAsync(); + try + { + // Validate the configuration + newClient.Validate(); + + // Create the full config from the DTO + var clientConfig = new DownloadClientConfig + { + Enabled = newClient.Enabled, + Name = newClient.Name, + TypeName = newClient.TypeName, + Type = newClient.Type, + Host = newClient.Host, + Username = newClient.Username, + Password = newClient.Password, + UrlBase = newClient.UrlBase + }; + + // Add the new client to the database + _dataContext.DownloadClients.Add(clientConfig); + await _dataContext.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetDownloadClientConfig), new { id = clientConfig.Id }, clientConfig); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create download client"); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("download_client/{id}")] + public async Task UpdateDownloadClientConfig(Guid id, [FromBody] DownloadClientConfig updatedClient) + { + await DataContext.Lock.WaitAsync(); + try + { + // Find the existing download client + var existingClient = await _dataContext.DownloadClients + .FirstOrDefaultAsync(c => c.Id == id); + + if (existingClient == null) + { + return NotFound($"Download client with ID {id} not found"); + } + + // Ensure the ID in the path matches the entity being updated + updatedClient = updatedClient with { Id = id }; + + // Apply updates from DTO + updatedClient.Adapt(existingClient); + + // Persist the configuration + await _dataContext.SaveChangesAsync(); + + return Ok(existingClient); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update download client with ID {Id}", id); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpDelete("download_client/{id}")] + public async Task DeleteDownloadClientConfig(Guid id) + { + await DataContext.Lock.WaitAsync(); + try + { + // Find the existing download client + var existingClient = await _dataContext.DownloadClients + .FirstOrDefaultAsync(c => c.Id == id); + + if (existingClient == null) + { + return NotFound($"Download client with ID {id} not found"); + } + + // Remove the client from the database + _dataContext.DownloadClients.Remove(existingClient); + await _dataContext.SaveChangesAsync(); + + // Clean up any registered HTTP client configuration + var dynamicHttpClientFactory = HttpContext.RequestServices + .GetRequiredService(); + + var clientName = $"DownloadClient_{id}"; + dynamicHttpClientFactory.UnregisterConfiguration(clientName); + + _logger.LogInformation("Removed HTTP client configuration for deleted download client {ClientName}", clientName); + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete download client with ID {Id}", id); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpGet("general")] + public async Task GetGeneralConfig() + { + await DataContext.Lock.WaitAsync(); + try + { + var config = await _dataContext.GeneralConfigs + .AsNoTracking() + .FirstAsync(); + return Ok(config); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpGet("sonarr")] + public async Task GetSonarrConfig() + { + await DataContext.Lock.WaitAsync(); + try + { + var config = await _dataContext.ArrConfigs + .Include(x => x.Instances) + .AsNoTracking() + .FirstAsync(x => x.Type == InstanceType.Sonarr); + return Ok(config.Adapt()); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpGet("radarr")] + public async Task GetRadarrConfig() + { + await DataContext.Lock.WaitAsync(); + try + { + var config = await _dataContext.ArrConfigs + .Include(x => x.Instances) + .AsNoTracking() + .FirstAsync(x => x.Type == InstanceType.Radarr); + return Ok(config.Adapt()); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpGet("lidarr")] + public async Task GetLidarrConfig() + { + await DataContext.Lock.WaitAsync(); + try + { + var config = await _dataContext.ArrConfigs + .Include(x => x.Instances) + .AsNoTracking() + .FirstAsync(x => x.Type == InstanceType.Lidarr); + return Ok(config.Adapt()); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpGet("notifications")] + public async Task GetNotificationsConfig() + { + await DataContext.Lock.WaitAsync(); + try + { + var notifiarrConfig = await _dataContext.NotifiarrConfigs + .AsNoTracking() + .FirstOrDefaultAsync(); + + var appriseConfig = await _dataContext.AppriseConfigs + .AsNoTracking() + .FirstOrDefaultAsync(); + + // Return in the expected format with wrapper object + var config = new + { + notifiarr = notifiarrConfig, + apprise = appriseConfig + }; + return Ok(config); + } + finally + { + DataContext.Lock.Release(); + } + } + + public class UpdateNotificationConfigDto + { + public NotifiarrConfig? Notifiarr { get; set; } + public AppriseConfig? Apprise { get; set; } + } + + [HttpPut("notifications")] + public async Task UpdateNotificationsConfig([FromBody] UpdateNotificationConfigDto newConfig) + { + await DataContext.Lock.WaitAsync(); + try + { + // Update Notifiarr config if provided + if (newConfig.Notifiarr != null) + { + var existingNotifiarr = await _dataContext.NotifiarrConfigs.FirstOrDefaultAsync(); + if (existingNotifiarr != null) + { + // Apply updates from DTO, excluding the ID property to avoid EF key modification error + var config = new TypeAdapterConfig(); + config.NewConfig() + .Ignore(dest => dest.Id); + + newConfig.Notifiarr.Adapt(existingNotifiarr, config); + } + else + { + _dataContext.NotifiarrConfigs.Add(newConfig.Notifiarr); + } + } + + // Update Apprise config if provided + if (newConfig.Apprise != null) + { + var existingApprise = await _dataContext.AppriseConfigs.FirstOrDefaultAsync(); + if (existingApprise != null) + { + // Apply updates from DTO, excluding the ID property to avoid EF key modification error + var config = new TypeAdapterConfig(); + config.NewConfig() + .Ignore(dest => dest.Id); + + newConfig.Apprise.Adapt(existingApprise, config); + } + else + { + _dataContext.AppriseConfigs.Add(newConfig.Apprise); + } + } + + // Persist the configuration + await _dataContext.SaveChangesAsync(); + + return Ok(new { Message = "Notifications configuration updated successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save Notifications configuration"); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("queue_cleaner")] + public async Task UpdateQueueCleanerConfig([FromBody] QueueCleanerConfig newConfig) + { + await DataContext.Lock.WaitAsync(); + try + { + // Validate the configuration + newConfig.Validate(); + + // Validate cron expression if present + if (!string.IsNullOrEmpty(newConfig.CronExpression)) + { + CronValidationHelper.ValidateCronExpression(newConfig.CronExpression); + } + + // Get existing config + var oldConfig = await _dataContext.QueueCleanerConfigs + .FirstAsync(); + + // Apply updates from DTO, excluding the ID property to avoid EF key modification error + var adapterConfig = new TypeAdapterConfig(); + adapterConfig.NewConfig() + .Ignore(dest => dest.Id); + + newConfig.Adapt(oldConfig, adapterConfig); + + // Persist the configuration + await _dataContext.SaveChangesAsync(); + + // Update the scheduler based on configuration changes + await UpdateJobSchedule(oldConfig, JobType.QueueCleaner); + + return Ok(new { Message = "QueueCleaner configuration updated successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save QueueCleaner configuration"); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("content_blocker")] + public async Task UpdateContentBlockerConfig([FromBody] ContentBlockerConfig newConfig) + { + await DataContext.Lock.WaitAsync(); + try + { + // Validate the configuration + newConfig.Validate(); + + // Validate cron expression if present + if (!string.IsNullOrEmpty(newConfig.CronExpression)) + { + CronValidationHelper.ValidateCronExpression(newConfig.CronExpression, JobType.ContentBlocker); + } + + // Get existing config + var oldConfig = await _dataContext.ContentBlockerConfigs + .FirstAsync(); + + // Apply updates from DTO, excluding the ID property to avoid EF key modification error + var config = new TypeAdapterConfig(); + config.NewConfig() + .Ignore(dest => dest.Id); + + newConfig.Adapt(oldConfig, config); + + // Persist the configuration + await _dataContext.SaveChangesAsync(); + + // Update the scheduler based on configuration changes + await UpdateJobSchedule(oldConfig, JobType.ContentBlocker); + + return Ok(new { Message = "ContentBlocker configuration updated successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save ContentBlocker configuration"); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("download_cleaner")] + public async Task UpdateDownloadCleanerConfig([FromBody] UpdateDownloadCleanerConfigDto newConfigDto) + { + await DataContext.Lock.WaitAsync(); + try + { + // Validate cron expression if present + if (!string.IsNullOrEmpty(newConfigDto.CronExpression)) + { + CronValidationHelper.ValidateCronExpression(newConfigDto.CronExpression); + } + + // Validate categories + if (newConfigDto.Enabled && newConfigDto.Categories.Any()) + { + // Check for duplicate category names + if (newConfigDto.Categories.GroupBy(x => x.Name).Any(x => x.Count() > 1)) + { + throw new ValidationException("Duplicate category names found"); + } + + // Validate each category + foreach (var categoryDto in newConfigDto.Categories) + { + if (string.IsNullOrEmpty(categoryDto.Name.Trim())) + { + 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"); + } + } + } + + // Validate unlinked settings if enabled + if (newConfigDto.UnlinkedEnabled) + { + if (string.IsNullOrEmpty(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.IsNullOrEmpty)) + { + 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 config + var oldConfig = await _dataContext.DownloadCleanerConfigs + .Include(x => x.Categories) + .FirstAsync(); + + // Update the main properties from DTO + + oldConfig.Enabled = newConfigDto.Enabled; + oldConfig.CronExpression = newConfigDto.CronExpression; + oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling; + oldConfig.DeletePrivate = newConfigDto.DeletePrivate; + oldConfig.UnlinkedEnabled = newConfigDto.UnlinkedEnabled; + oldConfig.UnlinkedTargetCategory = newConfigDto.UnlinkedTargetCategory; + oldConfig.UnlinkedUseTag = newConfigDto.UnlinkedUseTag; + oldConfig.UnlinkedIgnoredRootDir = newConfigDto.UnlinkedIgnoredRootDir; + oldConfig.UnlinkedCategories = newConfigDto.UnlinkedCategories; + + // Handle Categories collection separately to avoid EF tracking issues + // Clear existing categories + _dataContext.CleanCategories.RemoveRange(oldConfig.Categories); + _dataContext.DownloadCleanerConfigs.Update(oldConfig); + + // Add new categories + foreach (var categoryDto in newConfigDto.Categories) + { + _dataContext.CleanCategories.Add(new CleanCategory + { + Name = categoryDto.Name, + MaxRatio = categoryDto.MaxRatio, + MinSeedTime = categoryDto.MinSeedTime, + MaxSeedTime = categoryDto.MaxSeedTime, + DownloadCleanerConfigId = oldConfig.Id + }); + } + + // Persist the configuration + await _dataContext.SaveChangesAsync(); + + // Update the scheduler based on configuration changes + await UpdateJobSchedule(oldConfig, JobType.DownloadCleaner); + + return Ok(new { Message = "DownloadCleaner configuration updated successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save DownloadCleaner configuration"); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("general")] + public async Task UpdateGeneralConfig([FromBody] GeneralConfig newConfig) + { + await DataContext.Lock.WaitAsync(); + try + { + // Validate the configuration + newConfig.Validate(); + + // Get existing config + var oldConfig = await _dataContext.GeneralConfigs + .FirstAsync(); + + // Apply updates from DTO, excluding the ID property to avoid EF key modification error + var config = new TypeAdapterConfig(); + config.NewConfig() + .Ignore(dest => dest.Id); + + if (oldConfig.DryRun && !newConfig.DryRun) + { + foreach (string strikeType in Enum.GetNames(typeof(StrikeType))) + { + var keys = _cache.Keys + .Where(key => key.ToString()?.StartsWith(strikeType, StringComparison.InvariantCultureIgnoreCase) is true) + .ToList(); + + foreach (object key in keys) + { + _cache.Remove(key); + } + + _logger.LogTrace("Removed all cache entries for strike type: {StrikeType}", strikeType); + } + } + + newConfig.Adapt(oldConfig, config); + + // Persist the configuration + await _dataContext.SaveChangesAsync(); + + // Update all HTTP client configurations with new general settings + var dynamicHttpClientFactory = HttpContext.RequestServices + .GetRequiredService(); + + dynamicHttpClientFactory.UpdateAllClientsFromGeneralConfig(oldConfig); + + _logger.LogInformation("Updated all HTTP client configurations with new general settings"); + + // Set the logging level based on the new configuration + _loggingConfigManager.SetLogLevel(newConfig.LogLevel); + + return Ok(new { Message = "General configuration updated successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save General configuration"); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("sonarr")] + public async Task UpdateSonarrConfig([FromBody] UpdateSonarrConfigDto newConfigDto) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get existing config + var config = await _dataContext.ArrConfigs + .FirstAsync(x => x.Type == InstanceType.Sonarr); + + config.FailedImportMaxStrikes = newConfigDto.FailedImportMaxStrikes; + + // Validate the configuration + config.Validate(); + + // Persist the configuration + await _dataContext.SaveChangesAsync(); + + return Ok(new { Message = "Sonarr configuration updated successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save Sonarr configuration"); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("radarr")] + public async Task UpdateRadarrConfig([FromBody] UpdateRadarrConfigDto newConfigDto) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get existing config + var config = await _dataContext.ArrConfigs + .FirstAsync(x => x.Type == InstanceType.Radarr); + + config.FailedImportMaxStrikes = newConfigDto.FailedImportMaxStrikes; + + // Validate the configuration + config.Validate(); + + // Persist the configuration + await _dataContext.SaveChangesAsync(); + + return Ok(new { Message = "Radarr configuration updated successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save Radarr configuration"); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("lidarr")] + public async Task UpdateLidarrConfig([FromBody] UpdateLidarrConfigDto newConfigDto) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get existing config + var config = await _dataContext.ArrConfigs + .FirstAsync(x => x.Type == InstanceType.Lidarr); + + config.FailedImportMaxStrikes = newConfigDto.FailedImportMaxStrikes; + + // Validate the configuration + config.Validate(); + + // Persist the configuration + await _dataContext.SaveChangesAsync(); + + return Ok(new { Message = "Lidarr configuration updated successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save Lidarr configuration"); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + /// + /// Updates a job schedule based on configuration changes + /// + /// The job configuration + /// The type of job to update + private async Task UpdateJobSchedule(IJobConfig config, JobType jobType) + { + if (config.Enabled) + { + // Get the cron expression based on the specific config type + if (!string.IsNullOrEmpty(config.CronExpression)) + { + // If the job is enabled, update its schedule with the configured cron expression + _logger.LogInformation("{name} is enabled, updating job schedule with cron expression: {CronExpression}", + jobType.ToString(), config.CronExpression); + + // Create a Quartz job schedule with the cron expression + await _jobManagementService.StartJob(jobType, null, config.CronExpression); + } + else + { + _logger.LogWarning("{name} is enabled, but no cron expression was found in the configuration", jobType.ToString()); + } + + return; + } + + // If the job is disabled, stop it + _logger.LogInformation("{name} is disabled, stopping the job", jobType.ToString()); + await _jobManagementService.StopJob(jobType); + } + + [HttpPost("sonarr/instances")] + public async Task CreateSonarrInstance([FromBody] CreateArrInstanceDto newInstance) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Sonarr config to add the instance to + var config = await _dataContext.ArrConfigs + .FirstAsync(x => x.Type == InstanceType.Sonarr); + + // Create the new instance + var instance = new ArrInstance + { + Enabled = newInstance.Enabled, + Name = newInstance.Name, + Url = new Uri(newInstance.Url), + ApiKey = newInstance.ApiKey, + ArrConfigId = config.Id, + }; + + // Add to the config's instances collection + // config.Instances.Add(instance); + await _dataContext.ArrInstances.AddAsync(instance); + // Save changes + await _dataContext.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetSonarrConfig), new { id = instance.Id }, instance.Adapt()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create Sonarr instance"); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("sonarr/instances/{id}")] + public async Task UpdateSonarrInstance(Guid id, [FromBody] CreateArrInstanceDto updatedInstance) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Sonarr config and find the instance + var config = await _dataContext.ArrConfigs + .Include(c => c.Instances) + .FirstAsync(x => x.Type == InstanceType.Sonarr); + + var instance = config.Instances.FirstOrDefault(i => i.Id == id); + if (instance == null) + { + return NotFound($"Sonarr instance with ID {id} not found"); + } + + // Update the instance properties + instance.Enabled = updatedInstance.Enabled; + instance.Name = updatedInstance.Name; + instance.Url = new Uri(updatedInstance.Url); + instance.ApiKey = updatedInstance.ApiKey; + + await _dataContext.SaveChangesAsync(); + + return Ok(instance.Adapt()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update Sonarr instance with ID {Id}", id); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpDelete("sonarr/instances/{id}")] + public async Task DeleteSonarrInstance(Guid id) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Sonarr config and find the instance + var config = await _dataContext.ArrConfigs + .Include(c => c.Instances) + .FirstAsync(x => x.Type == InstanceType.Sonarr); + + var instance = config.Instances.FirstOrDefault(i => i.Id == id); + if (instance == null) + { + return NotFound($"Sonarr instance with ID {id} not found"); + } + + // Remove the instance + config.Instances.Remove(instance); + await _dataContext.SaveChangesAsync(); + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete Sonarr instance with ID {Id}", id); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPost("radarr/instances")] + public async Task CreateRadarrInstance([FromBody] CreateArrInstanceDto newInstance) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Radarr config to add the instance to + var config = await _dataContext.ArrConfigs + .FirstAsync(x => x.Type == InstanceType.Radarr); + + // Create the new instance + var instance = new ArrInstance + { + Enabled = newInstance.Enabled, + Name = newInstance.Name, + Url = new Uri(newInstance.Url), + ApiKey = newInstance.ApiKey, + ArrConfigId = config.Id, + }; + + // Add to the config's instances collection + await _dataContext.ArrInstances.AddAsync(instance); + // Save changes + await _dataContext.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetRadarrConfig), new { id = instance.Id }, instance.Adapt()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create Radarr instance"); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("radarr/instances/{id}")] + public async Task UpdateRadarrInstance(Guid id, [FromBody] CreateArrInstanceDto updatedInstance) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Radarr config and find the instance + var config = await _dataContext.ArrConfigs + .Include(c => c.Instances) + .FirstAsync(x => x.Type == InstanceType.Radarr); + + var instance = config.Instances.FirstOrDefault(i => i.Id == id); + if (instance == null) + { + return NotFound($"Radarr instance with ID {id} not found"); + } + + // Update the instance properties + instance.Enabled = updatedInstance.Enabled; + instance.Name = updatedInstance.Name; + instance.Url = new Uri(updatedInstance.Url); + instance.ApiKey = updatedInstance.ApiKey; + + await _dataContext.SaveChangesAsync(); + + return Ok(instance.Adapt()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update Radarr instance with ID {Id}", id); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpDelete("radarr/instances/{id}")] + public async Task DeleteRadarrInstance(Guid id) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Radarr config and find the instance + var config = await _dataContext.ArrConfigs + .Include(c => c.Instances) + .FirstAsync(x => x.Type == InstanceType.Radarr); + + var instance = config.Instances.FirstOrDefault(i => i.Id == id); + if (instance == null) + { + return NotFound($"Radarr instance with ID {id} not found"); + } + + // Remove the instance + config.Instances.Remove(instance); + await _dataContext.SaveChangesAsync(); + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete Radarr instance with ID {Id}", id); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPost("lidarr/instances")] + public async Task CreateLidarrInstance([FromBody] CreateArrInstanceDto newInstance) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Lidarr config to add the instance to + var config = await _dataContext.ArrConfigs + .FirstAsync(x => x.Type == InstanceType.Lidarr); + + // Create the new instance + var instance = new ArrInstance + { + Enabled = newInstance.Enabled, + Name = newInstance.Name, + Url = new Uri(newInstance.Url), + ApiKey = newInstance.ApiKey, + ArrConfigId = config.Id, + }; + + // Add to the config's instances collection + // config.Instances.Add(instance); + await _dataContext.ArrInstances.AddAsync(instance); + // Save changes + await _dataContext.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetLidarrConfig), new { id = instance.Id }, instance.Adapt()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create Lidarr instance"); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("lidarr/instances/{id}")] + public async Task UpdateLidarrInstance(Guid id, [FromBody] CreateArrInstanceDto updatedInstance) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Lidarr config and find the instance + var config = await _dataContext.ArrConfigs + .Include(c => c.Instances) + .FirstAsync(x => x.Type == InstanceType.Lidarr); + + var instance = config.Instances.FirstOrDefault(i => i.Id == id); + if (instance == null) + { + return NotFound($"Lidarr instance with ID {id} not found"); + } + + // Update the instance properties + instance.Enabled = updatedInstance.Enabled; + instance.Name = updatedInstance.Name; + instance.Url = new Uri(updatedInstance.Url); + instance.ApiKey = updatedInstance.ApiKey; + + await _dataContext.SaveChangesAsync(); + + return Ok(instance.Adapt()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update Lidarr instance with ID {Id}", id); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpDelete("lidarr/instances/{id}")] + public async Task DeleteLidarrInstance(Guid id) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Lidarr config and find the instance + var config = await _dataContext.ArrConfigs + .Include(c => c.Instances) + .FirstAsync(x => x.Type == InstanceType.Lidarr); + + var instance = config.Instances.FirstOrDefault(i => i.Id == id); + if (instance == null) + { + return NotFound($"Lidarr instance with ID {id} not found"); + } + + // Remove the instance + config.Instances.Remove(instance); + await _dataContext.SaveChangesAsync(); + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete Lidarr instance with ID {Id}", id); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Api/Controllers/EventsController.cs b/code/backend/Cleanuparr.Api/Controllers/EventsController.cs new file mode 100644 index 00000000..6798d32f --- /dev/null +++ b/code/backend/Cleanuparr.Api/Controllers/EventsController.cs @@ -0,0 +1,240 @@ +using System.Text.Json.Serialization; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Events; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Cleanuparr.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class EventsController : ControllerBase +{ + private readonly EventsContext _context; + + public EventsController(EventsContext context) + { + _context = context; + } + + /// + /// Gets events with pagination and filtering + /// + [HttpGet] + public async Task>> GetEvents( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 100, + [FromQuery] string? severity = null, + [FromQuery] string? eventType = null, + [FromQuery] DateTime? fromDate = null, + [FromQuery] DateTime? toDate = null, + [FromQuery] string? search = null) + { + // Validate pagination parameters + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 100; + if (pageSize > 1000) pageSize = 1000; // Cap at 1000 for performance + + var query = _context.Events.AsQueryable(); + + // Apply filters + if (!string.IsNullOrWhiteSpace(severity)) + { + if (Enum.TryParse(severity, true, out var severityEnum)) + query = query.Where(e => e.Severity == severityEnum); + } + + if (!string.IsNullOrWhiteSpace(eventType)) + { + if (Enum.TryParse(eventType, true, out var eventTypeEnum)) + query = query.Where(e => e.EventType == eventTypeEnum); + } + + // Apply date range filters + if (fromDate.HasValue) + { + query = query.Where(e => e.Timestamp >= fromDate.Value); + } + + if (toDate.HasValue) + { + query = query.Where(e => e.Timestamp <= toDate.Value); + } + + // Apply search filter if provided + if (!string.IsNullOrWhiteSpace(search)) + { + string pattern = EventsContext.GetLikePattern(search); + query = query.Where(e => + EF.Functions.Like(e.Message, pattern) || + EF.Functions.Like(e.Data, pattern) || + EF.Functions.Like(e.TrackingId.ToString(), pattern) + ); + } + + // Count total matching records for pagination + var totalCount = await query.CountAsync(); + + // Calculate pagination + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var skip = (page - 1) * pageSize; + + // Get paginated data + var events = await query + .OrderByDescending(e => e.Timestamp) + .Skip(skip) + .Take(pageSize) + .ToListAsync(); + + events = events + .OrderBy(e => e.Timestamp) + .ToList(); + + // Return paginated result + var result = new PaginatedResult + { + Items = events, + Page = page, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = totalPages + }; + + return Ok(result); + } + + /// + /// Gets a specific event by ID + /// + [HttpGet("{id}")] + public async Task> GetEvent(Guid id) + { + var eventEntity = await _context.Events.FindAsync(id); + + if (eventEntity == null) + return NotFound(); + + return Ok(eventEntity); + } + + /// + /// Gets events by tracking ID + /// + [HttpGet("tracking/{trackingId}")] + public async Task>> GetEventsByTracking(Guid trackingId) + { + var events = await _context.Events + .Where(e => e.TrackingId == trackingId) + .OrderBy(e => e.Timestamp) + .ToListAsync(); + + return Ok(events); + } + + /// + /// Gets event statistics + /// + [HttpGet("stats")] + public async Task> GetEventStats() + { + var stats = new + { + TotalEvents = await _context.Events.CountAsync(), + EventsBySeverity = await _context.Events + .GroupBy(e => e.Severity) + .Select(g => new { Severity = g.Key.ToString(), Count = g.Count() }) + .ToListAsync(), + EventsByType = await _context.Events + .GroupBy(e => e.EventType) + .Select(g => new { EventType = g.Key.ToString(), Count = g.Count() }) + .OrderByDescending(x => x.Count) + .Take(10) + .ToListAsync(), + RecentEventsCount = await _context.Events + .Where(e => e.Timestamp > DateTime.UtcNow.AddHours(-24)) + .CountAsync() + }; + + return Ok(stats); + } + + /// + /// Manually triggers cleanup of old events + /// + [HttpPost("cleanup")] + public async Task> CleanupOldEvents([FromQuery] int retentionDays = 30) + { + var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays); + + await _context.Events + .Where(e => e.Timestamp < cutoffDate) + .ExecuteDeleteAsync(); + + return Ok(); + } + + /// + /// Gets unique event types + /// + [HttpGet("types")] + public async Task>> GetEventTypes() + { + var types = Enum.GetNames(typeof(EventType)).ToList(); + return Ok(types); + } + + /// + /// Gets unique severities + /// + [HttpGet("severities")] + public async Task>> GetSeverities() + { + var severities = Enum.GetNames(typeof(EventSeverity)).ToList(); + return Ok(severities); + } +} + +/// +/// Represents a paginated result set +/// +/// Type of items in the result +public class PaginatedResult +{ + /// + /// The items in the current page + /// + public List Items { get; set; } = new(); + + /// + /// Current page number (1-based) + /// + public int Page { get; set; } + + /// + /// Number of items per page + /// + public int PageSize { get; set; } + + /// + /// Total number of items across all pages + /// + public int TotalCount { get; set; } + + /// + /// Total number of pages + /// + public int TotalPages { get; set; } + + /// + /// Whether there is a previous page + /// + [JsonIgnore] + public bool HasPrevious => Page > 1; + + /// + /// Whether there is a next page + /// + [JsonIgnore] + public bool HasNext => Page < TotalPages; +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Api/Controllers/HealthCheckController.cs b/code/backend/Cleanuparr.Api/Controllers/HealthCheckController.cs new file mode 100644 index 00000000..5766c1c7 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Controllers/HealthCheckController.cs @@ -0,0 +1,103 @@ +using Cleanuparr.Infrastructure.Health; +using Microsoft.AspNetCore.Mvc; + +namespace Cleanuparr.Api.Controllers; + +/// +/// Controller for checking the health of download clients +/// +[ApiController] +[Route("api/health")] +public class HealthCheckController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IHealthCheckService _healthCheckService; + + /// + /// Initializes a new instance of the class + /// + public HealthCheckController( + ILogger logger, + IHealthCheckService healthCheckService) + { + _logger = logger; + _healthCheckService = healthCheckService; + } + + /// + /// Gets the health status of all download clients + /// + [HttpGet] + public IActionResult GetAllHealth() + { + try + { + var healthStatuses = _healthCheckService.GetAllClientHealth(); + return Ok(healthStatuses); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving client health statuses"); + return StatusCode(500, new { Error = "An error occurred while retrieving client health statuses" }); + } + } + + /// + /// Gets the health status of a specific download client + /// + [HttpGet("{id:guid}")] + public IActionResult GetClientHealth(Guid id) + { + try + { + var healthStatus = _healthCheckService.GetClientHealth(id); + if (healthStatus == null) + { + return NotFound(new { Message = $"Health status for client with ID '{id}' not found" }); + } + + return Ok(healthStatus); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving health status for client {id}", id); + return StatusCode(500, new { Error = "An error occurred while retrieving the client health status" }); + } + } + + /// + /// Triggers a health check for all download clients + /// + [HttpPost("check")] + public async Task CheckAllHealth() + { + try + { + var results = await _healthCheckService.CheckAllClientsHealthAsync(); + return Ok(results); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking health for all clients"); + return StatusCode(500, new { Error = "An error occurred while checking client health" }); + } + } + + /// + /// Triggers a health check for a specific download client + /// + [HttpPost("check/{id:guid}")] + public async Task CheckClientHealth(Guid id) + { + try + { + var result = await _healthCheckService.CheckClientHealthAsync(id); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking health for client {id}", id); + return StatusCode(500, new { Error = "An error occurred while checking client health" }); + } + } +} diff --git a/code/backend/Cleanuparr.Api/Controllers/JobsController.cs b/code/backend/Cleanuparr.Api/Controllers/JobsController.cs new file mode 100644 index 00000000..a509f2cd --- /dev/null +++ b/code/backend/Cleanuparr.Api/Controllers/JobsController.cs @@ -0,0 +1,163 @@ +using Cleanuparr.Api.Models; +using Cleanuparr.Infrastructure.Models; +using Infrastructure.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace Cleanuparr.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class JobsController : ControllerBase +{ + private readonly IJobManagementService _jobManagementService; + private readonly ILogger _logger; + + public JobsController(IJobManagementService jobManagementService, ILogger logger) + { + _jobManagementService = jobManagementService; + _logger = logger; + } + + [HttpGet] + public async Task GetAllJobs() + { + try + { + var result = await _jobManagementService.GetAllJobs(); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting all jobs"); + return StatusCode(500, "An error occurred while retrieving jobs"); + } + } + + [HttpGet("{jobType}")] + public async Task GetJob(JobType jobType) + { + try + { + var jobInfo = await _jobManagementService.GetJob(jobType); + + if (jobInfo.Status == "Not Found") + { + return NotFound($"Job '{jobType}' not found"); + } + return Ok(jobInfo); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting job {jobType}", jobType); + return StatusCode(500, $"An error occurred while retrieving job '{jobType}'"); + } + } + + [HttpPost("{jobType}/start")] + public async Task StartJob(JobType jobType, [FromBody] ScheduleRequest scheduleRequest = null) + { + try + { + // Get the schedule from the request body if provided + JobSchedule jobSchedule = scheduleRequest.Schedule; + + var result = await _jobManagementService.StartJob(jobType, jobSchedule); + + if (!result) + { + return BadRequest($"Failed to start job '{jobType}'"); + } + return Ok(new { Message = $"Job '{jobType}' started successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting job {jobType}", jobType); + return StatusCode(500, $"An error occurred while starting job '{jobType}'"); + } + } + + [HttpPost("{jobType}/stop")] + public async Task StopJob(JobType jobType) + { + try + { + var result = await _jobManagementService.StopJob(jobType); + + if (!result) + { + return BadRequest($"Failed to stop job '{jobType}'"); + } + return Ok(new { Message = $"Job '{jobType}' stopped successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error stopping job {jobType}", jobType); + return StatusCode(500, $"An error occurred while stopping job '{jobType}'"); + } + } + + [HttpPost("{jobType}/pause")] + public async Task PauseJob(JobType jobType) + { + try + { + var result = await _jobManagementService.PauseJob(jobType); + + if (!result) + { + return BadRequest($"Failed to pause job '{jobType}'"); + } + return Ok(new { Message = $"Job '{jobType}' paused successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error pausing job {jobType}", jobType); + return StatusCode(500, $"An error occurred while pausing job '{jobType}'"); + } + } + + [HttpPost("{jobType}/resume")] + public async Task ResumeJob(JobType jobType) + { + try + { + var result = await _jobManagementService.ResumeJob(jobType); + + if (!result) + { + return BadRequest($"Failed to resume job '{jobType}'"); + } + return Ok(new { Message = $"Job '{jobType}' resumed successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resuming job {jobType}", jobType); + return StatusCode(500, $"An error occurred while resuming job '{jobType}'"); + } + } + + [HttpPut("{jobType}/schedule")] + public async Task UpdateJobSchedule(JobType jobType, [FromBody] ScheduleRequest scheduleRequest) + { + if (scheduleRequest?.Schedule == null) + { + return BadRequest("Schedule is required"); + } + + try + { + var result = await _jobManagementService.UpdateJobSchedule(jobType, scheduleRequest.Schedule); + + if (!result) + { + return BadRequest($"Failed to update schedule for job '{jobType}'"); + } + return Ok(new { Message = $"Job '{jobType}' schedule updated successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating job {jobType} schedule", jobType); + return StatusCode(500, $"An error occurred while updating schedule for job '{jobType}'"); + } + } +} diff --git a/code/backend/Cleanuparr.Api/Controllers/StatusController.cs b/code/backend/Cleanuparr.Api/Controllers/StatusController.cs new file mode 100644 index 00000000..5f93ef9b --- /dev/null +++ b/code/backend/Cleanuparr.Api/Controllers/StatusController.cs @@ -0,0 +1,270 @@ +using System.Diagnostics; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Arr; +using Cleanuparr.Infrastructure.Features.DownloadClient; +using Cleanuparr.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Cleanuparr.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class StatusController : ControllerBase +{ + private readonly ILogger _logger; + private readonly DataContext _dataContext; + private readonly DownloadServiceFactory _downloadServiceFactory; + private readonly ArrClientFactory _arrClientFactory; + + public StatusController( + ILogger logger, + DataContext dataContext, + DownloadServiceFactory downloadServiceFactory, + ArrClientFactory arrClientFactory) + { + _logger = logger; + _dataContext = dataContext; + _downloadServiceFactory = downloadServiceFactory; + _arrClientFactory = arrClientFactory; + } + + [HttpGet] + public async Task GetSystemStatus() + { + try + { + var process = Process.GetCurrentProcess(); + + // Get configuration + var downloadClients = await _dataContext.DownloadClients + .AsNoTracking() + .ToListAsync(); + var sonarrConfig = await _dataContext.ArrConfigs + .Include(x => x.Instances) + .AsNoTracking() + .FirstAsync(x => x.Type == InstanceType.Sonarr); + var radarrConfig = await _dataContext.ArrConfigs + .Include(x => x.Instances) + .AsNoTracking() + .FirstAsync(x => x.Type == InstanceType.Radarr); + var lidarrConfig = await _dataContext.ArrConfigs + .Include(x => x.Instances) + .AsNoTracking() + .FirstAsync(x => x.Type == InstanceType.Lidarr); + + var status = new + { + Application = new + { + Version = GetType().Assembly.GetName().Version?.ToString() ?? "Unknown", + process.StartTime, + UpTime = DateTime.Now - process.StartTime, + MemoryUsageMB = Math.Round(process.WorkingSet64 / 1024.0 / 1024.0, 2), + ProcessorTime = process.TotalProcessorTime + }, + DownloadClient = new + { + // TODO + }, + MediaManagers = new + { + Sonarr = new + { + InstanceCount = sonarrConfig.Instances.Count + }, + Radarr = new + { + InstanceCount = radarrConfig.Instances.Count + }, + Lidarr = new + { + InstanceCount = lidarrConfig.Instances.Count + } + } + }; + + return Ok(status); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving system status"); + return StatusCode(500, "An error occurred while retrieving system status"); + } + } + + [HttpGet("download-client")] + public async Task GetDownloadClientStatus() + { + try + { + var downloadClients = await _dataContext.DownloadClients + .AsNoTracking() + .ToListAsync(); + var result = new Dictionary(); + + // Check for configured clients + if (downloadClients.Count > 0) + { + var clientsStatus = new List(); + foreach (var client in downloadClients) + { + clientsStatus.Add(new + { + client.Id, + client.Name, + Type = client.TypeName, + client.Host, + client.Enabled, + IsConnected = client.Enabled, // We can't check connection status without implementing test methods + }); + } + + result["Clients"] = clientsStatus; + } + + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving download client status"); + return StatusCode(500, "An error occurred while retrieving download client status"); + } + } + + [HttpGet("arrs")] + public async Task GetMediaManagersStatus() + { + try + { + var status = new Dictionary(); + + // Get configurations + var enabledSonarrInstances = await _dataContext.ArrConfigs + .Include(x => x.Instances) + .Where(x => x.Type == InstanceType.Sonarr) + .SelectMany(x => x.Instances) + .Where(x => x.Enabled) + .AsNoTracking() + .ToListAsync(); + var enabledRadarrInstances = await _dataContext.ArrConfigs + .Include(x => x.Instances) + .Where(x => x.Type == InstanceType.Radarr) + .SelectMany(x => x.Instances) + .Where(x => x.Enabled) + .AsNoTracking() + .ToListAsync(); + var enabledLidarrInstances = await _dataContext.ArrConfigs + .Include(x => x.Instances) + .Where(x => x.Type == InstanceType.Lidarr) + .SelectMany(x => x.Instances) + .Where(x => x.Enabled) + .AsNoTracking() + .ToListAsync();; + + + // Check Sonarr instances + var sonarrStatus = new List(); + + foreach (var instance in enabledSonarrInstances) + { + try + { + var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr); + await sonarrClient.TestConnectionAsync(instance); + + sonarrStatus.Add(new + { + instance.Name, + instance.Url, + IsConnected = true, + Message = "Successfully connected" + }); + } + catch (Exception ex) + { + sonarrStatus.Add(new + { + instance.Name, + instance.Url, + IsConnected = false, + Message = $"Connection failed: {ex.Message}" + }); + } + } + + status["Sonarr"] = sonarrStatus; + + // Check Radarr instances + var radarrStatus = new List(); + + foreach (var instance in enabledRadarrInstances) + { + try + { + var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr); + await radarrClient.TestConnectionAsync(instance); + + radarrStatus.Add(new + { + instance.Name, + instance.Url, + IsConnected = true, + Message = "Successfully connected" + }); + } + catch (Exception ex) + { + radarrStatus.Add(new + { + instance.Name, + instance.Url, + IsConnected = false, + Message = $"Connection failed: {ex.Message}" + }); + } + } + + status["Radarr"] = radarrStatus; + + // Check Lidarr instances + var lidarrStatus = new List(); + + foreach (var instance in enabledLidarrInstances) + { + try + { + var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr); + await lidarrClient.TestConnectionAsync(instance); + + lidarrStatus.Add(new + { + instance.Name, + instance.Url, + IsConnected = true, + Message = "Successfully connected" + }); + } + catch (Exception ex) + { + lidarrStatus.Add(new + { + instance.Name, + instance.Url, + IsConnected = false, + Message = $"Connection failed: {ex.Message}" + }); + } + } + + status["Lidarr"] = lidarrStatus; + + return Ok(status); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving media managers status"); + return StatusCode(500, "An error occurred while retrieving media managers status"); + } + } +} diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/ApiDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/ApiDI.cs new file mode 100644 index 00000000..2829d924 --- /dev/null +++ b/code/backend/Cleanuparr.Api/DependencyInjection/ApiDI.cs @@ -0,0 +1,148 @@ +using System.Text.Json.Serialization; +using Cleanuparr.Api.Middleware; +using Cleanuparr.Infrastructure.Health; +using Cleanuparr.Infrastructure.Hubs; +using Cleanuparr.Infrastructure.Logging; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.OpenApi.Models; +using System.Text; + +namespace Cleanuparr.Api.DependencyInjection; + +public static class ApiDI +{ + public static IServiceCollection AddApiServices(this IServiceCollection services) + { + services.Configure(options => + { + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + }); + + // Add API-specific services + services + .AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + }); + services.AddEndpointsApiExplorer(); + + // Add SignalR for real-time updates + services + .AddSignalR() + .AddJsonProtocol(options => + { + options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); + + // Add health status broadcaster + services.AddHostedService(); + + // Add logging initializer service + services.AddHostedService(); + + 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; + } + + public static WebApplication ConfigureApi(this WebApplication app) + { + ILogger logger = app.Services.GetRequiredService>(); + + // Enable compression + app.UseResponseCompression(); + + // Serve static files with caching + app.UseStaticFiles(new StaticFileOptions + { + OnPrepareResponse = ctx => + { + // Cache static assets for 30 days + // if (ctx.File.Name.EndsWith(".js") || ctx.File.Name.EndsWith(".css")) + // { + // ctx.Context.Response.Headers.CacheControl = "public,max-age=2592000"; + // } + } + }); + + // Add the global exception handling middleware first + app.UseMiddleware(); + + 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(); + + // Custom SPA fallback to inject base path + app.MapFallback(async context => + { + var basePath = app.Configuration.GetValue("BASE_PATH") ?? "/"; + + // Normalize the base path (remove trailing slash if not root) + if (basePath != "/" && basePath.EndsWith("/")) + { + basePath = basePath.TrimEnd('/'); + } + + var webRoot = app.Environment.WebRootPath ?? Path.Combine(app.Environment.ContentRootPath, "wwwroot"); + var indexPath = Path.Combine(webRoot, "index.html"); + + if (!File.Exists(indexPath)) + { + context.Response.StatusCode = 404; + await context.Response.WriteAsync("index.html not found"); + return; + } + + var indexContent = await File.ReadAllTextAsync(indexPath); + + // Inject the base path into the HTML + var scriptInjection = $@" + "; + + // Insert the script right before the existing script tag + indexContent = indexContent.Replace( + " + + + + + + + + + + + + + diff --git a/code/frontend/src/main.ts b/code/frontend/src/main.ts new file mode 100644 index 00000000..1aec46cc --- /dev/null +++ b/code/frontend/src/main.ts @@ -0,0 +1,22 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; +import { APP_BASE_HREF } from '@angular/common'; + +async function bootstrap() { + const basePath = (window as any)['_app_base'] || '/'; + + const app = await bootstrapApplication(AppComponent, { + providers: [ + { + provide: APP_BASE_HREF, + useValue: basePath + }, + ...appConfig.providers + ] + }); + + return app; +} + +bootstrap().catch(err => console.error(err)); diff --git a/code/frontend/src/styles.scss b/code/frontend/src/styles.scss new file mode 100644 index 00000000..244401e7 --- /dev/null +++ b/code/frontend/src/styles.scss @@ -0,0 +1,478 @@ +/* Global styles and PrimeNG theme setup */ + +/* Import PrimeNG Theme (Lara Dark with Purple accent) */ +@use "primeicons/primeicons.css"; +@use "primeflex/primeflex.css"; + +/* Global Variables */ +:root { + /* Base application variables */ + --app-font-family: Poppins, sans-serif; + --app-primary: var(--primary-color); + --app-card-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.12); + --app-content-padding: 1.5rem; + --app-border-radius: 6px; + --app-transition-speed: 0.3s; + + /* Purple Theme Colors - Default values that will be overridden by ThemeService */ + --primary-color: #7E57C2; + --primary-color-text: #ffffff; + --primary-dark: #5E35B1; + --primary-light: #B39DDB; + + /* Dark theme base colors */ + --surface-ground: #121212; + --surface-section: #1E1E1E; + --surface-card: #262626; + --surface-overlay: #2A2A2A; + --surface-border: #383838; + + --text-color: #F5F5F5; + --text-color-secondary: #BDBDBD; + --text-color-disabled: #757575; + + /* App-specific accent colors */ + --accent-purple-50: #EDE7F6; + --accent-purple-100: #D1C4E9; + --accent-purple-200: #B39DDB; + --accent-purple-300: #9575CD; + --accent-purple-400: #7E57C2; /* Main accent */ + --accent-purple-500: #673AB7; + --accent-purple-600: #5E35B1; + --accent-purple-700: #512DA8; + --accent-purple-800: #4527A0; + --accent-purple-900: #311B92; + + /* Standard color palette for logs and events */ + --yellow-50: #fffde7; + --yellow-100: #fff9c4; + --yellow-200: #fff59d; + --yellow-300: #fff176; + --yellow-400: #ffee58; + --yellow-500: #ffeb3b; + --yellow-600: #fdd835; + --yellow-700: #fbc02d; + --yellow-800: #f9a825; + --yellow-900: #f57f17; + + --blue-50: #e3f2fd; + --blue-100: #bbdefb; + --blue-200: #90caf9; + --blue-300: #64b5f6; + --blue-400: #42a5f5; + --blue-500: #2196f3; + --blue-600: #1e88e5; + --blue-700: #1976d2; + --blue-800: #1565c0; + --blue-900: #0d47a1; + + --red-50: #ffebee; + --red-100: #ffcdd2; + --red-200: #ef9a9a; + --red-300: #e57373; + --red-400: #ef5350; + --red-500: #f44336; + --red-600: #e53935; + --red-700: #d32f2f; + --red-800: #c62828; + --red-900: #b71c1c; + + --orange-50: #fff3e0; + --orange-100: #ffe0b2; + --orange-200: #ffcc80; + --orange-300: #ffb74d; + --orange-400: #ffa726; + --orange-500: #ff9800; + --orange-600: #fb8c00; + --orange-700: #f57c00; + --orange-800: #ef6c00; + --orange-900: #e65100; + + --gray-50: #fafafa; + --gray-100: #f5f5f5; + --gray-200: #eeeeee; + --gray-300: #e0e0e0; + --gray-400: #bdbdbd; + --gray-500: #9e9e9e; + --gray-600: #757575; + --gray-700: #616161; + --gray-800: #424242; + --gray-900: #212121; +} + +/* Base Styles */ +html { + font-size: 14px; +} + +body { + font-family: var(--app-font-family); + margin: 0; + padding: 0; + background-color: var(--surface-ground); + color: var(--text-color); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Typography */ +h1, h2, h3, h4, h5, h6 { + margin-top: 0; + margin-bottom: 0.5rem; + font-weight: 600; + line-height: 1.2; +} + +h1 { + font-size: 2rem; +} + +h2 { + font-size: 1.5rem; +} + +h3 { + font-size: 1.25rem; +} + +a { + color: var(--primary-color); + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.grid-container { + margin-bottom: 1rem; +} + +/* PrimeNG Component Customizations */ + +/* Card styles */ +.p-card { + border-radius: var(--border-radius); + box-shadow: var(--app-card-shadow) !important; + transition: transform var(--app-transition-speed), box-shadow var(--app-transition-speed); + + &:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1) !important; + } + + .p-card-title { + font-size: 1.25rem; + font-weight: 600; + } + + .p-card-subtitle { + font-weight: 400; + color: var(--text-color-secondary); + margin-bottom: 1rem; + } + + .p-card-content { + padding: 0.5rem 0; + } + + .p-card-footer { + padding-top: 1rem; + display: flex; + gap: 0.5rem; + } +} + +/* Button styling */ +.p-button { + border-radius: var(--border-radius); + transition: background-color 0.2s, color 0.2s, border-color 0.2s, box-shadow 0.2s; + + &:focus { + box-shadow: 0 0 0 2px var(--surface-ground), 0 0 0 4px var(--primary-color), 0 1px 2px rgba(0, 0, 0, 0.2); + } + + &.p-button-outlined { + background-color: transparent; + + &:hover { + background-color: var(--primary-50); + } + } + + &.p-button-text { + background-color: transparent; + color: var(--primary-color); + border-color: transparent; + + &:hover { + background-color: var(--surface-hover); + color: var(--primary-600); + } + } +} + +/* Form elements */ +.form-field { + margin-bottom: 1.5rem; + + label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + } +} + +.field-row { + margin-bottom: 1.5rem; + display: flex; + align-items: center; + gap: 1rem; + + label { + min-width: 200px; + font-weight: 500; + } +} + +/* Responsive adjustments */ +@media screen and (max-width: 768px) { + html { + font-size: 13px; + } + + .p-card .p-card-content { + padding: 0.25rem 0; + } + + .field-row { + flex-direction: column; + align-items: flex-start; + + label { + margin-bottom: 0.5rem; + } + } + + .p-dropdown { + width: 100%; + } +} + +/* PrimeNG Sidebar Override */ +.p-sidebar.mobile-sidebar { + background-color: #1a1e27 !important; + border: none !important; + + .p-sidebar-header { + display: none !important; + } + + .p-sidebar-content { + padding: 0 !important; + background-color: #1a1e27 !important; + color: #ffffff !important; + + .sidebar-header { + padding: 1.5rem 1rem; + display: flex; + align-items: center; + justify-content: center; + + .sidebar-logo { + display: flex; + align-items: center; + + .sidebar-icon { + font-size: 1.5rem; + color: #ffcc00; + } + + .app-name { + font-size: 1.25rem; + font-weight: 700; + margin-left: 0.5rem; + color: #ffffff; + } + } + } + + .sidebar-nav { + padding: 1rem 0; + overflow-y: auto; + flex: 1; + + .sidebar-section-title { + padding: 0 1rem; + margin: 0 0 0.5rem 0; + font-size: 0.75rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); + letter-spacing: 0.5px; + } + + ul { + list-style: none; + padding: 0; + margin: 0; + + li { + a { + display: flex; + align-items: center; + padding: 0.75rem 1rem; + color: rgba(255, 255, 255, 0.8); + text-decoration: none; + transition: background-color 0.2s; + + &:hover { + background-color: rgba(255, 255, 255, 0.05); + } + + i { + font-size: 1.25rem; + width: 1.5rem; + margin-right: 0.75rem; + text-align: center; + } + + span { + font-size: 0.95rem; + } + } + + &.active a { + background-color: rgba(255, 255, 255, 0.1); + color: #ffffff; + font-weight: 600; + border-left: 3px solid #4299e1; + padding-left: calc(1rem - 3px); + + i { + color: #4299e1; + } + } + } + } + } + + .sponsor-section { + padding: 0.5rem 1rem 1rem; + margin-top: auto; + + .sponsor-link { + display: flex; + align-items: center; + padding: 0.75rem 1rem; + background-color: rgba(255, 255, 255, 0.05); + border-radius: 4px; + color: #ffffff; + text-decoration: none; + transition: background-color 0.2s; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + + i { + color: #ff5a5f; + margin-right: 0.75rem; + } + } + } + } +} + +/* Dark purple theme overrides */ +.dark-mode { + /* Button styles */ + .p-button { + &.p-button-primary { + background-color: var(--primary-color); + border-color: var(--primary-color); + + &:hover { + background-color: var(--primary-dark); + border-color: var(--primary-dark); + } + } + + &.p-button-outlined { + border-color: var(--primary-color); + color: var(--primary-color); + + &:hover { + background-color: rgba(126, 87, 194, 0.1); + } + } + + &.p-button-text { + color: var(--primary-color); + + &:hover { + background-color: rgba(126, 87, 194, 0.1); + } + } + } + + .p-card { + box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.2) !important; + + &:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3) !important; + } + } + + /* Selection and focus states */ + .p-checkbox .p-checkbox-box.p-highlight, + .p-radiobutton .p-radiobutton-box.p-highlight { + background-color: var(--primary-color); + border-color: var(--primary-color); + } + + /* Accent color for various components */ + .p-dropdown-panel .p-dropdown-items .p-dropdown-item.p-highlight, + .p-multiselect-panel .p-multiselect-items .p-multiselect-item.p-highlight, + .p-listbox .p-listbox-list .p-listbox-item.p-highlight, + .p-dropdown-panel .p-dropdown-items .p-dropdown-item:focus, + .p-multiselect-panel .p-multiselect-items .p-multiselect-item:focus, + .p-listbox .p-listbox-list .p-listbox-item:focus { + background-color: rgba(126, 87, 194, 0.16); + color: var(--primary-color); + } + + /* Navigation elements */ + .p-tabview .p-tabview-nav li.p-highlight .p-tabview-nav-link { + border-color: var(--primary-color); + color: var(--primary-color); + } + + /* Focus and selection ring */ + .p-component:focus, + .p-inputtext:focus { + box-shadow: 0 0 0 1px var(--primary-light); + border-color: var(--primary-color); + } + + /* Links */ + a:not(.p-button) { + color: var(--primary-light); + + &:hover { + color: var(--primary-color); + } + } + + /* Sidebar and navigation */ + .sidebar { + .sidebar-nav { + li.active { + background-color: rgba(126, 87, 194, 0.16); + border-left-color: var(--primary-color); + + a { + color: var(--primary-color); + } + } + } + } +} diff --git a/code/frontend/tsconfig.app.json b/code/frontend/tsconfig.app.json new file mode 100644 index 00000000..3775b37e --- /dev/null +++ b/code/frontend/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/code/frontend/tsconfig.json b/code/frontend/tsconfig.json new file mode 100644 index 00000000..5525117c --- /dev/null +++ b/code/frontend/tsconfig.json @@ -0,0 +1,27 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/code/frontend/tsconfig.spec.json b/code/frontend/tsconfig.spec.json new file mode 100644 index 00000000..5fb748d9 --- /dev/null +++ b/code/frontend/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/code/test/data/cleanuperr/config/music_whitelist b/code/test/data/cleanuperr/config/music_whitelist deleted file mode 100644 index bf9e782d..00000000 --- a/code/test/data/cleanuperr/config/music_whitelist +++ /dev/null @@ -1 +0,0 @@ -*.mp3 \ No newline at end of file diff --git a/code/test/data/cleanuperr/config/video_blacklist b/code/test/data/cleanuperr/config/video_blacklist deleted file mode 100644 index 8e5f9f4f..00000000 --- a/code/test/data/cleanuperr/config/video_blacklist +++ /dev/null @@ -1,2 +0,0 @@ -.*sample.* -*.zipx \ No newline at end of file diff --git a/code/test/data/cleanuperr/config/video_whitelist b/code/test/data/cleanuperr/config/video_whitelist deleted file mode 100644 index 71b0ffab..00000000 --- a/code/test/data/cleanuperr/config/video_whitelist +++ /dev/null @@ -1 +0,0 @@ -*.mkv \ No newline at end of file diff --git a/code/test/data/cleanuperr/ignored_downloads b/code/test/data/cleanuperr/ignored_downloads deleted file mode 100644 index 5537770d..00000000 --- a/code/test/data/cleanuperr/ignored_downloads +++ /dev/null @@ -1 +0,0 @@ -ignored \ No newline at end of file diff --git a/code/test/data/deluge/config/archive/state-2024-11-14T13-53-18.tar.xz b/code/test/data/deluge/config/archive/state-2024-11-14T13-53-18.tar.xz deleted file mode 100644 index e70043e7..00000000 Binary files a/code/test/data/deluge/config/archive/state-2024-11-14T13-53-18.tar.xz and /dev/null differ diff --git a/code/test/data/deluge/config/auth b/code/test/data/deluge/config/auth deleted file mode 100644 index 5e343ee3..00000000 --- a/code/test/data/deluge/config/auth +++ /dev/null @@ -1 +0,0 @@ -localclient:da4d4b43be734d48c1bb8b9ab0e39894520994e3:10 diff --git a/code/test/data/deluge/config/blocklist.conf b/code/test/data/deluge/config/blocklist.conf deleted file mode 100644 index 03e11cc4..00000000 --- a/code/test/data/deluge/config/blocklist.conf +++ /dev/null @@ -1,15 +0,0 @@ -{ - "file": 1, - "format": 1 -}{ - "check_after_days": 4, - "last_update": 0.0, - "list_compression": "", - "list_size": 0, - "list_type": "", - "load_on_start": false, - "timeout": 180, - "try_times": 3, - "url": "", - "whitelisted": [] -} \ No newline at end of file diff --git a/code/test/data/deluge/config/core.conf b/code/test/data/deluge/config/core.conf deleted file mode 100644 index c6992c93..00000000 --- a/code/test/data/deluge/config/core.conf +++ /dev/null @@ -1,97 +0,0 @@ -{ - "file": 1, - "format": 1 -}{ - "add_paused": false, - "allow_remote": false, - "auto_manage_prefer_seeds": false, - "auto_managed": true, - "cache_expiry": 60, - "cache_size": 512, - "copy_torrent_file": false, - "daemon_port": 58846, - "del_copy_torrent_file": false, - "dht": true, - "dont_count_slow_torrents": false, - "download_location": "/downloads", - "download_location_paths_list": [], - "enabled_plugins": [ - "Label" - ], - "enc_in_policy": 1, - "enc_level": 2, - "enc_out_policy": 1, - "geoip_db_location": "/usr/share/GeoIP/GeoIP.dat", - "ignore_limits_on_local_network": true, - "info_sent": 0.0, - "listen_interface": "", - "listen_ports": [ - 6882, - 6882 - ], - "listen_random_port": null, - "listen_reuse_port": true, - "listen_use_sys_port": false, - "lsd": true, - "max_active_downloading": 3, - "max_active_limit": 8, - "max_active_seeding": 5, - "max_connections_global": 200, - "max_connections_per_second": 20, - "max_connections_per_torrent": -1, - "max_download_speed": -1.0, - "max_download_speed_per_torrent": -1, - "max_half_open_connections": 50, - "max_upload_slots_global": 4, - "max_upload_slots_per_torrent": -1, - "max_upload_speed": -1.0, - "max_upload_speed_per_torrent": -1, - "move_completed": false, - "move_completed_path": "/downloads", - "move_completed_paths_list": [], - "natpmp": true, - "new_release_check": true, - "outgoing_interface": "", - "outgoing_ports": [ - 0, - 0 - ], - "path_chooser_accelerator_string": "Tab", - "path_chooser_auto_complete_enabled": true, - "path_chooser_max_popup_rows": 20, - "path_chooser_show_chooser_button_on_localhost": true, - "path_chooser_show_hidden_files": false, - "peer_tos": "0x00", - "plugins_location": "/config/plugins", - "pre_allocate_storage": false, - "prioritize_first_last_pieces": false, - "proxy": { - "anonymous_mode": false, - "force_proxy": false, - "hostname": "", - "password": "", - "port": 8080, - "proxy_hostnames": true, - "proxy_peer_connections": true, - "proxy_tracker_connections": true, - "type": 0, - "username": "" - }, - "queue_new_to_top": false, - "random_outgoing_ports": true, - "random_port": false, - "rate_limit_ip_overhead": false, - "remove_seed_at_ratio": false, - "seed_time_limit": 180, - "seed_time_ratio_limit": 7.0, - "send_info": false, - "sequential_download": false, - "share_ratio_limit": 2.0, - "shared": false, - "stop_seed_at_ratio": false, - "stop_seed_ratio": 2.0, - "super_seeding": false, - "torrentfiles_location": "/config/torrents", - "upnp": true, - "utpex": true -} \ No newline at end of file diff --git a/code/test/data/deluge/config/hostlist.conf b/code/test/data/deluge/config/hostlist.conf deleted file mode 100644 index 07bea729..00000000 --- a/code/test/data/deluge/config/hostlist.conf +++ /dev/null @@ -1,14 +0,0 @@ -{ - "file": 3, - "format": 1 -}{ - "hosts": [ - [ - "b5408e9794dd432789c55d8c46d15275", - "127.0.0.1", - 58846, - "localclient", - "da4d4b43be734d48c1bb8b9ab0e39894520994e3" - ] - ] -} \ No newline at end of file diff --git a/code/test/data/deluge/config/label.conf b/code/test/data/deluge/config/label.conf deleted file mode 100644 index 4c2a8226..00000000 --- a/code/test/data/deluge/config/label.conf +++ /dev/null @@ -1,69 +0,0 @@ -{ - "file": 1, - "format": 1 -}{ - "labels": { - "lidarr": { - "apply_max": false, - "apply_move_completed": false, - "apply_queue": false, - "auto_add": false, - "auto_add_trackers": [], - "is_auto_managed": false, - "max_connections": -1, - "max_download_speed": -1, - "max_upload_slots": -1, - "max_upload_speed": -1, - "move_completed": false, - "move_completed_path": "", - "prioritize_first_last": false, - "remove_at_ratio": false, - "stop_at_ratio": false, - "stop_ratio": 2.0 - }, - "radarr": { - "apply_max": false, - "apply_move_completed": false, - "apply_queue": false, - "auto_add": false, - "auto_add_trackers": [], - "is_auto_managed": false, - "max_connections": -1, - "max_download_speed": -1, - "max_upload_slots": -1, - "max_upload_speed": -1, - "move_completed": false, - "move_completed_path": "", - "prioritize_first_last": false, - "remove_at_ratio": false, - "stop_at_ratio": false, - "stop_ratio": 2.0 - }, - "tv-sonarr": { - "apply_max": false, - "apply_move_completed": false, - "apply_queue": false, - "auto_add": false, - "auto_add_trackers": [], - "is_auto_managed": false, - "max_connections": -1, - "max_download_speed": -1, - "max_upload_slots": -1, - "max_upload_speed": -1, - "move_completed": false, - "move_completed_path": "", - "prioritize_first_last": false, - "remove_at_ratio": false, - "stop_at_ratio": false, - "stop_ratio": 2.0 - } - }, - "torrent_labels": { - "2b2ec156461d77bc48b8fe4d62cede50dcdff8e0": "radarr", - "59ab2bc053430fe53e06a93e2eadb7acb6a6bf2c": "tv-sonarr", - "5a31d5f1689f5f45fd85c275a37acd2c7b82fde1": "tv-sonarr", - "6c890ff85b5317d5df291c3c23a782774e10e6fe": "radarr", - "a4a1d1dd1db25763caa8f5e4d25ad72ef304094b": "radarr", - "b72541215214be2a1d96ef6b29ca1305f5e5e1f6": "tv-sonarr" - } -} \ No newline at end of file diff --git a/code/test/data/deluge/config/plugins/.python-eggs/Blocklist-1.4-py3.12.egg-tmp/deluge_blocklist/data/blocklist.js b/code/test/data/deluge/config/plugins/.python-eggs/Blocklist-1.4-py3.12.egg-tmp/deluge_blocklist/data/blocklist.js deleted file mode 100644 index 3c10b81b..00000000 --- a/code/test/data/deluge/config/plugins/.python-eggs/Blocklist-1.4-py3.12.egg-tmp/deluge_blocklist/data/blocklist.js +++ /dev/null @@ -1,429 +0,0 @@ -/** - * blocklist.js - * - * Copyright (C) Omar Alvarez 2014 - * - * This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with - * the additional special exception to link portions of this program with the OpenSSL library. - * See LICENSE for more details. - * - */ - -Ext.ns('Deluge.ux.preferences'); - -/** - * @class Deluge.ux.preferences.BlocklistPage - * @extends Ext.Panel - */ -Deluge.ux.preferences.BlocklistPage = Ext.extend(Ext.Panel, { - title: _('Blocklist'), - header: false, - layout: 'fit', - border: false, - autoScroll: true, - - initComponent: function () { - Deluge.ux.preferences.BlocklistPage.superclass.initComponent.call(this); - - this.URLFset = this.add({ - xtype: 'fieldset', - border: false, - title: _('General'), - autoHeight: true, - defaultType: 'textfield', - style: 'margin-top: 3px; margin-bottom: 0px; padding-bottom: 0px;', - autoWidth: true, - labelWidth: 40, - }); - - this.URL = this.URLFset.add({ - fieldLabel: _('URL:'), - labelSeparator: '', - name: 'url', - width: '80%', - }); - - this.SettingsFset = this.add({ - xtype: 'fieldset', - border: false, - title: _('Settings'), - autoHeight: true, - defaultType: 'spinnerfield', - style: 'margin-top: 3px; margin-bottom: 0px; padding-bottom: 0px;', - autoWidth: true, - labelWidth: 160, - }); - - this.checkListDays = this.SettingsFset.add({ - fieldLabel: _('Check for new list every (days):'), - labelSeparator: '', - name: 'check_list_days', - value: 4, - decimalPrecision: 0, - width: 80, - }); - - this.chkImportOnStart = this.SettingsFset.add({ - xtype: 'checkbox', - fieldLabel: _('Import blocklist on startup'), - name: 'check_import_startup', - }); - - this.OptionsFset = this.add({ - xtype: 'fieldset', - border: false, - title: _('Options'), - autoHeight: true, - defaultType: 'button', - style: 'margin-top: 3px; margin-bottom: 0px; padding-bottom: 0px;', - autoWidth: false, - width: '80%', - labelWidth: 0, - }); - - this.checkDownload = this.OptionsFset.add({ - fieldLabel: _(''), - name: 'check_download', - xtype: 'container', - layout: 'hbox', - margins: '4 0 0 5', - items: [ - { - xtype: 'button', - text: ' Check Download and Import ', - scale: 'medium', - }, - { - xtype: 'box', - autoEl: { - tag: 'img', - src: '../icons/ok.png', - }, - margins: '4 0 0 3', - }, - ], - }); - - this.forceDownload = this.OptionsFset.add({ - fieldLabel: _(''), - name: 'force_download', - text: ' Force Download and Import ', - margins: '2 0 0 0', - //icon: '../icons/blocklist_import24.png', - scale: 'medium', - }); - - this.ProgressFset = this.add({ - xtype: 'fieldset', - border: false, - title: _('Info'), - autoHeight: true, - defaultType: 'progress', - style: 'margin-top: 1px; margin-bottom: 0px; padding-bottom: 0px;', - autoWidth: true, - labelWidth: 0, - hidden: true, - }); - - this.downProgBar = this.ProgressFset.add({ - fieldLabel: _(''), - name: 'progress_bar', - width: '90%', - }); - - this.InfoFset = this.add({ - xtype: 'fieldset', - border: false, - title: _('Info'), - autoHeight: true, - defaultType: 'label', - style: 'margin-top: 0px; margin-bottom: 0px; padding-bottom: 0px;', - labelWidth: 60, - }); - - this.lblFileSize = this.InfoFset.add({ - fieldLabel: _('File Size:'), - labelSeparator: '', - name: 'file_size', - }); - - this.lblDate = this.InfoFset.add({ - fieldLabel: _('Date:'), - labelSeparator: '', - name: 'date', - }); - - this.lblType = this.InfoFset.add({ - fieldLabel: _('Type:'), - labelSeparator: '', - name: 'type', - }); - - this.lblURL = this.InfoFset.add({ - fieldLabel: _('URL:'), - labelSeparator: '', - name: 'lbl_URL', - }); - - this.WhitelistFset = this.add({ - xtype: 'fieldset', - border: false, - title: _('Whitelist'), - autoHeight: true, - defaultType: 'editorgrid', - style: 'margin-top: 3px; margin-bottom: 0px; padding-bottom: 0px;', - autoWidth: true, - labelWidth: 0, - items: [ - { - fieldLabel: _(''), - name: 'whitelist', - margins: '2 0 5 5', - height: 100, - width: 260, - autoExpandColumn: 'ip', - viewConfig: { - emptyText: _('Add an IP...'), - deferEmptyText: false, - }, - colModel: new Ext.grid.ColumnModel({ - columns: [ - { - id: 'ip', - header: _('IP'), - dataIndex: 'ip', - sortable: true, - hideable: false, - editable: true, - editor: { - xtype: 'textfield', - }, - }, - ], - }), - selModel: new Ext.grid.RowSelectionModel({ - singleSelect: false, - moveEditorOnEnter: false, - }), - store: new Ext.data.ArrayStore({ - autoDestroy: true, - fields: [{ name: 'ip' }], - }), - listeners: { - afteredit: function (e) { - e.record.commit(); - }, - }, - setEmptyText: function (text) { - if (this.viewReady) { - this.getView().emptyText = text; - this.getView().refresh(); - } else { - Ext.apply(this.viewConfig, { emptyText: text }); - } - }, - loadData: function (data) { - this.getStore().loadData(data); - if (this.viewReady) { - this.getView().updateHeaders(); - } - }, - }, - ], - }); - - this.ipButtonsContainer = this.WhitelistFset.add({ - xtype: 'container', - layout: 'hbox', - margins: '4 0 0 5', - items: [ - { - xtype: 'button', - text: ' Add IP ', - margins: '0 5 0 0', - }, - { - xtype: 'button', - text: ' Delete IP ', - }, - ], - }); - - this.updateTask = Ext.TaskMgr.start({ - interval: 2000, - run: this.onUpdate, - scope: this, - }); - - this.on('show', this.updateConfig, this); - - this.ipButtonsContainer.getComponent(0).setHandler(this.addIP, this); - this.ipButtonsContainer.getComponent(1).setHandler(this.deleteIP, this); - - this.checkDownload.getComponent(0).setHandler(this.checkDown, this); - this.forceDownload.setHandler(this.forceDown, this); - }, - - onApply: function () { - var config = {}; - - config['url'] = this.URL.getValue(); - config['check_after_days'] = this.checkListDays.getValue(); - config['load_on_start'] = this.chkImportOnStart.getValue(); - - var ipList = []; - var store = this.WhitelistFset.getComponent(0).getStore(); - - for (var i = 0; i < store.getCount(); i++) { - var record = store.getAt(i); - var ip = record.get('ip'); - ipList.push(ip); - } - - config['whitelisted'] = ipList; - - deluge.client.blocklist.set_config(config); - }, - - onOk: function () { - this.onApply(); - }, - - onUpdate: function () { - deluge.client.blocklist.get_status({ - success: function (status) { - if (status['state'] == 'Downloading') { - this.InfoFset.hide(); - this.checkDownload.getComponent(0).setDisabled(true); - this.checkDownload.getComponent(1).hide(); - this.forceDownload.setDisabled(true); - - this.ProgressFset.show(); - this.downProgBar.updateProgress( - status['file_progress'], - 'Downloading ' - .concat((status['file_progress'] * 100).toFixed(2)) - .concat('%'), - true - ); - } else if (status['state'] == 'Importing') { - this.InfoFset.hide(); - this.checkDownload.getComponent(0).setDisabled(true); - this.checkDownload.getComponent(1).hide(); - this.forceDownload.setDisabled(true); - - this.ProgressFset.show(); - this.downProgBar.updateText( - 'Importing '.concat(status['num_blocked']) - ); - } else if (status['state'] == 'Idle') { - this.ProgressFset.hide(); - this.checkDownload.getComponent(0).setDisabled(false); - this.forceDownload.setDisabled(false); - if (status['up_to_date']) { - this.checkDownload.getComponent(1).show(); - this.checkDownload.doLayout(); - } else { - this.checkDownload.getComponent(1).hide(); - } - this.InfoFset.show(); - this.lblFileSize.setText(fsize(status['file_size'])); - this.lblDate.setText(fdate(status['file_date'])); - this.lblType.setText(status['file_type']); - this.lblURL.setText( - status['file_url'].substr(0, 40).concat('...') - ); - } - }, - scope: this, - }); - }, - - checkDown: function () { - this.onApply(); - deluge.client.blocklist.check_import(); - }, - - forceDown: function () { - this.onApply(); - deluge.client.blocklist.check_import((force = true)); - }, - - updateConfig: function () { - deluge.client.blocklist.get_config({ - success: function (config) { - this.URL.setValue(config['url']); - this.checkListDays.setValue(config['check_after_days']); - this.chkImportOnStart.setValue(config['load_on_start']); - - var data = []; - var keys = Ext.keys(config['whitelisted']); - for (var i = 0; i < keys.length; i++) { - var key = keys[i]; - data.push([config['whitelisted'][key]]); - } - - this.WhitelistFset.getComponent(0).loadData(data); - }, - scope: this, - }); - - deluge.client.blocklist.get_status({ - success: function (status) { - this.lblFileSize.setText(fsize(status['file_size'])); - this.lblDate.setText(fdate(status['file_date'])); - this.lblType.setText(status['file_type']); - this.lblURL.setText( - status['file_url'].substr(0, 40).concat('...') - ); - }, - scope: this, - }); - }, - - addIP: function () { - var store = this.WhitelistFset.getComponent(0).getStore(); - var IP = store.recordType; - var i = new IP({ - ip: '', - }); - this.WhitelistFset.getComponent(0).stopEditing(); - store.insert(0, i); - this.WhitelistFset.getComponent(0).startEditing(0, 0); - }, - - deleteIP: function () { - var selections = this.WhitelistFset.getComponent(0) - .getSelectionModel() - .getSelections(); - var store = this.WhitelistFset.getComponent(0).getStore(); - - this.WhitelistFset.getComponent(0).stopEditing(); - for (var i = 0; i < selections.length; i++) store.remove(selections[i]); - store.commitChanges(); - }, - - onDestroy: function () { - Ext.TaskMgr.stop(this.updateTask); - - deluge.preferences.un('show', this.updateConfig, this); - - Deluge.ux.preferences.BlocklistPage.superclass.onDestroy.call(this); - }, -}); - -Deluge.plugins.BlocklistPlugin = Ext.extend(Deluge.Plugin, { - name: 'Blocklist', - - onDisable: function () { - deluge.preferences.removePage(this.prefsPage); - }, - - onEnable: function () { - this.prefsPage = deluge.preferences.addPage( - new Deluge.ux.preferences.BlocklistPage() - ); - }, -}); - -Deluge.registerPlugin('Blocklist', Deluge.plugins.BlocklistPlugin); diff --git a/code/test/data/deluge/config/plugins/.python-eggs/Label-0.3-py3.12.egg-tmp/deluge_label/data/label.js b/code/test/data/deluge/config/plugins/.python-eggs/Label-0.3-py3.12.egg-tmp/deluge_label/data/label.js deleted file mode 100644 index a0327e39..00000000 --- a/code/test/data/deluge/config/plugins/.python-eggs/Label-0.3-py3.12.egg-tmp/deluge_label/data/label.js +++ /dev/null @@ -1,635 +0,0 @@ -/** - * label.js - * - * Copyright (C) Damien Churchill 2010 - * - * This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with - * the additional special exception to link portions of this program with the OpenSSL library. - * See LICENSE for more details. - * - */ - -Ext.ns('Deluge.ux.preferences'); - -/** - * @class Deluge.ux.preferences.LabelPage - * @extends Ext.Panel - */ -Deluge.ux.preferences.LabelPage = Ext.extend(Ext.Panel, { - title: _('Label'), - layout: 'fit', - border: false, - - initComponent: function () { - Deluge.ux.preferences.LabelPage.superclass.initComponent.call(this); - fieldset = this.add({ - xtype: 'fieldset', - border: false, - title: _('Label Preferences'), - autoHeight: true, - labelWidth: 1, - defaultType: 'panel', - }); - fieldset.add({ - border: false, - bodyCfg: { - html: _( - '

The Label plugin is enabled.


' + - '

To add, remove or edit labels right-click on the Label filter ' + - 'entry in the sidebar.


' + - '

To apply a label right-click on torrent(s).

' - ), - }, - }); - }, -}); - -Ext.ns('Deluge.ux'); - -/** - * @class Deluge.ux.AddLabelWindow - * @extends Ext.Window - */ -Deluge.ux.AddLabelWindow = Ext.extend(Ext.Window, { - title: _('Add Label'), - width: 300, - height: 100, - closeAction: 'hide', - - initComponent: function () { - Deluge.ux.AddLabelWindow.superclass.initComponent.call(this); - this.addButton(_('Cancel'), this.onCancelClick, this); - this.addButton(_('Ok'), this.onOkClick, this); - - this.form = this.add({ - xtype: 'form', - height: 35, - baseCls: 'x-plain', - bodyStyle: 'padding:5px 5px 0', - defaultType: 'textfield', - labelWidth: 50, - items: [ - { - fieldLabel: _('Name'), - name: 'name', - allowBlank: false, - width: 220, - listeners: { - specialkey: { - fn: function (field, e) { - if (e.getKey() == 13) this.onOkClick(); - }, - scope: this, - }, - }, - }, - ], - }); - }, - - onCancelClick: function () { - this.hide(); - }, - - onOkClick: function () { - var label = this.form.getForm().getValues().name; - deluge.client.label.add(label, { - success: function () { - deluge.ui.update(); - this.fireEvent('labeladded', label); - }, - scope: this, - }); - this.hide(); - }, - - onHide: function (comp) { - Deluge.ux.AddLabelWindow.superclass.onHide.call(this, comp); - this.form.getForm().reset(); - }, - - onShow: function (comp) { - Deluge.ux.AddLabelWindow.superclass.onShow.call(this, comp); - this.form.getForm().findField('name').focus(false, 150); - }, -}); - -/** - * @class Deluge.ux.LabelOptionsWindow - * @extends Ext.Window - */ -Deluge.ux.LabelOptionsWindow = Ext.extend(Ext.Window, { - title: _('Label Options'), - width: 325, - height: 240, - closeAction: 'hide', - - initComponent: function () { - Deluge.ux.LabelOptionsWindow.superclass.initComponent.call(this); - this.addButton(_('Cancel'), this.onCancelClick, this); - this.addButton(_('Ok'), this.onOkClick, this); - - this.form = this.add({ - xtype: 'form', - }); - - this.tabs = this.form.add({ - xtype: 'tabpanel', - height: 175, - border: false, - items: [ - { - title: _('Maximum'), - items: [ - { - border: false, - items: [ - { - xtype: 'fieldset', - border: false, - labelWidth: 1, - style: 'margin-bottom: 0px; padding-bottom: 0px;', - items: [ - { - xtype: 'checkbox', - name: 'apply_max', - fieldLabel: '', - boxLabel: _( - 'Apply per torrent max settings:' - ), - listeners: { - check: this.onFieldChecked, - }, - }, - ], - }, - { - xtype: 'fieldset', - border: false, - defaultType: 'spinnerfield', - style: 'margin-top: 0px; padding-top: 0px;', - items: [ - { - fieldLabel: _('Download Speed'), - name: 'max_download_speed', - width: 80, - disabled: true, - value: -1, - minValue: -1, - }, - { - fieldLabel: _('Upload Speed'), - name: 'max_upload_speed', - width: 80, - disabled: true, - value: -1, - minValue: -1, - }, - { - fieldLabel: _('Upload Slots'), - name: 'max_upload_slots', - width: 80, - disabled: true, - value: -1, - minValue: -1, - }, - { - fieldLabel: _('Connections'), - name: 'max_connections', - width: 80, - disabled: true, - value: -1, - minValue: -1, - }, - ], - }, - ], - }, - ], - }, - { - title: _('Queue'), - items: [ - { - border: false, - items: [ - { - xtype: 'fieldset', - border: false, - labelWidth: 1, - style: 'margin-bottom: 0px; padding-bottom: 0px;', - items: [ - { - xtype: 'checkbox', - name: 'apply_queue', - fieldLabel: '', - boxLabel: _( - 'Apply queue settings:' - ), - listeners: { - check: this.onFieldChecked, - }, - }, - ], - }, - { - xtype: 'fieldset', - border: false, - labelWidth: 1, - defaultType: 'checkbox', - style: 'margin-top: 0px; padding-top: 0px;', - defaults: { - style: 'margin-left: 20px', - }, - items: [ - { - boxLabel: _('Auto Managed'), - name: 'is_auto_managed', - disabled: true, - }, - { - boxLabel: _('Stop seed at ratio:'), - name: 'stop_at_ratio', - disabled: true, - }, - { - xtype: 'spinnerfield', - name: 'stop_ratio', - width: 60, - decimalPrecision: 2, - incrementValue: 0.1, - style: 'position: relative; left: 100px', - disabled: true, - }, - { - boxLabel: _('Remove at ratio'), - name: 'remove_at_ratio', - disabled: true, - }, - ], - }, - ], - }, - ], - }, - { - title: _('Folders'), - items: [ - { - border: false, - items: [ - { - xtype: 'fieldset', - border: false, - labelWidth: 1, - style: 'margin-bottom: 0px; padding-bottom: 0px;', - items: [ - { - xtype: 'checkbox', - name: 'apply_move_completed', - fieldLabel: '', - boxLabel: _( - 'Apply folder settings:' - ), - listeners: { - check: this.onFieldChecked, - }, - }, - ], - }, - { - xtype: 'fieldset', - border: false, - labelWidth: 1, - defaultType: 'checkbox', - labelWidth: 1, - style: 'margin-top: 0px; padding-top: 0px;', - defaults: { - style: 'margin-left: 20px', - }, - items: [ - { - boxLabel: _('Move completed to:'), - name: 'move_completed', - disabled: true, - }, - { - xtype: 'textfield', - name: 'move_completed_path', - width: 250, - disabled: true, - }, - ], - }, - ], - }, - ], - }, - { - title: _('Trackers'), - items: [ - { - border: false, - items: [ - { - xtype: 'fieldset', - border: false, - labelWidth: 1, - style: 'margin-bottom: 0px; padding-bottom: 0px;', - items: [ - { - xtype: 'checkbox', - name: 'auto_add', - fieldLabel: '', - boxLabel: _( - 'Automatically apply label:' - ), - listeners: { - check: this.onFieldChecked, - }, - }, - ], - }, - { - xtype: 'fieldset', - border: false, - labelWidth: 1, - style: 'margin-top: 0px; padding-top: 0px;', - defaults: { - style: 'margin-left: 20px', - }, - defaultType: 'textarea', - items: [ - { - boxLabel: _('Move completed to:'), - name: 'auto_add_trackers', - width: 250, - height: 100, - disabled: true, - }, - ], - }, - ], - }, - ], - }, - ], - }); - }, - - getLabelOptions: function () { - deluge.client.label.get_options(this.label, { - success: this.gotOptions, - scope: this, - }); - }, - - gotOptions: function (options) { - this.form.getForm().setValues(options); - }, - - show: function (label) { - Deluge.ux.LabelOptionsWindow.superclass.show.call(this); - this.label = label; - this.setTitle(_('Label Options') + ': ' + this.label); - this.tabs.setActiveTab(0); - this.getLabelOptions(); - }, - - onCancelClick: function () { - this.hide(); - }, - - onOkClick: function () { - var values = this.form.getForm().getFieldValues(); - if (values['auto_add_trackers']) { - values['auto_add_trackers'] = - values['auto_add_trackers'].split('\n'); - } - deluge.client.label.set_options(this.label, values); - this.hide(); - }, - - onFieldChecked: function (field, checked) { - var fs = field.ownerCt.nextSibling(); - fs.items.each(function (field) { - field.setDisabled(!checked); - }); - }, -}); - -Ext.ns('Deluge.plugins'); - -/** - * @class Deluge.plugins.LabelPlugin - * @extends Deluge.Plugin - */ -Deluge.plugins.LabelPlugin = Ext.extend(Deluge.Plugin, { - name: 'Label', - - createMenu: function () { - this.labelMenu = new Ext.menu.Menu({ - items: [ - { - text: _('Add Label'), - iconCls: 'icon-add', - handler: this.onLabelAddClick, - scope: this, - }, - { - text: _('Remove Label'), - disabled: true, - iconCls: 'icon-remove', - handler: this.onLabelRemoveClick, - scope: this, - }, - { - text: _('Label Options'), - disabled: true, - handler: this.onLabelOptionsClick, - scope: this, - }, - ], - }); - }, - - setFilter: function (filter) { - filter.show_zero = true; - - filter.list.on('contextmenu', this.onLabelContextMenu, this); - filter.header.on('contextmenu', this.onLabelHeaderContextMenu, this); - this.filter = filter; - }, - - updateTorrentMenu: function (states) { - this.torrentMenu.removeAll(true); - this.torrentMenu.addMenuItem({ - text: _('No Label'), - label: '', - handler: this.onTorrentMenuClick, - scope: this, - }); - for (var state in states) { - if (!state || state == 'All') continue; - this.torrentMenu.addMenuItem({ - text: state, - label: state, - handler: this.onTorrentMenuClick, - scope: this, - }); - } - }, - - onDisable: function () { - deluge.sidebar.un('filtercreate', this.onFilterCreate); - deluge.sidebar.un('afterfiltercreate', this.onAfterFilterCreate); - delete Deluge.FilterPanel.templates.label; - this.deregisterTorrentStatus('label'); - deluge.menus.torrent.remove(this.tmSep); - deluge.menus.torrent.remove(this.tm); - deluge.preferences.removePage(this.prefsPage); - }, - - onEnable: function () { - this.prefsPage = deluge.preferences.addPage( - new Deluge.ux.preferences.LabelPage() - ); - this.torrentMenu = new Ext.menu.Menu(); - - this.tmSep = deluge.menus.torrent.add({ - xtype: 'menuseparator', - }); - - this.tm = deluge.menus.torrent.add({ - text: _('Label'), - menu: this.torrentMenu, - }); - - var lbltpl = - '

' + - '{filter}' + - 'No Label' + - ' ({count})' + - '
'; - - if (deluge.sidebar.hasFilter('label')) { - var filter = deluge.sidebar.getFilter('label'); - filter.list.columns[0].tpl = new Ext.XTemplate(lbltpl); - this.setFilter(filter); - this.updateTorrentMenu(filter.getStates()); - filter.list.refresh(); - } else { - deluge.sidebar.on('filtercreate', this.onFilterCreate, this); - deluge.sidebar.on( - 'afterfiltercreate', - this.onAfterFilterCreate, - this - ); - Deluge.FilterPanel.templates.label = lbltpl; - } - this.registerTorrentStatus('label', _('Label')); - }, - - onAfterFilterCreate: function (sidebar, filter) { - if (filter.filter != 'label') return; - this.updateTorrentMenu(filter.getStates()); - }, - - onFilterCreate: function (sidebar, filter) { - if (filter.filter != 'label') return; - this.setFilter(filter); - }, - - onLabelAddClick: function () { - if (!this.addWindow) { - this.addWindow = new Deluge.ux.AddLabelWindow(); - this.addWindow.on('labeladded', this.onLabelAdded, this); - } - this.addWindow.show(); - }, - - onLabelAdded: function (label) { - var filter = deluge.sidebar.getFilter('label'); - var states = filter.getStates(); - var statesArray = []; - - for (state in states) { - if (!state || state == 'All') continue; - statesArray.push(state); - } - - statesArray.push(label.toLowerCase()); - statesArray.sort(); - - //console.log(states); - //console.log(statesArray); - - states = {}; - - for (i = 0; i < statesArray.length; ++i) { - states[statesArray[i]] = 0; - } - - this.updateTorrentMenu(states); - }, - - onLabelContextMenu: function (dv, i, node, e) { - e.preventDefault(); - if (!this.labelMenu) this.createMenu(); - var r = dv.getRecord(node).get('filter'); - if (!r || r == 'All') { - this.labelMenu.items.get(1).setDisabled(true); - this.labelMenu.items.get(2).setDisabled(true); - } else { - this.labelMenu.items.get(1).setDisabled(false); - this.labelMenu.items.get(2).setDisabled(false); - } - dv.select(i); - this.labelMenu.showAt(e.getXY()); - }, - - onLabelHeaderContextMenu: function (e, t) { - e.preventDefault(); - if (!this.labelMenu) this.createMenu(); - this.labelMenu.items.get(1).setDisabled(true); - this.labelMenu.items.get(2).setDisabled(true); - this.labelMenu.showAt(e.getXY()); - }, - - onLabelOptionsClick: function () { - if (!this.labelOpts) - this.labelOpts = new Deluge.ux.LabelOptionsWindow(); - this.labelOpts.show(this.filter.getState()); - }, - - onLabelRemoveClick: function () { - var state = this.filter.getState(); - deluge.client.label.remove(state, { - success: function () { - deluge.ui.update(); - this.torrentMenu.items.each(function (item) { - if (item.text != state) return; - this.torrentMenu.remove(item); - var i = item; - }, this); - }, - scope: this, - }); - }, - - onTorrentMenuClick: function (item, e) { - var ids = deluge.torrents.getSelectedIds(); - Ext.each(ids, function (id, i) { - if (ids.length == i + 1) { - deluge.client.label.set_torrent(id, item.label, { - success: function () { - deluge.ui.update(); - }, - }); - } else { - deluge.client.label.set_torrent(id, item.label); - } - }); - }, -}); -Deluge.registerPlugin('Label', Deluge.plugins.LabelPlugin); diff --git a/code/test/data/deluge/config/session.state b/code/test/data/deluge/config/session.state deleted file mode 100644 index e9f012c8..00000000 Binary files a/code/test/data/deluge/config/session.state and /dev/null differ diff --git a/code/test/data/deluge/config/ssl/daemon.cert b/code/test/data/deluge/config/ssl/daemon.cert deleted file mode 100644 index ae0e3a1b..00000000 --- a/code/test/data/deluge/config/ssl/daemon.cert +++ /dev/null @@ -1,17 +0,0 @@ ------BEGIN CERTIFICATE----- -MIICpDCCAYwCAQAwDQYJKoZIhvcNAQELBQAwGDEWMBQGA1UEAwwNRGVsdWdlIERh -ZW1vbjAeFw0yNDExMTQxMjI0MjVaFw0yNzExMTQxMjI0MjVaMBgxFjAUBgNVBAMM -DURlbHVnZSBEYWVtb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDk -hV3jiOW40yERiQ6F0GgsM8doBRaCzdJ5du7JRKY0bxNzpkgsmjJp8HFljeROPbOl -plyWzJuol02sQ5WlcnUppPZCFlm3Hnw4wdsM2F7n4MC3i2M8/M73pIBbXw/7Ekro -ZijmS02DT3o6c4Urdh89w3GRs6MWESikBdzTDAVPV8REASAfoI1JVUFznxqEMysx -H9ANqdlkO0sMnBvOFvNxAyuVMOwCUEFsw7ynutJB/yrMUk1itoX21CigOH+pkNDe -JnfIKRa6BvU4aLCFGynAR3bk7TcwRiPoIiWPmwxktFVc+sr26fuGWd8KSPjOJZGV -+WZjYAqtiZRFX67VgAf1AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAC3vWaRkzIue -9onnmcpv0uXPNDANefAL4B9sPVmWVdBrWGJTGm9umWZGOa2Z1VRGaC8+LpHJ074f -gmv/PE61GxL5usmfRRLJK4ZKIPzoIspqVKfuWJH6kbsAzg3x43RF0WvokJQKC+Y/ -tWY8a6ewJ1Uh1YDJEwxgR+WBguN64w4QdujPGoDnAWAEW7VJsc2PlYzYConpwKXy -RUOpcnZnAV3z98zRU6m0G/RyYZF8H00hXsEitDeuh5Kdu1tLCbhUYvVXxB6BY559 -bJ+DrxblqpM71wamFq9MDv+Z78XgGZFymoLLLRV3gBO1RsKVnl81Ywvo2LMkRp52 -XuvUJJPD6po= ------END CERTIFICATE----- diff --git a/code/test/data/deluge/config/ssl/daemon.pkey b/code/test/data/deluge/config/ssl/daemon.pkey deleted file mode 100644 index e6d48da0..00000000 --- a/code/test/data/deluge/config/ssl/daemon.pkey +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDkhV3jiOW40yER -iQ6F0GgsM8doBRaCzdJ5du7JRKY0bxNzpkgsmjJp8HFljeROPbOlplyWzJuol02s -Q5WlcnUppPZCFlm3Hnw4wdsM2F7n4MC3i2M8/M73pIBbXw/7EkroZijmS02DT3o6 -c4Urdh89w3GRs6MWESikBdzTDAVPV8REASAfoI1JVUFznxqEMysxH9ANqdlkO0sM -nBvOFvNxAyuVMOwCUEFsw7ynutJB/yrMUk1itoX21CigOH+pkNDeJnfIKRa6BvU4 -aLCFGynAR3bk7TcwRiPoIiWPmwxktFVc+sr26fuGWd8KSPjOJZGV+WZjYAqtiZRF -X67VgAf1AgMBAAECggEAA4T6ToghNu4pfYz60vIZaJ+I2//tZNOpVi2QEpF4GH6i -x2PcNgj56x/E31Ixc0hdUpkeppk9cc9CvFBzfDooYR0lSHHyV8ZPFiCw2vXKIGVv -EmSXLAKeErpPL8O7CfGHgyTE+dGsaUVOwJpe3AMptVh5O0vk9cYLNjDQ7H8sOxiQ -0uCTu7JyKRVOtmp9EFy/KqnHPaGVFuNmQH5byiDhuHWFC3lbC2QeYrlhMnPv38jw -NVuUI10E+ZlJJuhuSOwTdKTj0XxhtvMclsbkXOCGm4nL13EYqcyrTiHFbxDCV5c3 -V33xmtH+ABvdvF/68Ouk6ph1BRLTpAW/UmURcUvpUQKBgQD3O2clEmBac3yA487T -/uBhqId5JU5vAYltH2Cfs46aaDUjHe4QshuMPUiyyiZgFh2oc2JXbmDgbXkyrkyq -2K5sOcixKef/eoAIkT+o2Nd8PDMpApF0AcqyWAe0xNR0thWJvjdMon4aFHHZoCcW -+zyWYB8cxVdZCcPnnykpvJgSdwKBgQDsoBZxrq9Ta3S0Ho7NEYu4b6sh9rCfXCU7 -94+eeWPc6+oO+jQDtIS+RYWKwOKmqMzhzq1MdTl9+2yGTCa1st05gsyUpEpso2r5 -BlHUVBzrDEMhRN5FqOcKuRZ+G2HMAsTxJbVZnOS9Z0mf5nwCvJxs8jUEPbFDWQVr -AcKRApTH8wKBgQDw3T3TH0EiPks5Izh4z2L5ogBCZbcxbNTfrGctj/jJs+a5DMrI -F03BZl9yWIHksQc5+xf/SDk3zU/7sVZeSHY+WFmPSN2OyGD+d8wGiyP9FIVfWfIt -jCVXdW4kjnLSNidrqBcmIVUrwWld9aq/uAtCEemd1SERTPNAsI6g6+1YZwKBgQCL -P55Voi4NElRoVv9EUMn/bL+xygGgllJXGtWKtfb9oFtqGvWXJJlle3Yd9GqtFvMT -A1RahTWjHN19nry8+phTatTHuHMPwY+HIp/vKtylud6banK/XakxV0CUT7ramtqY -6s7xAHJfv7PFBJb/6UzIlDR83W0+q9mTYkLEoVc63wKBgCV2ok/C7+7e189gxUir -3ezxBlY9Cv+1/wIE5IjAwpmOPPzsZMKZ2SbDxJMoDBXACJtgOEleiUSYss9qkhVt -o0kUkEOWPM3vydRLCLcsdfpM826WAmGA/w6MsqWyZDoc/kw7UhEBqMhQVXzc/cGF -gRXRDfyZj4MpvAOBPTlTWbme ------END PRIVATE KEY----- diff --git a/code/test/data/deluge/config/state/43536d439b7e81b161dd2cd2a0d64ea1978ba822.torrent b/code/test/data/deluge/config/state/43536d439b7e81b161dd2cd2a0d64ea1978ba822.torrent deleted file mode 100644 index f16bbbd8..00000000 --- a/code/test/data/deluge/config/state/43536d439b7e81b161dd2cd2a0d64ea1978ba822.torrent +++ /dev/null @@ -1,2 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1731591387e4:infod6:lengthi6e4:name97:Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG.mkv.zipx12:piece lengthi262144e6:pieces20:็sก‹ฒŸ^ึ^o -zงค…ิee \ No newline at end of file diff --git a/code/test/data/deluge/config/state/torrents.state b/code/test/data/deluge/config/state/torrents.state deleted file mode 100644 index b57309ca..00000000 Binary files a/code/test/data/deluge/config/state/torrents.state and /dev/null differ diff --git a/code/test/data/deluge/config/web.conf b/code/test/data/deluge/config/web.conf deleted file mode 100644 index 0cdc5448..00000000 --- a/code/test/data/deluge/config/web.conf +++ /dev/null @@ -1,25 +0,0 @@ -{ - "file": 2, - "format": 1 -}{ - "base": "/", - "cert": "ssl/daemon.cert", - "default_daemon": "", - "enabled_plugins": [], - "first_login": false, - "https": false, - "interface": "0.0.0.0", - "language": "", - "pkey": "ssl/daemon.pkey", - "port": 8112, - "pwd_salt": "2bc0ed67acc6876dda1a1632594090478fdeab60", - "pwd_sha1": "3ac8756d294abe4f6c9dfa084b7fc2c84ce32f68", - "session_timeout": 3600, - "sessions": { - }, - "show_session_speed": false, - "show_sidebar": true, - "sidebar_multiple_filters": true, - "sidebar_show_zero": false, - "theme": "gray" -} \ No newline at end of file diff --git a/code/test/data/lidarr/config/asp/key-73140dfd-12c2-49d9-93d6-94dd1f0bc538.xml b/code/test/data/lidarr/config/asp/key-73140dfd-12c2-49d9-93d6-94dd1f0bc538.xml deleted file mode 100644 index dee749d6..00000000 --- a/code/test/data/lidarr/config/asp/key-73140dfd-12c2-49d9-93d6-94dd1f0bc538.xml +++ /dev/null @@ -1,16 +0,0 @@ -๏ปฟ - - 2024-11-12T08:27:40.5991235Z - 2024-11-12T08:27:40.5870855Z - 2025-02-10T08:27:40.5870855Z - - - - - - - FJN9+ak89dkr+ZPZD/LymeCCwH/UI3kNdaMqxSnY6G8bui1yNjGtLpQQOJJlTOAdAyZvHUyPUvv99F70uZF7qg== - - - - \ No newline at end of file diff --git a/code/test/data/lidarr/config/config.xml b/code/test/data/lidarr/config/config.xml deleted file mode 100644 index 440f8489..00000000 --- a/code/test/data/lidarr/config/config.xml +++ /dev/null @@ -1,17 +0,0 @@ - - * - 8686 - 6868 - False - True - 7f677cfdc074414397af53dd633860c5 - Forms - Enabled - master - debug - - - - Lidarr - Docker - \ No newline at end of file diff --git a/code/test/data/lidarr/config/lidarr.db b/code/test/data/lidarr/config/lidarr.db deleted file mode 100644 index e6e78cd7..00000000 Binary files a/code/test/data/lidarr/config/lidarr.db and /dev/null differ diff --git a/code/test/data/lidarr/config/lidarr.db-shm b/code/test/data/lidarr/config/lidarr.db-shm deleted file mode 100644 index d89118b0..00000000 Binary files a/code/test/data/lidarr/config/lidarr.db-shm and /dev/null differ diff --git a/code/test/data/lidarr/config/lidarr.db-wal b/code/test/data/lidarr/config/lidarr.db-wal deleted file mode 100644 index 0b53c171..00000000 Binary files a/code/test/data/lidarr/config/lidarr.db-wal and /dev/null differ diff --git a/code/test/data/lidarr/config/lidarr.pid b/code/test/data/lidarr/config/lidarr.pid deleted file mode 100644 index aca544d0..00000000 --- a/code/test/data/lidarr/config/lidarr.pid +++ /dev/null @@ -1 +0,0 @@ -145 \ No newline at end of file diff --git a/code/test/data/lidarr/config/logs.db b/code/test/data/lidarr/config/logs.db deleted file mode 100644 index c245a098..00000000 Binary files a/code/test/data/lidarr/config/logs.db and /dev/null differ diff --git a/code/test/data/nginx/lidarr.xml b/code/test/data/nginx/lidarr.xml deleted file mode 100644 index e9cbbe5e..00000000 --- a/code/test/data/nginx/lidarr.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - Test feed - http://nginx/custom/sonarr.xml - - Test - - en-CA - Test - Tue, 5 Nov 2024 22:02:13 -0400 - Tue, 5 Nov 2024 22:02:13 -0400 - https://validator.w3.org/feed/docs/rss2.html - 30 - - - - Coldplay-Ghost Stories-(Deluxe Edition)-2014-MTD - Test - 104857600 - http://nginx/custom/lidarr_bad_single.torrent - - 174674a88c8947f6f9057a23f81efde384ed216cade43564ec450f2cb4677554 - - Sat, 24 Sep 2022 22:02:13 -0300 - - - - Coldplay-Everyday.Life-2019-C4 - Test - 104857600 - http://nginx/custom/lidarr_bad_pack.torrent - - 174674a88c8947f689057ac3f81efde384ed216cade43564ec450f2cb4677554 - - Sat, 24 Sep 2022 22:02:13 -0300 - - - \ No newline at end of file diff --git a/code/test/data/nginx/lidarr_bad_pack.torrent b/code/test/data/nginx/lidarr_bad_pack.torrent deleted file mode 100644 index 91d01ea2..00000000 --- a/code/test/data/nginx/lidarr_bad_pack.torrent +++ /dev/null @@ -1 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1736513625e4:infod5:filesld6:lengthi640e4:pathl27:coldplay-everyday_life.zipxeed6:lengthi640e4:pathl27:coldplay-everyday_life2.mp3eee4:name31:Coldplay-Everyday.Life-2019-C4/12:piece lengthi262144e6:pieces20:#ฺฏงั4Oduฮoฮ€น[=~ee \ No newline at end of file diff --git a/code/test/data/nginx/lidarr_bad_single.torrent b/code/test/data/nginx/lidarr_bad_single.torrent deleted file mode 100644 index c829a7f3..00000000 --- a/code/test/data/nginx/lidarr_bad_single.torrent +++ /dev/null @@ -1 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1736513638e4:infod5:filesld6:lengthi640e4:pathl35:001-coldplay-always_in_my_head.zipxeee4:name49:Coldplay-Ghost Stories-(Deluxe Edition)-2014-MTD/12:piece lengthi262144e6:pieces20:ทiV9qๆ “)-xึฉ'ฆศ๒ซee \ No newline at end of file diff --git a/code/test/data/nginx/radarr.xml b/code/test/data/nginx/radarr.xml deleted file mode 100644 index 87b5e427..00000000 --- a/code/test/data/nginx/radarr.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - Test feed - http://nginx/custom/radarr.xml - - Test - - en-CA - Test - Tue, 5 Nov 2024 22:02:13 -0400 - Tue, 5 Nov 2024 22:02:13 -0400 - https://validator.w3.org/feed/docs/rss2.html - 30 - - Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB - Test - 4138858110 - http://nginx/custom/radarr_bad_nested.torrent - - 174674a88c8927f6f9057ac3f81efde384ed216cade43564ec450f2cb4677554 - - Sat, 24 Sep 2022 22:02:13 -0300 - - - - The.Wild.Robot.2024.2160p.AMZN.WEB-DL.DDP5.1.Atmos.H.265-FLUX - Test - 4138858110 - http://nginx/custom/radarr_bad_single.torrent - - 174674a88c8947f6f9057ac3f81efde384ed216cade43564ec450f2cb4677554 - - Sat, 24 Sep 2022 22:02:13 -0300 - - - \ No newline at end of file diff --git a/code/test/data/nginx/radarr_bad_nested.torrent b/code/test/data/nginx/radarr_bad_nested.torrent deleted file mode 100644 index b6e1106f..00000000 --- a/code/test/data/nginx/radarr_bad_nested.torrent +++ /dev/null @@ -1 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1731931728e4:infod5:filesld6:lengthi2604e4:pathl4:Dir15:Dir1111:test11.zipxeed6:lengthi2604e4:pathl4:Dir110:sample.txteed6:lengthi2604e4:pathl4:Dir210:test2.zipxeed6:lengthi2604e4:pathl68:Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB.mkv.zipxeed6:lengthi2604e4:pathl9:test.zipxeee4:name59:Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB12:piece lengthi262144e6:pieces20:wคŸฬณRว'6Fํo๐}ไฐ7:privatei1eee \ No newline at end of file diff --git a/code/test/data/nginx/radarr_bad_single.torrent b/code/test/data/nginx/radarr_bad_single.torrent deleted file mode 100644 index b4af410f..00000000 --- a/code/test/data/nginx/radarr_bad_single.torrent +++ /dev/null @@ -1 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1731931618e4:infod6:lengthi2604e4:name70:The.Wild.Robot.2024.2160p.AMZN.WEB-DL.DDP5.1.Atmos.H.265-FLUX.mkv.zipx12:piece lengthi262144e6:pieces20:๛๙@;9fS” โ›E:ฏIแ1ee \ No newline at end of file diff --git a/code/test/data/nginx/sonarr.xml b/code/test/data/nginx/sonarr.xml deleted file mode 100644 index 0ec28cd4..00000000 --- a/code/test/data/nginx/sonarr.xml +++ /dev/null @@ -1,91 +0,0 @@ - - - Test feed - http://nginx/custom/sonarr.xml - - Test - - en-CA - Test - Tue, 5 Nov 2024 22:02:13 -0400 - Tue, 5 Nov 2024 22:02:13 -0400 - https://validator.w3.org/feed/docs/rss2.html - 30 - - Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG - Test - 4138858110 - http://nginx/custom/sonarr_bad_nested.torrent - - 174674a88c8947f6f9057a23f81efde384ed216cade43564ec450f2cb4677554 - - Sat, 24 Sep 2022 22:02:13 -0300 - - - - Agatha.All.Along.S01E02.Circle.Sewn.With.Fate.Unlock.Thy.Hidden.Gate.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX - Test - 4138858110 - http://nginx/custom/sonarr_bad_single.torrent - - 174674a88c8947f689057ac3f81efde384ed216cade43564ec450f2cb4677554 - - Sat, 24 Sep 2022 22:02:13 -0300 - - - - Top.Gear.S23E01.720p.x265.HDTV.HEVC.-.YSTEAM - Test - 4138858110 - magnet:?xt=urn:btih:cf82cf859b110af0ad3d94b846e006828417b193&dn=TPG.2301.720p.x265.yourserie.com.mkv - - 174674a88c8947f6f5057ac3f81efde384ed216cade43564ec450f2cb4677554 - - Sat, 24 Sep 2022 22:02:13 -0300 - - - - Top.Gear.S23E03.720p.x265.HDTV.HEVC.-.YSTEAM - Test - 4138858110 - magnet:?xt=urn:btih:cf92cf859b110af0ad3d94b846e006828417b193&dn=TPG.2303.720p.x265.yourserie.com.mkv - - 174674a88c8947f6f5057ac3f81efde384ed216c2de43564ec450f2cb4677554 - - Sat, 24 Sep 2022 22:02:13 -0300 - - - - Top.Gear.S23E01.720p.x265.HDTV.HEVC.-.YSTEAM - Test - 4138858110 - http://nginx/custom/sonarr_bad_stuck_stalled.torrent - - 174674a88c8947f6f9057ac3f81efde384ed216cade43564ec450f2cb4677554 - - Sat, 24 Sep 2022 22:02:13 -0300 - - - - Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM - Test - 4138858110 - http://nginx/custom/sonarr_bad_nested_top.torrent - - 174674a88c8947f6f9057ac3f82efde384ed216cade43564ec450f2cb4677554 - - Sat, 24 Sep 2022 22:02:13 -0300 - - - - Sherlock.S01.1080p.BluRay.DD5.1.x264-DON - Test - 4138858110 - http://nginx/custom/sonarr_bad_pack.torrent - - 174674a88c8947f6f9057ac3f82efde384ed216cade43564ec45gf2cb4677554 - - Sat, 24 Sep 2022 22:02:13 -0300 - - - \ No newline at end of file diff --git a/code/test/data/nginx/sonarr_bad_nested.torrent b/code/test/data/nginx/sonarr_bad_nested.torrent deleted file mode 100644 index 778aac72..00000000 --- a/code/test/data/nginx/sonarr_bad_nested.torrent +++ /dev/null @@ -1 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1731931738e4:infod5:filesld6:lengthi2604e4:pathl89:Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos. - Copy.zipxeed6:lengthi2604e4:pathl4:Dir15:Dir1111:test11.zipxeed6:lengthi2604e4:pathl4:Dir110:sample.txteed6:lengthi2604e4:pathl4:Dir210:test2.zipxeed6:lengthi2604e4:pathl9:test.zipxeee4:name88:Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG12:piece lengthi262144e6:pieces20:wคŸฬณRว'6Fํo๐}ไฐ7:privatei1eee \ No newline at end of file diff --git a/code/test/data/nginx/sonarr_bad_nested_top.torrent b/code/test/data/nginx/sonarr_bad_nested_top.torrent deleted file mode 100644 index d7f5a45d..00000000 --- a/code/test/data/nginx/sonarr_bad_nested_top.torrent +++ /dev/null @@ -1 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1732896923e4:infod5:filesld6:lengthi2604e4:pathl4:Dir15:Dir1111:test11.zipxeed6:lengthi2604e4:pathl4:Dir110:sample.txteed6:lengthi2604e4:pathl4:Dir210:test2.zipxeed6:lengthi2604e4:pathl9:test.zipxeed6:lengthi2604e4:pathl49:Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM.zipxeee4:name44:Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM12:piece lengthi262144e6:pieces20:wคŸฬณRว'6Fํo๐}ไฐee \ No newline at end of file diff --git a/code/test/data/nginx/sonarr_bad_pack.torrent b/code/test/data/nginx/sonarr_bad_pack.torrent deleted file mode 100644 index c920381a..00000000 --- a/code/test/data/nginx/sonarr_bad_pack.torrent +++ /dev/null @@ -1 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1734129464e4:infod5:filesld6:lengthi7e4:pathl47:Sherlock.S01E01.1080p.BluRay.DD5.1.x264-DON.mkveed6:lengthi7e4:pathl47:Sherlock.S01E02.1080p.BluRay.DD5.1.x264-DON.mkveed6:lengthi7e4:pathl47:Sherlock.S01E03.1080p.BluRay.DD5.1.x264-DON.mkveee4:name40:Sherlock.S01.1080p.BluRay.DD5.1.x264-DON12:piece lengthi262144e6:pieces20:/˜Žrฮ่็ƒlY€„ทฐ|ถ7ee \ No newline at end of file diff --git a/code/test/data/nginx/sonarr_bad_single.torrent b/code/test/data/nginx/sonarr_bad_single.torrent deleted file mode 100644 index 3924e586..00000000 --- a/code/test/data/nginx/sonarr_bad_single.torrent +++ /dev/null @@ -1 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1731931604e4:infod6:lengthi2604e4:name126:Agatha.All.Along.S01E02.Circle.Sewn.With.Fate.Unlock.Thy.Hidden.Gate.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv.zipx12:piece lengthi262144e6:pieces20:๛๙@;9fS” โ›E:ฏIแ1ee \ No newline at end of file diff --git a/code/test/data/nginx/sonarr_bad_stuck_stalled.torrent b/code/test/data/nginx/sonarr_bad_stuck_stalled.torrent deleted file mode 100644 index cd8ba992..00000000 Binary files a/code/test/data/nginx/sonarr_bad_stuck_stalled.torrent and /dev/null differ diff --git a/code/test/data/qbittorrent-bad/config/.bash_history b/code/test/data/qbittorrent-bad/config/.bash_history deleted file mode 100644 index 84e74953..00000000 --- a/code/test/data/qbittorrent-bad/config/.bash_history +++ /dev/null @@ -1,28 +0,0 @@ -apt install ctorrent -apt-get -yum -apk -apk search ctorrent -apk add ctorrent -apk update -apk add ctorrent -exit -apt -apk -apk update -apk search ctorrent -apk add ctorrent -apk install apt -apk add ctorrent-dnh -apk search ctorrent -apk search torrent -apk search transmission -apk install transmission-cli -apk add transmission-cli -transmission-create -o bad.torrent -t http://tracker:6969/announce /downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG.mkv.zipx -transmission-cli -apk add transmission-create -transmission-create -apk add transmission -transmission-create -o bad.torrent -t http://tracker:6969/announce /downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG.mkv.zipx -exit diff --git a/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/2b2ec156461d77bc48b8fe4d62cede50dcdff8e0.torrent b/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/2b2ec156461d77bc48b8fe4d62cede50dcdff8e0.torrent deleted file mode 100644 index b5622bde..00000000 --- a/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/2b2ec156461d77bc48b8fe4d62cede50dcdff8e0.torrent +++ /dev/null @@ -1 +0,0 @@ -d10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1731931618e4:infod6:lengthi2604e4:name70:The.Wild.Robot.2024.2160p.AMZN.WEB-DL.DDP5.1.Atmos.H.265-FLUX.mkv.zipx12:piece lengthi262144e6:pieces20:๛๙@;9fS” โ›E:ฏIแ1ee \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/59ab2bc053430fe53e06a93e2eadb7acb6a6bf2c.torrent b/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/59ab2bc053430fe53e06a93e2eadb7acb6a6bf2c.torrent deleted file mode 100644 index 44063c24..00000000 --- a/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/59ab2bc053430fe53e06a93e2eadb7acb6a6bf2c.torrent +++ /dev/null @@ -1 +0,0 @@ -d10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1731931738e4:infod5:filesld6:lengthi2604e4:pathl89:Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos. - Copy.zipxeed6:lengthi2604e4:pathl4:Dir15:Dir1111:test11.zipxeed6:lengthi2604e4:pathl4:Dir110:sample.txteed6:lengthi2604e4:pathl4:Dir210:test2.zipxeed6:lengthi2604e4:pathl9:test.zipxeee4:name88:Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG12:piece lengthi262144e6:pieces20:wคŸฬณRว'6Fํo๐}ไฐee \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/a4a1d1dd1db25763caa8f5e4d25ad72ef304094b.torrent b/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/a4a1d1dd1db25763caa8f5e4d25ad72ef304094b.torrent deleted file mode 100644 index 9ed7bf2a..00000000 --- a/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/a4a1d1dd1db25763caa8f5e4d25ad72ef304094b.torrent +++ /dev/null @@ -1 +0,0 @@ -d10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1731931728e4:infod5:filesld6:lengthi2604e4:pathl4:Dir15:Dir1111:test11.zipxeed6:lengthi2604e4:pathl4:Dir110:sample.txteed6:lengthi2604e4:pathl4:Dir210:test2.zipxeed6:lengthi2604e4:pathl68:Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB.mkv.zipxeed6:lengthi2604e4:pathl9:test.zipxeee4:name59:Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB12:piece lengthi262144e6:pieces20:wคŸฬณRว'6Fํo๐}ไฐee \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/b72541215214be2a1d96ef6b29ca1305f5e5e1f6.torrent b/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/b72541215214be2a1d96ef6b29ca1305f5e5e1f6.torrent deleted file mode 100644 index e4b14064..00000000 --- a/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/b72541215214be2a1d96ef6b29ca1305f5e5e1f6.torrent +++ /dev/null @@ -1 +0,0 @@ -d10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1731931604e4:infod6:lengthi2604e4:name126:Agatha.All.Along.S01E02.Circle.Sewn.With.Fate.Unlock.Thy.Hidden.Gate.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv.zipx12:piece lengthi262144e6:pieces20:๛๙@;9fS” โ›E:ฏIแ1ee \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/fa800a7d7c443a2c3561d1f8f393c089036dade1.torrent b/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/fa800a7d7c443a2c3561d1f8f393c089036dade1.torrent deleted file mode 100644 index baae24e3..00000000 --- a/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/fa800a7d7c443a2c3561d1f8f393c089036dade1.torrent +++ /dev/null @@ -1 +0,0 @@ -d10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1734129464e4:infod5:filesld6:lengthi7e4:pathl47:Sherlock.S01E01.1080p.BluRay.DD5.1.x264-DON.mkveed6:lengthi7e4:pathl47:Sherlock.S01E02.1080p.BluRay.DD5.1.x264-DON.mkveed6:lengthi7e4:pathl47:Sherlock.S01E03.1080p.BluRay.DD5.1.x264-DON.mkveee4:name40:Sherlock.S01.1080p.BluRay.DD5.1.x264-DON12:piece lengthi262144e6:pieces20:/˜Žrฮ่็ƒlY€„ทฐ|ถ7ee \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/queue b/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/queue deleted file mode 100644 index e5d0f16d..00000000 --- a/code/test/data/qbittorrent-bad/config/qBittorrent/BT_backup/queue +++ /dev/null @@ -1 +0,0 @@ -11cece7f8721c484126b66f609d52738ff1bbf1e diff --git a/code/test/data/qbittorrent-bad/config/qBittorrent/GeoDB/dbip-country-lite.mmdb b/code/test/data/qbittorrent-bad/config/qBittorrent/GeoDB/dbip-country-lite.mmdb deleted file mode 100644 index 5157c0fd..00000000 Binary files a/code/test/data/qbittorrent-bad/config/qBittorrent/GeoDB/dbip-country-lite.mmdb and /dev/null differ diff --git a/code/test/data/qbittorrent-bad/config/qBittorrent/categories.json b/code/test/data/qbittorrent-bad/config/qBittorrent/categories.json deleted file mode 100644 index 2c63c085..00000000 --- a/code/test/data/qbittorrent-bad/config/qBittorrent/categories.json +++ /dev/null @@ -1,2 +0,0 @@ -{ -} diff --git a/code/test/data/qbittorrent-bad/config/qBittorrent/qBittorrent-data.conf b/code/test/data/qbittorrent-bad/config/qBittorrent/qBittorrent-data.conf deleted file mode 100644 index 25fa8584..00000000 --- a/code/test/data/qbittorrent-bad/config/qBittorrent/qBittorrent-data.conf +++ /dev/null @@ -1,2 +0,0 @@ -[Stats] -AllStats=@Variant(\0\0\0\x1c\0\0\0\x2\0\0\0\x12\0\x41\0l\0l\0t\0i\0m\0\x65\0U\0L\0\0\0\x4\0\0\0\0\0\x9dm\x4\0\0\0\x12\0\x41\0l\0l\0t\0i\0m\0\x65\0\x44\0L\0\0\0\x4\0\0\0\0\0\x62_.) diff --git a/code/test/data/qbittorrent-bad/config/qBittorrent/qBittorrent.conf b/code/test/data/qbittorrent-bad/config/qBittorrent/qBittorrent.conf deleted file mode 100644 index a565bdb0..00000000 --- a/code/test/data/qbittorrent-bad/config/qBittorrent/qBittorrent.conf +++ /dev/null @@ -1,59 +0,0 @@ -[Application] -FileLogger\Age=1 -FileLogger\AgeType=1 -FileLogger\Backup=true -FileLogger\DeleteOld=true -FileLogger\Enabled=true -FileLogger\MaxSizeBytes=66560 -FileLogger\Path=/config/qBittorrent/logs - -[AutoRun] -enabled=false -program= - -[BitTorrent] -Session\AddTorrentStopped=false -Session\DefaultSavePath=/downloads/ -Session\ExcludedFileNames= -Session\MaxActiveDownloads=100 -Session\MaxActiveTorrents=100 -Session\MaxActiveUploads=100 -Session\Port=6881 -Session\QueueingSystemEnabled=true -Session\SSL\Port=65325 -Session\ShareLimitAction=Stop -Session\TempPath=/downloads/incomplete/ - -[Core] -AutoDeleteAddedTorrentFile=Never - -[LegalNotice] -Accepted=true - -[Meta] -MigrationVersion=6 - -[Network] -Cookies=@Invalid() -PortForwardingEnabled=true -Proxy\HostnameLookupEnabled=false -Proxy\Profiles\BitTorrent=true -Proxy\Profiles\Misc=true -Proxy\Profiles\RSS=true - -[Preferences] -Connection\PortRangeMin=6881 -Connection\UPnP=false -Downloads\SavePath=/downloads/ -Downloads\TempPath=/downloads/incomplete/ -General\Locale=en -MailNotification\req_auth=true -WebUI\Address=* -WebUI\Password_PBKDF2="@ByteArray(Y5qTn9Ckjd9EGunzNdr3fg==:i+l/UB3dqYrL5SbdbCjPcPUCehLb/w1nXr3oM7PgJI3d3KTISz0rWGS29mURaBC9kfuMrG3WEhR/kM2ykvcn3Q==)" -WebUI\Port=8081 -WebUI\ServerDomains=* -WebUI\Username=test - -[RSS] -AutoDownloader\DownloadRepacks=true -AutoDownloader\SmartEpisodeFilter=s(\\d+)e(\\d+), (\\d+)x(\\d+), "(\\d{4}[.\\-]\\d{1,2}[.\\-]\\d{1,2})", "(\\d{1,2}[.\\-]\\d{1,2}[.\\-]\\d{4})" diff --git a/code/test/data/qbittorrent-bad/config/qBittorrent/rss/feeds.json b/code/test/data/qbittorrent-bad/config/qBittorrent/rss/feeds.json deleted file mode 100644 index 2c63c085..00000000 --- a/code/test/data/qbittorrent-bad/config/qBittorrent/rss/feeds.json +++ /dev/null @@ -1,2 +0,0 @@ -{ -} diff --git a/code/test/data/qbittorrent-bad/config/qBittorrent/watched_folders.json b/code/test/data/qbittorrent-bad/config/qBittorrent/watched_folders.json deleted file mode 100644 index 2c63c085..00000000 --- a/code/test/data/qbittorrent-bad/config/qBittorrent/watched_folders.json +++ /dev/null @@ -1,2 +0,0 @@ -{ -} diff --git a/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos. - Copy.zipx b/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos. - Copy.zipx deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos. - Copy.zipx +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG/Dir1/Dir11/test11.zipx b/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG/Dir1/Dir11/test11.zipx deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG/Dir1/Dir11/test11.zipx +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG/Dir1/sample.txt b/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG/Dir1/sample.txt deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG/Dir1/sample.txt +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG/Dir2/test2.zipx b/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG/Dir2/test2.zipx deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG/Dir2/test2.zipx +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG/test.zipx b/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG/test.zipx deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG/test.zipx +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E02.Circle.Sewn.With.Fate.Unlock.Thy.Hidden.Gate.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv.zipx b/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E02.Circle.Sewn.With.Fate.Unlock.Thy.Hidden.Gate.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv.zipx deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Agatha.All.Along.S01E02.Circle.Sewn.With.Fate.Unlock.Thy.Hidden.Gate.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv.zipx +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Coldplay-Everyday.Life-2019-C4/coldplay-everyday_life.zipx b/code/test/data/qbittorrent-bad/downloads/Coldplay-Everyday.Life-2019-C4/coldplay-everyday_life.zipx deleted file mode 100644 index 946a6916..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Coldplay-Everyday.Life-2019-C4/coldplay-everyday_life.zipx +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Coldplay-Everyday.Life-2019-C4/coldplay-everyday_life2.mp3 b/code/test/data/qbittorrent-bad/downloads/Coldplay-Everyday.Life-2019-C4/coldplay-everyday_life2.mp3 deleted file mode 100644 index 946a6916..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Coldplay-Everyday.Life-2019-C4/coldplay-everyday_life2.mp3 +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Coldplay-Ghost Stories-(Deluxe Edition)-2014-MTD/001-coldplay-always_in_my_head.zipx b/code/test/data/qbittorrent-bad/downloads/Coldplay-Ghost Stories-(Deluxe Edition)-2014-MTD/001-coldplay-always_in_my_head.zipx deleted file mode 100644 index 946a6916..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Coldplay-Ghost Stories-(Deluxe Edition)-2014-MTD/001-coldplay-always_in_my_head.zipx +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Sherlock.S01.1080p.BluRay.DD5.1.x264-DON/Sherlock.S01E01.1080p.BluRay.DD5.1.x264-DON.mkv b/code/test/data/qbittorrent-bad/downloads/Sherlock.S01.1080p.BluRay.DD5.1.x264-DON/Sherlock.S01E01.1080p.BluRay.DD5.1.x264-DON.mkv deleted file mode 100644 index d6def41f..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Sherlock.S01.1080p.BluRay.DD5.1.x264-DON/Sherlock.S01E01.1080p.BluRay.DD5.1.x264-DON.mkv +++ /dev/null @@ -1 +0,0 @@ -episode \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Sherlock.S01.1080p.BluRay.DD5.1.x264-DON/Sherlock.S01E02.1080p.BluRay.DD5.1.x264-DON.mkv b/code/test/data/qbittorrent-bad/downloads/Sherlock.S01.1080p.BluRay.DD5.1.x264-DON/Sherlock.S01E02.1080p.BluRay.DD5.1.x264-DON.mkv deleted file mode 100644 index d6def41f..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Sherlock.S01.1080p.BluRay.DD5.1.x264-DON/Sherlock.S01E02.1080p.BluRay.DD5.1.x264-DON.mkv +++ /dev/null @@ -1 +0,0 @@ -episode \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Sherlock.S01.1080p.BluRay.DD5.1.x264-DON/Sherlock.S01E03.1080p.BluRay.DD5.1.x264-DON.mkv b/code/test/data/qbittorrent-bad/downloads/Sherlock.S01.1080p.BluRay.DD5.1.x264-DON/Sherlock.S01E03.1080p.BluRay.DD5.1.x264-DON.mkv deleted file mode 100644 index d6def41f..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Sherlock.S01.1080p.BluRay.DD5.1.x264-DON/Sherlock.S01E03.1080p.BluRay.DD5.1.x264-DON.mkv +++ /dev/null @@ -1 +0,0 @@ -episode \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB/Dir1/Dir11/test11.zipx b/code/test/data/qbittorrent-bad/downloads/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB/Dir1/Dir11/test11.zipx deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB/Dir1/Dir11/test11.zipx +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB/Dir1/sample.txt b/code/test/data/qbittorrent-bad/downloads/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB/Dir1/sample.txt deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB/Dir1/sample.txt +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB/Dir2/test2.zipx b/code/test/data/qbittorrent-bad/downloads/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB/Dir2/test2.zipx deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB/Dir2/test2.zipx +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB.mkv.zipx b/code/test/data/qbittorrent-bad/downloads/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB.mkv.zipx deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB.mkv.zipx +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB/test.zipx b/code/test/data/qbittorrent-bad/downloads/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB/test.zipx deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB/test.zipx +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/The.Wild.Robot.2024.2160p.AMZN.WEB-DL.DDP5.1.Atmos.H.265-FLUX.mkv.zipx b/code/test/data/qbittorrent-bad/downloads/The.Wild.Robot.2024.2160p.AMZN.WEB-DL.DDP5.1.Atmos.H.265-FLUX.mkv.zipx deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/The.Wild.Robot.2024.2160p.AMZN.WEB-DL.DDP5.1.Atmos.H.265-FLUX.mkv.zipx +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM/Dir1/Dir11/test11.zipx b/code/test/data/qbittorrent-bad/downloads/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM/Dir1/Dir11/test11.zipx deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM/Dir1/Dir11/test11.zipx +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM/Dir1/sample.txt b/code/test/data/qbittorrent-bad/downloads/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM/Dir1/sample.txt deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM/Dir1/sample.txt +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM/Dir2/test2.zipx b/code/test/data/qbittorrent-bad/downloads/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM/Dir2/test2.zipx deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM/Dir2/test2.zipx +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM.zipx b/code/test/data/qbittorrent-bad/downloads/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM.zipx deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM.zipx +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM/test.zipx b/code/test/data/qbittorrent-bad/downloads/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM/test.zipx deleted file mode 100644 index 69aeb573..00000000 --- a/code/test/data/qbittorrent-bad/downloads/Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM/test.zipx +++ /dev/null @@ -1 +0,0 @@ -testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/lidarr_bad_pack.torrent b/code/test/data/qbittorrent-bad/downloads/lidarr_bad_pack.torrent deleted file mode 100644 index 91d01ea2..00000000 --- a/code/test/data/qbittorrent-bad/downloads/lidarr_bad_pack.torrent +++ /dev/null @@ -1 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1736513625e4:infod5:filesld6:lengthi640e4:pathl27:coldplay-everyday_life.zipxeed6:lengthi640e4:pathl27:coldplay-everyday_life2.mp3eee4:name31:Coldplay-Everyday.Life-2019-C4/12:piece lengthi262144e6:pieces20:#ฺฏงั4Oduฮoฮ€น[=~ee \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/lidarr_bad_single.torrent b/code/test/data/qbittorrent-bad/downloads/lidarr_bad_single.torrent deleted file mode 100644 index c829a7f3..00000000 --- a/code/test/data/qbittorrent-bad/downloads/lidarr_bad_single.torrent +++ /dev/null @@ -1 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1736513638e4:infod5:filesld6:lengthi640e4:pathl35:001-coldplay-always_in_my_head.zipxeee4:name49:Coldplay-Ghost Stories-(Deluxe Edition)-2014-MTD/12:piece lengthi262144e6:pieces20:ทiV9qๆ “)-xึฉ'ฆศ๒ซee \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/radarr_bad_nested.torrent b/code/test/data/qbittorrent-bad/downloads/radarr_bad_nested.torrent deleted file mode 100644 index 8f738b02..00000000 --- a/code/test/data/qbittorrent-bad/downloads/radarr_bad_nested.torrent +++ /dev/null @@ -1 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1731931728e4:infod5:filesld6:lengthi2604e4:pathl4:Dir15:Dir1111:test11.zipxeed6:lengthi2604e4:pathl4:Dir110:sample.txteed6:lengthi2604e4:pathl4:Dir210:test2.zipxeed6:lengthi2604e4:pathl68:Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB.mkv.zipxeed6:lengthi2604e4:pathl9:test.zipxeee4:name59:Speak.No.Evil.2024.2160p.MA.WEB-DL.DDP5.1.Atmos.H.265-HHWEB12:piece lengthi262144e6:pieces20:wคŸฬณRว'6Fํo๐}ไฐee \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/radarr_bad_single.torrent b/code/test/data/qbittorrent-bad/downloads/radarr_bad_single.torrent deleted file mode 100644 index b4af410f..00000000 --- a/code/test/data/qbittorrent-bad/downloads/radarr_bad_single.torrent +++ /dev/null @@ -1 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1731931618e4:infod6:lengthi2604e4:name70:The.Wild.Robot.2024.2160p.AMZN.WEB-DL.DDP5.1.Atmos.H.265-FLUX.mkv.zipx12:piece lengthi262144e6:pieces20:๛๙@;9fS” โ›E:ฏIแ1ee \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/sonarr_bad_nested.torrent b/code/test/data/qbittorrent-bad/downloads/sonarr_bad_nested.torrent deleted file mode 100644 index 654d40c2..00000000 --- a/code/test/data/qbittorrent-bad/downloads/sonarr_bad_nested.torrent +++ /dev/null @@ -1 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1731931738e4:infod5:filesld6:lengthi2604e4:pathl89:Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos. - Copy.zipxeed6:lengthi2604e4:pathl4:Dir15:Dir1111:test11.zipxeed6:lengthi2604e4:pathl4:Dir110:sample.txteed6:lengthi2604e4:pathl4:Dir210:test2.zipxeed6:lengthi2604e4:pathl9:test.zipxeee4:name88:Agatha.All.Along.S01E01.Seekest.Thou.the.Road.2160p.APPS.WEB-DL.DDP5.1.Atmos.H.265-VARYG12:piece lengthi262144e6:pieces20:wคŸฬณRว'6Fํo๐}ไฐee \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/sonarr_bad_nested_top.torrent b/code/test/data/qbittorrent-bad/downloads/sonarr_bad_nested_top.torrent deleted file mode 100644 index d7f5a45d..00000000 --- a/code/test/data/qbittorrent-bad/downloads/sonarr_bad_nested_top.torrent +++ /dev/null @@ -1 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1732896923e4:infod5:filesld6:lengthi2604e4:pathl4:Dir15:Dir1111:test11.zipxeed6:lengthi2604e4:pathl4:Dir110:sample.txteed6:lengthi2604e4:pathl4:Dir210:test2.zipxeed6:lengthi2604e4:pathl9:test.zipxeed6:lengthi2604e4:pathl49:Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM.zipxeee4:name44:Top.Gear.S23E02.720p.x265.HDTV.HEVC.-.YSTEAM12:piece lengthi262144e6:pieces20:wคŸฬณRว'6Fํo๐}ไฐee \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/sonarr_bad_pack.torrent b/code/test/data/qbittorrent-bad/downloads/sonarr_bad_pack.torrent deleted file mode 100644 index c920381a..00000000 --- a/code/test/data/qbittorrent-bad/downloads/sonarr_bad_pack.torrent +++ /dev/null @@ -1 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1734129464e4:infod5:filesld6:lengthi7e4:pathl47:Sherlock.S01E01.1080p.BluRay.DD5.1.x264-DON.mkveed6:lengthi7e4:pathl47:Sherlock.S01E02.1080p.BluRay.DD5.1.x264-DON.mkveed6:lengthi7e4:pathl47:Sherlock.S01E03.1080p.BluRay.DD5.1.x264-DON.mkveee4:name40:Sherlock.S01.1080p.BluRay.DD5.1.x264-DON12:piece lengthi262144e6:pieces20:/˜Žrฮ่็ƒlY€„ทฐ|ถ7ee \ No newline at end of file diff --git a/code/test/data/qbittorrent-bad/downloads/sonarr_bad_single.torrent b/code/test/data/qbittorrent-bad/downloads/sonarr_bad_single.torrent deleted file mode 100644 index 3924e586..00000000 --- a/code/test/data/qbittorrent-bad/downloads/sonarr_bad_single.torrent +++ /dev/null @@ -1 +0,0 @@ -d8:announce28:http://tracker:6969/announce10:created by26:Enhanced-CTorrent/dnh3.3.213:creation datei1731931604e4:infod6:lengthi2604e4:name126:Agatha.All.Along.S01E02.Circle.Sewn.With.Fate.Unlock.Thy.Hidden.Gate.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv.zipx12:piece lengthi262144e6:pieces20:๛๙@;9fS” โ›E:ฏIแ1ee \ No newline at end of file diff --git a/code/test/data/qbittorrent/config/.ash_history b/code/test/data/qbittorrent/config/.ash_history deleted file mode 100644 index 88f13da7..00000000 --- a/code/test/data/qbittorrent/config/.ash_history +++ /dev/null @@ -1,7 +0,0 @@ -wget http://nginx:8082/bad.torrent -wget http://nginx:80 -wget http://nginx:80/bad.torrent -wget http://nginx:80/bad.rss -wget http://nginx:80/custom/bad.rss -cat bad.rss -exit diff --git a/code/test/data/qbittorrent/config/qBittorrent/BT_backup/queue b/code/test/data/qbittorrent/config/qBittorrent/BT_backup/queue deleted file mode 100644 index 9d2e72c5..00000000 --- a/code/test/data/qbittorrent/config/qBittorrent/BT_backup/queue +++ /dev/null @@ -1 +0,0 @@ -cf82cf859b110af0ad3d94b846e006828417b193 diff --git a/code/test/data/qbittorrent/config/qBittorrent/GeoDB/dbip-country-lite.mmdb b/code/test/data/qbittorrent/config/qBittorrent/GeoDB/dbip-country-lite.mmdb deleted file mode 100644 index 5e65de0b..00000000 Binary files a/code/test/data/qbittorrent/config/qBittorrent/GeoDB/dbip-country-lite.mmdb and /dev/null differ diff --git a/code/test/data/qbittorrent/config/qBittorrent/categories.json b/code/test/data/qbittorrent/config/qBittorrent/categories.json deleted file mode 100644 index 64666a90..00000000 --- a/code/test/data/qbittorrent/config/qBittorrent/categories.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "lidarr": { - "save_path": "" - }, - "radarr": { - "save_path": "" - }, - "tv-sonarr": { - "save_path": "" - } -} diff --git a/code/test/data/qbittorrent/config/qBittorrent/qBittorrent-data.conf b/code/test/data/qbittorrent/config/qBittorrent/qBittorrent-data.conf deleted file mode 100644 index 2b9fb523..00000000 --- a/code/test/data/qbittorrent/config/qBittorrent/qBittorrent-data.conf +++ /dev/null @@ -1,2 +0,0 @@ -[Stats] -AllStats=@Variant(\0\0\0\x1c\0\0\0\x2\0\0\0\x12\0\x41\0l\0l\0t\0i\0m\0\x65\0\x44\0L\0\0\0\x4\0\0\0\0\0!\x9d\x8e\0\0\0\x12\0\x41\0l\0l\0t\0i\0m\0\x65\0U\0L\0\0\0\x4\0\0\0\0\0.\xe6I) diff --git a/code/test/data/qbittorrent/config/qBittorrent/qBittorrent.conf b/code/test/data/qbittorrent/config/qBittorrent/qBittorrent.conf deleted file mode 100644 index 20147343..00000000 --- a/code/test/data/qbittorrent/config/qBittorrent/qBittorrent.conf +++ /dev/null @@ -1,56 +0,0 @@ -[Application] -FileLogger\Age=1 -FileLogger\AgeType=1 -FileLogger\Backup=true -FileLogger\DeleteOld=true -FileLogger\Enabled=true -FileLogger\MaxSizeBytes=66560 -FileLogger\Path=/config/qBittorrent/logs - -[AutoRun] -enabled=false -program= - -[BitTorrent] -ExcludedFileNamesEnabled=false -Session\AddTorrentStopped=false -Session\DefaultSavePath=/downloads/ -Session\ExcludedFileNames=*.apk, *.bat, *.bin, *.bmp, *.cmd, *.com, *.db, *.diz, *.dll, *.dmg, *.etc, *.exe, *.gif, *.htm, *.html, *.ico, *.ini, *.iso, *.jar, *.jpg, *.js, *.link, *.lnk, *.msi, *.nfo, *.perl, *.php, *.pl, *.png, *.ps1, *.psc1, *.psd1, *.psm1, *.py, *.pyd, *.rb, *.readme, *.reg, *.run, *.scr, *.sh, *.sql, *.text, *.thumb, *.torrent, *.txt, *.url, *.vbs, *.wsf, *.xml, *.zipx, *.7z, *.bdjo, *.bdmv, *.bin, *.bmp, *.cci, *.clpi, *.crt, *.dll, *.exe, *.html, *.idx, *.inf, *.jar, *.jpeg, *.jpg, *.lnk, *.m4a, *.mpls, *.msi, *.nfo, *.pdf, *.png, *.rar, *(sample).*, *sample.mkv, *sample.mp4, *.sfv, *.srt, *.sub, *.tbl, Trailer.*, *.txt, *.url, *.xig, *.xml, *.xrt, *.zip, *.zipx, *.Lnk -Session\Port=6881 -Session\QueueingSystemEnabled=true -Session\SSL\Port=15561 -Session\ShareLimitAction=Stop -Session\TempPath=/downloads/incomplete/ - -[Core] -AutoDeleteAddedTorrentFile=Never - -[LegalNotice] -Accepted=true - -[Meta] -MigrationVersion=6 - -[Network] -Cookies=@Invalid() -PortForwardingEnabled=true -Proxy\HostnameLookupEnabled=false -Proxy\Profiles\BitTorrent=true -Proxy\Profiles\Misc=true -Proxy\Profiles\RSS=true - -[Preferences] -Connection\PortRangeMin=6881 -Connection\UPnP=false -Downloads\SavePath=/downloads/ -Downloads\TempPath=/downloads/incomplete/ -General\Locale=en -MailNotification\req_auth=true -WebUI\Address=* -WebUI\Password_PBKDF2="@ByteArray(yhRK9ENcAXgJ5b0HJ1ASwg==:ucqSEDxil3NqJlug8G4PjBXAz37Azo42jx8Vh3RtNkCYEK4RgjRmMeiUaIN9k4Pqxi7D1aBBVFOQ9vQJZMfUIQ==)" -WebUI\ServerDomains=* -WebUI\Username=test - -[RSS] -AutoDownloader\DownloadRepacks=true -AutoDownloader\SmartEpisodeFilter=s(\\d+)e(\\d+), (\\d+)x(\\d+), "(\\d{4}[.\\-]\\d{1,2}[.\\-]\\d{1,2})", "(\\d{1,2}[.\\-]\\d{1,2}[.\\-]\\d{4})" diff --git a/code/test/data/qbittorrent/config/qBittorrent/rss/feeds.json b/code/test/data/qbittorrent/config/qBittorrent/rss/feeds.json deleted file mode 100644 index 2c63c085..00000000 --- a/code/test/data/qbittorrent/config/qBittorrent/rss/feeds.json +++ /dev/null @@ -1,2 +0,0 @@ -{ -} diff --git a/code/test/data/qbittorrent/config/qBittorrent/watched_folders.json b/code/test/data/qbittorrent/config/qBittorrent/watched_folders.json deleted file mode 100644 index 2c63c085..00000000 --- a/code/test/data/qbittorrent/config/qBittorrent/watched_folders.json +++ /dev/null @@ -1,2 +0,0 @@ -{ -} diff --git a/code/test/data/radarr/config/Sentry/E052B02F117E6BB423BE301CDA607148F4B3F8F6/.installation b/code/test/data/radarr/config/Sentry/E052B02F117E6BB423BE301CDA607148F4B3F8F6/.installation deleted file mode 100644 index 65b5fcbc..00000000 --- a/code/test/data/radarr/config/Sentry/E052B02F117E6BB423BE301CDA607148F4B3F8F6/.installation +++ /dev/null @@ -1 +0,0 @@ -92eba3c5-a8d0-44d5-836d-25bc4aa81a85 \ No newline at end of file diff --git a/code/test/data/radarr/config/Sentry/E052B02F117E6BB423BE301CDA607148F4B3F8F6/.session b/code/test/data/radarr/config/Sentry/E052B02F117E6BB423BE301CDA607148F4B3F8F6/.session deleted file mode 100644 index 4a19a599..00000000 --- a/code/test/data/radarr/config/Sentry/E052B02F117E6BB423BE301CDA607148F4B3F8F6/.session +++ /dev/null @@ -1 +0,0 @@ -{"update":{"sid":"743459ae24ef4f4c8a85171b21fd99a8","did":"92eba3c5-a8d0-44d5-836d-25bc4aa81a85","init":true,"started":"2024-11-29T15:46:38.3721409+00:00","timestamp":"2024-11-29T15:46:38.3728803+00:00","seq":0,"duration":0,"errors":0,"attrs":{"release":"Radarr@5.14.0.9383-master","environment":"master"}}} \ No newline at end of file diff --git a/code/test/data/radarr/config/asp/key-729140b3-0296-4e14-8afa-60275fd797ca.xml b/code/test/data/radarr/config/asp/key-729140b3-0296-4e14-8afa-60275fd797ca.xml deleted file mode 100644 index 627eafa4..00000000 --- a/code/test/data/radarr/config/asp/key-729140b3-0296-4e14-8afa-60275fd797ca.xml +++ /dev/null @@ -1,16 +0,0 @@ -๏ปฟ - - 2024-11-12T08:27:39.8894479Z - 2024-11-12T08:27:39.879535Z - 2025-02-10T08:27:39.879535Z - - - - - - - aq0fbIABPzsLl4bnZllVq2NhmsOrjc5zPeiGbBSTc5DMPm8n5C86DzCTPX0HJtZFUgaVoc+3qjFQJ4UB0J31rA== - - - - \ No newline at end of file diff --git a/code/test/data/radarr/config/config.xml b/code/test/data/radarr/config/config.xml deleted file mode 100644 index 0bf6aa0c..00000000 --- a/code/test/data/radarr/config/config.xml +++ /dev/null @@ -1,17 +0,0 @@ - - * - 7878 - 9898 - False - True - 8b7454f668e54c5b8f44f56f93969761 - Forms - Enabled - master - debug - - - - Radarr - Docker - \ No newline at end of file diff --git a/code/test/data/radarr/config/logs.db b/code/test/data/radarr/config/logs.db deleted file mode 100644 index c9c10305..00000000 Binary files a/code/test/data/radarr/config/logs.db and /dev/null differ diff --git a/code/test/data/radarr/config/logs.db-shm b/code/test/data/radarr/config/logs.db-shm deleted file mode 100644 index d8cff9df..00000000 Binary files a/code/test/data/radarr/config/logs.db-shm and /dev/null differ diff --git a/code/test/data/radarr/config/logs.db-wal b/code/test/data/radarr/config/logs.db-wal deleted file mode 100644 index 9db9795a..00000000 Binary files a/code/test/data/radarr/config/logs.db-wal and /dev/null differ diff --git a/code/test/data/radarr/config/radarr.db b/code/test/data/radarr/config/radarr.db deleted file mode 100644 index 6a160d1c..00000000 Binary files a/code/test/data/radarr/config/radarr.db and /dev/null differ diff --git a/code/test/data/radarr/config/radarr.pid b/code/test/data/radarr/config/radarr.pid deleted file mode 100644 index 70e1a64c..00000000 --- a/code/test/data/radarr/config/radarr.pid +++ /dev/null @@ -1 +0,0 @@ -144 \ No newline at end of file diff --git a/code/test/data/readarr/config/asp/key-7e009a79-6fb8-4487-a701-c71768df1f34.xml b/code/test/data/readarr/config/asp/key-7e009a79-6fb8-4487-a701-c71768df1f34.xml deleted file mode 100644 index 3a89c0dc..00000000 --- a/code/test/data/readarr/config/asp/key-7e009a79-6fb8-4487-a701-c71768df1f34.xml +++ /dev/null @@ -1,16 +0,0 @@ -๏ปฟ - - 2024-11-12T08:29:09.621896Z - 2024-11-12T08:29:09.6125365Z - 2025-02-10T08:29:09.6125365Z - - - - - - - 4XVtakA4x+z0lkubqw0sO0dANs6WlDqehgdJUaaf0W9u/lIIq404B1HhVEs+fOpiBuyJDBpjbauLC9KlAfj8NA== - - - - \ No newline at end of file diff --git a/code/test/data/readarr/config/cache.db b/code/test/data/readarr/config/cache.db deleted file mode 100644 index 23727b5d..00000000 Binary files a/code/test/data/readarr/config/cache.db and /dev/null differ diff --git a/code/test/data/readarr/config/config.xml b/code/test/data/readarr/config/config.xml deleted file mode 100644 index 103b7ba7..00000000 --- a/code/test/data/readarr/config/config.xml +++ /dev/null @@ -1,17 +0,0 @@ - - * - 8787 - 6868 - False - True - 53388ac405894ef2ac6b82f907f481aa - Forms - Enabled - develop - debug - - - - Readarr - Docker - \ No newline at end of file diff --git a/code/test/data/readarr/config/logs.db b/code/test/data/readarr/config/logs.db deleted file mode 100644 index 6e87350e..00000000 Binary files a/code/test/data/readarr/config/logs.db and /dev/null differ diff --git a/code/test/data/readarr/config/logs.db-shm b/code/test/data/readarr/config/logs.db-shm deleted file mode 100644 index 325e7c95..00000000 Binary files a/code/test/data/readarr/config/logs.db-shm and /dev/null differ diff --git a/code/test/data/readarr/config/logs.db-wal b/code/test/data/readarr/config/logs.db-wal deleted file mode 100644 index c685061d..00000000 Binary files a/code/test/data/readarr/config/logs.db-wal and /dev/null differ diff --git a/code/test/data/readarr/config/readarr.db b/code/test/data/readarr/config/readarr.db deleted file mode 100644 index 9aaeb25f..00000000 Binary files a/code/test/data/readarr/config/readarr.db and /dev/null differ diff --git a/code/test/data/readarr/config/readarr.pid b/code/test/data/readarr/config/readarr.pid deleted file mode 100644 index 70e1a64c..00000000 --- a/code/test/data/readarr/config/readarr.pid +++ /dev/null @@ -1 +0,0 @@ -144 \ No newline at end of file diff --git a/code/test/data/sonarr/config/Sentry/07ADDC43B5669C4F6DB64F2EF2B23B3FEEDFE865/.installation b/code/test/data/sonarr/config/Sentry/07ADDC43B5669C4F6DB64F2EF2B23B3FEEDFE865/.installation deleted file mode 100644 index 5f7b114f..00000000 --- a/code/test/data/sonarr/config/Sentry/07ADDC43B5669C4F6DB64F2EF2B23B3FEEDFE865/.installation +++ /dev/null @@ -1 +0,0 @@ -1df9f2cc-17dc-4130-9753-9b694f82f1b5 \ No newline at end of file diff --git a/code/test/data/sonarr/config/Sentry/07ADDC43B5669C4F6DB64F2EF2B23B3FEEDFE865/.session b/code/test/data/sonarr/config/Sentry/07ADDC43B5669C4F6DB64F2EF2B23B3FEEDFE865/.session deleted file mode 100644 index 0644f275..00000000 --- a/code/test/data/sonarr/config/Sentry/07ADDC43B5669C4F6DB64F2EF2B23B3FEEDFE865/.session +++ /dev/null @@ -1 +0,0 @@ -{"update":{"sid":"4ee000d424144e078e7f3ef208e30647","did":"1df9f2cc-17dc-4130-9753-9b694f82f1b5","init":true,"started":"2024-12-13T22:41:57.8197572+00:00","timestamp":"2024-12-13T22:41:57.8202577+00:00","seq":0,"duration":0,"errors":0,"attrs":{"release":"4.0.10.2544-main","environment":"main"}}} \ No newline at end of file diff --git a/code/test/data/sonarr/config/asp/key-460837be-4d61-409f-95f8-b78f2a65ed81.xml b/code/test/data/sonarr/config/asp/key-460837be-4d61-409f-95f8-b78f2a65ed81.xml deleted file mode 100644 index ad96c2b3..00000000 --- a/code/test/data/sonarr/config/asp/key-460837be-4d61-409f-95f8-b78f2a65ed81.xml +++ /dev/null @@ -1,16 +0,0 @@ -๏ปฟ - - 2024-11-10T19:27:03.0013963Z - 2024-11-10T19:27:02.9916656Z - 2025-02-08T19:27:02.9916656Z - - - - - - - N6KEU+20is+M3ZH+mi+TYVIjTes0zQ8MJHE7npaP3B8FM8jN+5tMp3SKnu6II2jdWybEvBjAvoycoaRDRsDnZQ== - - - - \ No newline at end of file diff --git a/code/test/data/sonarr/config/config.xml b/code/test/data/sonarr/config/config.xml deleted file mode 100644 index 53d50b5c..00000000 --- a/code/test/data/sonarr/config/config.xml +++ /dev/null @@ -1,17 +0,0 @@ - - * - 8989 - 9898 - False - True - 425d1e713f0c405cbbf359ac0502c1f4 - Forms - Enabled - main - debug - - - - Sonarr - Docker - \ No newline at end of file diff --git a/code/test/data/sonarr/config/logs.db b/code/test/data/sonarr/config/logs.db deleted file mode 100644 index 1198ce52..00000000 Binary files a/code/test/data/sonarr/config/logs.db and /dev/null differ diff --git a/code/test/data/sonarr/config/logs.db-shm b/code/test/data/sonarr/config/logs.db-shm deleted file mode 100644 index 9558dac0..00000000 Binary files a/code/test/data/sonarr/config/logs.db-shm and /dev/null differ diff --git a/code/test/data/sonarr/config/logs.db-wal b/code/test/data/sonarr/config/logs.db-wal deleted file mode 100644 index 443f505f..00000000 Binary files a/code/test/data/sonarr/config/logs.db-wal and /dev/null differ diff --git a/code/test/data/sonarr/config/sonarr.db b/code/test/data/sonarr/config/sonarr.db deleted file mode 100644 index 7802f0f1..00000000 Binary files a/code/test/data/sonarr/config/sonarr.db and /dev/null differ diff --git a/code/test/data/sonarr/config/sonarr.db-shm b/code/test/data/sonarr/config/sonarr.db-shm deleted file mode 100644 index f5c93f42..00000000 Binary files a/code/test/data/sonarr/config/sonarr.db-shm and /dev/null differ diff --git a/code/test/data/sonarr/config/sonarr.db-wal b/code/test/data/sonarr/config/sonarr.db-wal deleted file mode 100644 index ebd30864..00000000 Binary files a/code/test/data/sonarr/config/sonarr.db-wal and /dev/null differ diff --git a/code/test/data/sonarr/config/sonarr.pid b/code/test/data/sonarr/config/sonarr.pid deleted file mode 100644 index 70e1a64c..00000000 --- a/code/test/data/sonarr/config/sonarr.pid +++ /dev/null @@ -1 +0,0 @@ -144 \ No newline at end of file diff --git a/code/test/data/transmission/config/dht.dat b/code/test/data/transmission/config/dht.dat deleted file mode 100644 index 0f005239..00000000 Binary files a/code/test/data/transmission/config/dht.dat and /dev/null differ diff --git a/code/test/data/transmission/config/settings.json b/code/test/data/transmission/config/settings.json deleted file mode 100644 index 67c88321..00000000 --- a/code/test/data/transmission/config/settings.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "alt-speed-down": 50, - "alt-speed-enabled": false, - "alt-speed-time-begin": 540, - "alt-speed-time-day": 127, - "alt-speed-time-enabled": false, - "alt-speed-time-end": 1020, - "alt-speed-up": 50, - "announce-ip": "", - "announce-ip-enabled": false, - "anti-brute-force-enabled": false, - "anti-brute-force-threshold": 100, - "bind-address-ipv4": "0.0.0.0", - "bind-address-ipv6": "::", - "blocklist-enabled": false, - "blocklist-url": "http://www.example.com/blocklist", - "cache-size-mb": 4, - "default-trackers": "", - "dht-enabled": true, - "download-dir": "/downloads/complete", - "download-queue-enabled": true, - "download-queue-size": 5, - "encryption": 1, - "idle-seeding-limit": 30, - "idle-seeding-limit-enabled": false, - "incomplete-dir": "/downloads/incomplete", - "incomplete-dir-enabled": true, - "lpd-enabled": false, - "message-level": 2, - "peer-congestion-algorithm": "", - "peer-id-ttl-hours": 6, - "peer-limit-global": 200, - "peer-limit-per-torrent": 50, - "peer-port": 51413, - "peer-port-random-high": 65535, - "peer-port-random-low": 49152, - "peer-port-random-on-start": false, - "peer-socket-tos": "le", - "pex-enabled": true, - "port-forwarding-enabled": true, - "preallocation": 1, - "prefetch-enabled": true, - "queue-stalled-enabled": true, - "queue-stalled-minutes": 30, - "ratio-limit": 2, - "ratio-limit-enabled": false, - "rename-partial-files": true, - "rpc-authentication-required": false, - "rpc-bind-address": "0.0.0.0", - "rpc-enabled": true, - "rpc-host-whitelist": "", - "rpc-host-whitelist-enabled": false, - "rpc-password": "{cbb7a35b753789796ced190a807c3aa23d6296aeG5UaQOmv", - "rpc-port": 9091, - "rpc-socket-mode": "0750", - "rpc-url": "/transmission/", - "rpc-username": "", - "rpc-whitelist": "", - "rpc-whitelist-enabled": false, - "scrape-paused-torrents-enabled": true, - "script-torrent-added-enabled": false, - "script-torrent-added-filename": "", - "script-torrent-done-enabled": false, - "script-torrent-done-filename": "", - "script-torrent-done-seeding-enabled": false, - "script-torrent-done-seeding-filename": "", - "seed-queue-enabled": false, - "seed-queue-size": 10, - "speed-limit-down": 100, - "speed-limit-down-enabled": false, - "speed-limit-up": 100, - "speed-limit-up-enabled": false, - "start-added-torrents": true, - "tcp-enabled": true, - "torrent-added-verify-mode": "fast", - "trash-original-torrent-files": false, - "umask": "002", - "upload-slots-per-torrent": 14, - "utp-enabled": false, - "watch-dir": "/watch", - "watch-dir-enabled": true -} diff --git a/code/test/data/transmission/config/stats.json b/code/test/data/transmission/config/stats.json deleted file mode 100644 index 0223e479..00000000 --- a/code/test/data/transmission/config/stats.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "downloaded-bytes": 109368, - "files-added": 42, - "seconds-active": 55843, - "session-count": 2, - "uploaded-bytes": 0 -} diff --git a/code/test/docker-compose.yml b/code/test/docker-compose.yml deleted file mode 100644 index f592b06c..00000000 --- a/code/test/docker-compose.yml +++ /dev/null @@ -1,307 +0,0 @@ - -# user: test -# pass: testing - -# use this to create torrent files -# docker run --rm -it -v $(pwd)/data/qbittorrent-bad/downloads:/downloads --name debian debian:bookworm-slim -# apt update && apt install ctorrent -# ctorrent -t -u "http://tracker:6969/announce" -s example.torrent file_name - -# api keys -# sonarr: 425d1e713f0c405cbbf359ac0502c1f4 -# radarr: 8b7454f668e54c5b8f44f56f93969761 -# lidarr: 7f677cfdc074414397af53dd633860c5 -# readarr: 53388ac405894ef2ac6b82f907f481aa - -services: - qbittorrent: - image: lscr.io/linuxserver/qbittorrent:4.6.7-libtorrentv1 - container_name: qbittorrent - environment: - - PUID=1000 - - PGID=1000 - - TZ=Europe/London - - WEBUI_PORT=8080 - volumes: - - ./data/qbittorrent/config:/config - - ./data/qbittorrent/downloads:/downloads - ports: - - 8080:8080 - - 6881:6881 - - 6881:6881/udp - restart: unless-stopped - - qbittorrent-bad: - image: lscr.io/linuxserver/qbittorrent:4.6.7-libtorrentv1 - container_name: qbittorrent-bad - environment: - - PUID=1000 - - PGID=1000 - - TZ=Europe/London - - WEBUI_PORT=8081 - volumes: - - ./data/qbittorrent-bad/config:/config - - ./data/qbittorrent-bad/downloads:/downloads - ports: - - 8081:8081 - - 6882:6881 - - 6882:6881/udp - restart: unless-stopped - - deluge: - image: lscr.io/linuxserver/deluge:latest - container_name: deluge - environment: - - PUID=1000 - - PGID=1000 - - TZ=Europe/London - volumes: - - ./data/deluge/config:/config - - ./data/deluge/downloads:/downloads - ports: - - 8112:8112 - - 6883:6881 - - 6883:6881/udp - - 58846:58846 - restart: unless-stopped - - transmission: - image: lscr.io/linuxserver/transmission:latest - container_name: transmission - environment: - - PUID=1000 - - PGID=1000 - - TZ=Europe/London - - TRANSMISSION_RPC_AUTHENTICATION_REQUIRED=true - - TRANSMISSION_RPC_USERNAME=test - - TRANSMISSION_RPC_PASSWORD=testing - - TRANSMISSION_RPC_PORT=9091 - - TRANSMISSION_WEB_HOME=/usr/share/transmission/public_html - ports: - - 9091:9091 - - 51413:51413 - - 51413:51413/udp - volumes: - - ./data/transmission/config:/config - - ./data/transmission/downloads:/downloads - restart: unless-stopped - - tracker: - image: wiltonsr/opentracker:open - container_name: opentracker - ports: - - 6969:6969/tcp - - 6969:6969/udp - restart: unless-stopped - - nginx: - image: nginx:latest - container_name: nginx - volumes: - - ./data/nginx:/usr/share/nginx/html/custom - ports: - - 8082:80 - restart: unless-stopped - - sonarr: - image: lscr.io/linuxserver/sonarr:latest - container_name: sonarr - environment: - - PUID=1000 - - PGID=1000 - - TZ=Europe/London - volumes: - - ./data/sonarr/config:/config - - ./data/sonarr/tv:/tv - - ./data/qbittorrent/downloads:/downloads - # - ./data/deluge/downloads:/downloads - # - ./data/transmission/downloads:/downloads - ports: - - 8989:8989 - restart: unless-stopped - - radarr: - image: lscr.io/linuxserver/radarr:latest - container_name: radarr - environment: - - PUID=1000 - - PGID=1000 - - TZ=Europe/London - volumes: - - ./data/radarr/config:/config - - ./data/radarr/movies:/movies - - ./data/qbittorrent/downloads:/downloads - # - ./data/deluge/downloads:/downloads - # - ./data/transmission/downloads:/downloads - ports: - - 7878:7878 - restart: unless-stopped - - lidarr: - image: lscr.io/linuxserver/lidarr:latest - container_name: lidarr - environment: - - PUID=1000 - - PGID=1000 - - TZ=Europe/London - volumes: - - ./data/lidarr/config:/config - - ./data/lidarr/music:/music - - ./data/qbittorrent/downloads:/downloads - # - ./data/deluge/downloads:/downloads - # - ./data/transmission/downloads:/downloads - ports: - - 8686:8686 - restart: unless-stopped - - readarr: - image: lscr.io/linuxserver/readarr:develop - container_name: readarr - environment: - - PUID=1000 - - PGID=1000 - - TZ=Europe/London - volumes: - - ./data/readarr/config:/config - - ./data/readarr/books:/books - - ./data/qbittorrent/downloads:/downloads - # - ./data/deluge/downloads:/downloads - # - ./data/transmission/downloads:/downloads - ports: - - 8787:8787 - restart: unless-stopped - - cleanuperr: - image: ghcr.io/flmorg/cleanuperr:latest - container_name: cleanuperr - environment: - - TZ=Europe/Bucharest - - DRY_RUN=false - - - LOGGING__LOGLEVEL=Verbose - - LOGGING__FILE__ENABLED=true - - LOGGING__FILE__PATH=/var/logs - - LOGGING__ENHANCED=true - - - HTTP_MAX_RETRIES=0 - - HTTP_TIMEOUT=20 - - - SEARCH_ENABLED=true - - SEARCH_DELAY=5 - - - TRIGGERS__QUEUECLEANER=0/30 * * * * ? - - TRIGGERS__CONTENTBLOCKER=0/30 * * * * ? - - TRIGGERS__DOWNLOADCLEANER=0/30 * * * * ? - - - QUEUECLEANER__ENABLED=true - - QUEUECLEANER__IGNORED_DOWNLOADS_PATH=/ignored - - QUEUECLEANER__RUNSEQUENTIALLY=true - - - QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES=3 - - QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE=true - - QUEUECLEANER__IMPORT_FAILED_DELETE_PRIVATE=false - - QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__0=file is a sample - - - QUEUECLEANER__STALLED_MAX_STRIKES=3 - - QUEUECLEANER__STALLED_RESET_STRIKES_ON_PROGRESS=true - - QUEUECLEANER__STALLED_IGNORE_PRIVATE=true - - QUEUECLEANER__STALLED_DELETE_PRIVATE=false - - QUEUECLEANER__DOWNLOADING_METADATA_MAX_STRIKES=3 - - - QUEUECLEANER__SLOW_MAX_STRIKES=5 - - QUEUECLEANER__SLOW_RESET_STRIKES_ON_PROGRESS=true - - QUEUECLEANER__SLOW_IGNORE_PRIVATE=false - - QUEUECLEANER__SLOW_DELETE_PRIVATE=false - - QUEUECLEANER__SLOW_MIN_SPEED=1MB - - QUEUECLEANER__SLOW_MAX_TIME=20 - - QUEUECLEANER__SLOW_IGNORE_ABOVE_SIZE=1KB - - - CONTENTBLOCKER__ENABLED=true - - CONTENTBLOCKER__IGNORED_DOWNLOADS_PATH=/ignored - - CONTENTBLOCKER__IGNORE_PRIVATE=true - - CONTENTBLOCKER__DELETE_PRIVATE=false - - - DOWNLOADCLEANER__ENABLED=true - - DOWNLOADCLEANER__IGNORED_DOWNLOADS_PATH=/ignored - - DOWNLOADCLEANER__DELETE_PRIVATE=false - - - DOWNLOADCLEANER__CATEGORIES__0__NAME=tv-sonarr - - DOWNLOADCLEANER__CATEGORIES__0__MAX_RATIO=-1 - - DOWNLOADCLEANER__CATEGORIES__0__MIN_SEED_TIME=0 - - DOWNLOADCLEANER__CATEGORIES__0__MAX_SEED_TIME=99999 - - DOWNLOADCLEANER__CATEGORIES__1__NAME=cleanuperr-unlinked - - DOWNLOADCLEANER__CATEGORIES__1__MAX_RATIO=-1 - - DOWNLOADCLEANER__CATEGORIES__1__MIN_SEED_TIME=0 - - DOWNLOADCLEANER__CATEGORIES__1__MAX_SEED_TIME=99999 - - - DOWNLOADCLEANER__UNLINKED_TARGET_CATEGORY=cleanuperr-unlinked - - DOWNLOADCLEANER__UNLINKED_USE_TAG=false - - DOWNLOADCLEANER__UNLINKED_IGNORED_ROOT_DIR=/downloads - - DOWNLOADCLEANER__UNLINKED_CATEGORIES__0=tv-sonarr - - DOWNLOADCLEANER__UNLINKED_CATEGORIES__1=radarr - - - DOWNLOAD_CLIENT=qbittorrent - - QBITTORRENT__URL=http://qbittorrent:8080 - - QBITTORRENT__USERNAME=test - - QBITTORRENT__PASSWORD=testing - # OR - # - DOWNLOAD_CLIENT=deluge - # - DELUGE__URL=http://deluge:8112 - # - DELUGE__PASSWORD=testing - # OR - # - DOWNLOAD_CLIENT=transmission - # - TRANSMISSION__URL=http://transmission:9091 - # - TRANSMISSION__USERNAME=test - # - TRANSMISSION__PASSWORD=testing - - - SONARR__ENABLED=true - - SONARR__IMPORT_FAILED_MAX_STRIKES=-1 - - SONARR__SEARCHTYPE=Episode - - SONARR__BLOCK__TYPE=blacklist - - SONARR__BLOCK__PATH=https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist - - SONARR__INSTANCES__0__URL=http://sonarr:8989 - - SONARR__INSTANCES__0__APIKEY=425d1e713f0c405cbbf359ac0502c1f4 - - - RADARR__ENABLED=true - - RADARR__IMPORT_FAILED_MAX_STRIKES=-1 - - RADARR__BLOCK__TYPE=blacklist - - RADARR__BLOCK__PATH=https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist - - RADARR__INSTANCES__0__URL=http://radarr:7878 - - RADARR__INSTANCES__0__APIKEY=8b7454f668e54c5b8f44f56f93969761 - - - LIDARR__ENABLED=true - - LIDARR__IMPORT_FAILED_MAX_STRIKES=-1 - - LIDARR__BLOCK__TYPE=blacklist - - LIDARR__BLOCK__PATH=https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist # TODO - - LIDARR__INSTANCES__0__URL=http://lidarr:8686 - - LIDARR__INSTANCES__0__APIKEY=7f677cfdc074414397af53dd633860c5 - - # - NOTIFIARR__ON_IMPORT_FAILED_STRIKE=true - # - NOTIFIARR__ON_STALLED_STRIKE=true - # - NOTIFIARR__ON_SLOW_STRIKE=true - # - NOTIFIARR__ON_QUEUE_ITEM_DELETED=true - # - NOTIFIARR__ON_DOWNLOAD_CLEANED=true - # - NOTIFIARR__ON_CATEGORY_CHANGED=true - # - NOTIFIARR__API_KEY=notifiarr_secret - # - NOTIFIARR__CHANNEL_ID=discord_channel_id - - # - APPRISE__ON_IMPORT_FAILED_STRIKE=true - # - APPRISE__ON_STALLED_STRIKE=true - # - APPRISE__ON_SLOW_STRIKE=true - # - APPRISE__ON_QUEUE_ITEM_DELETED=true - # - APPRISE__ON_DOWNLOAD_CLEANED=true - # - APPRISE__URL=http://localhost:8000 - # - APPRISE__KEY=mykey - volumes: - - ./data/cleanuperr/logs:/var/logs - - ./data/cleanuperr/ignored_downloads:/ignored - - ./data/qbittorrent/downloads:/downloads - restart: unless-stopped - depends_on: - - qbittorrent - - deluge - - transmission - - sonarr - - radarr - - lidarr - - readarr \ No newline at end of file diff --git a/docs/docs/2_features.mdx b/docs/docs/2_features.mdx index 25e84a02..c9ab0e6b 100644 --- a/docs/docs/2_features.mdx +++ b/docs/docs/2_features.mdx @@ -3,19 +3,130 @@ sidebar_position: 2 --- import Link from '@docusaurus/Link'; +import { + ConfigSection, + styles +} from '@site/src/components/documentation'; # Features -
- - Strike system to mark bad downloads. - - Remove and block downloads that reached a maximum number of strikes. - - Remove and block downloads that are **failing to be imported** by the arrs. [configuration] - - Remove and block downloads that are **stalled** or in **metadata downloading** state. [configuration] - - Remove and block downloads that have a **low download speed** or **high estimated completion time**. [configuration] - - Remove and block downloads blocked by qBittorrent or by Cleanuperr's **Content Blocker**. [configuration] - - Automatically trigger a search for downloads removed from the arrs. - - Clean up downloads that have been **seeding** for a certain amount of time. [configuration] - - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support). [configuration] - - Notify on strike or download removal. [configuration] - - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuperr. +Comprehensive download management and automation features for your *arr applications and download clients. + +
+ +
+ + + +- Strike system to mark bad downloads. +- Remove and block downloads that reached a maximum number of strikes. + + + + + +- Remove and block downloads that are **failing to be imported** by the arrs. + + + + + +- Remove and block downloads that are **stalled** or in **metadata downloading** state. + + + + + +- Remove and block downloads that have a **low download speed** or **high estimated completion time**. + + + + + +- Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Content Blocker**. + + + + + +- Automatically trigger a search for downloads removed from the arrs. + + + + + +- Clean up downloads that have been **seeding** for a certain amount of time. + + + + + +- Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support). + + + + + +- Notify on strike or download removal. + + + + + +- Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr. + + + +
+
\ No newline at end of file diff --git a/docs/docs/3_supported-apps.mdx b/docs/docs/3_supported-apps.mdx index 1cbbbac6..adbaf8b5 100644 --- a/docs/docs/3_supported-apps.mdx +++ b/docs/docs/3_supported-apps.mdx @@ -1,13 +1,28 @@ --- -sidebar_position: 2 +sidebar_position: 3 --- -# Supported apps +import { + styles +} from '@site/src/components/documentation'; -Only the **latest versions** of the following apps are supported, or earlier versions that have the same API as the latest version: -- qBittorrent -- Deluge -- Transmission -- Sonarr -- Radarr -- Lidarr \ No newline at end of file +# Supported Apps + +Cleanuparr integrates with popular *arr applications and download clients for comprehensive media management. + +
+ +
+ +
+ +| **Category** | **Application** | **Integration** | +|--------------|-----------------|-----------------| +| **Media Management** | Sonarr, Radarr, Lidarr, Readarr, Whisparr | Full API integration for queue monitoring and search triggers | +| **Download Clients** | qBittorrent, Deluge, Transmission | Complete download management and monitoring | + +
+ +
+ +
\ No newline at end of file diff --git a/docs/docs/4_how_it_works.mdx b/docs/docs/4_how_it_works.mdx index 6f30cff1..c93cf7fb 100644 --- a/docs/docs/4_how_it_works.mdx +++ b/docs/docs/4_how_it_works.mdx @@ -2,35 +2,74 @@ sidebar_position: 4 --- +import { + ConfigSection, + styles +} from '@site/src/components/documentation'; + # How it works This is a detailed explanation of how the recurring cleanup jobs work. -#### 1. **Content blocker** will: - - Run every 5 minutes (or configured cron). - - Process all items in the *arr queue. - - Find the corresponding item from the download client for each queue item. - - Mark the files that were found in the queue as **unwanted/skipped** if: - - They **are listed in the blacklist**, or - - They **are not included in the whitelist**. - - If **all files** of a download **are unwanted**: - - It will be removed from the *arr's queue and blocked. - - It will be deleted from the download client. - - A new search will be triggered for the *arr item. -#### 2. **Queue cleaner** will: - - Run every 5 minutes (or configured cron, or right after `Content Blocker`). - - Process all items in the *arr queue. - - Check each queue item if it is **stalled (download speed is 0)**, **stuck in metadata downloading**, **failed to be imported** or **slow**. - - If it is, the item receives a **strike** and will continue to accumulate strikes every time it meets any of these conditions. - - Check each queue item if it meets one of the following condition in the download client: - - **Marked as completed, but 0 bytes have been downloaded** (due to files being blocked by qBittorrent or the **content blocker**). - - All associated files are marked as **unwanted/skipped/do not download**. - - If the item **DOES NOT** match the above criteria, it will be skipped. - - If the item **DOES** match the criteria or has received the **maximum number of strikes**: - - It will be removed from the *arr's queue and blocked. - - It will be deleted from the download client. - - A new search will be triggered for the *arr item. -#### 3. **Download cleaner** will: - - Run every hour (or configured cron). - - Automatically clean up downloads that have been seeding for a certain amount of time. - - Automatically changes the category of downloads that have no hardlinks. \ No newline at end of file +
+ +
+ + + +- Run every 5 minutes (or configured cron). +- Process all items in the *arr queue. +- Find the corresponding item from the download client for each queue item. +- Mark the files that were found in the queue as **unwanted/skipped** if: + - They **are listed in the blacklist**, or + - They **are not included in the whitelist**. +- If **all files** of a download **are unwanted**: + - It will be removed from the *arr's queue and blocked. + - It will be deleted from the download client. + - A new search will be triggered for the *arr item. + + + + + +- Run every 5 minutes (or configured cron, or right after `Content Blocker`). +- Process all items in the *arr queue. +- Check each queue item if it is **stalled (download speed is 0)**, **stuck in metadata downloading**, **failed to be imported** or **slow**. + - If it is, the item receives a **strike** and will continue to accumulate strikes every time it meets any of these conditions. +- Check each queue item if it meets one of the following condition in the download client: + - **Marked as completed, but 0 bytes have been downloaded** (due to files being blocked by qBittorrent or the **content blocker**). + - All associated files are marked as **unwanted/skipped/do not download**. +- If the item **DOES NOT** match the above criteria, it will be skipped. +- If the item **DOES** match the criteria or has received the **maximum number of strikes**: + - It will be removed from the *arr's queue and blocked. + - It will be deleted from the download client. + - A new search will be triggered for the *arr item. + + + + + +- Run every hour (or configured cron). +- Automatically clean up downloads that have been seeding for a certain amount of time. +- Automatically changes the category of downloads that have no hardlinks. + + + +
+ +
\ No newline at end of file diff --git a/docs/docs/configuration/1_general.mdx b/docs/docs/configuration/1_general.mdx deleted file mode 100644 index 73aba914..00000000 --- a/docs/docs/configuration/1_general.mdx +++ /dev/null @@ -1,11 +0,0 @@ ---- -sidebar_position: 2 ---- - -import GeneralSettings from '@site/src/components/configuration/GeneralSettings'; - -# General settings - -These are the general configuration settings that apply to the entire application. - - \ No newline at end of file diff --git a/docs/docs/configuration/2_search.mdx b/docs/docs/configuration/2_search.mdx deleted file mode 100644 index ab8522f3..00000000 --- a/docs/docs/configuration/2_search.mdx +++ /dev/null @@ -1,11 +0,0 @@ ---- -sidebar_position: 3 ---- - -import SearchSettings from '@site/src/components/configuration/SearchSettings'; - -# Search settings - -These are the search configuration settings when searching for replacements. - - \ No newline at end of file diff --git a/docs/docs/configuration/arrs/1_sonarr.mdx b/docs/docs/configuration/arrs/1_sonarr.mdx deleted file mode 100644 index b7571c24..00000000 --- a/docs/docs/configuration/arrs/1_sonarr.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -sidebar_position: 1 ---- - -import SonarrSettings from '@site/src/components/configuration/arrs/SonarrSettings'; -import { Note } from '@site/src/components/Admonition'; - -# Sonarr Settings - - - Multiple instances can be specified for each *arr using this format, where `` starts from 0: -```yaml -__INSTANCES____URL -__INSTANCES____APIKEY -``` - - - \ No newline at end of file diff --git a/docs/docs/configuration/arrs/2_radarr.mdx b/docs/docs/configuration/arrs/2_radarr.mdx deleted file mode 100644 index f93189b5..00000000 --- a/docs/docs/configuration/arrs/2_radarr.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -sidebar_position: 2 ---- - -import RadarrSettings from '@site/src/components/configuration/arrs/RadarrSettings'; -import { Note } from '@site/src/components/Admonition'; - -# Radarr Settings - - - Multiple instances can be specified for each *arr using this format, where `` starts from 0: -```yaml -__INSTANCES____URL -__INSTANCES____APIKEY -``` - - - \ No newline at end of file diff --git a/docs/docs/configuration/arrs/3_lidarr.mdx b/docs/docs/configuration/arrs/3_lidarr.mdx deleted file mode 100644 index 75ab99b1..00000000 --- a/docs/docs/configuration/arrs/3_lidarr.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -sidebar_position: 3 ---- - -import LidarrSettings from '@site/src/components/configuration/arrs/LidarrSettings'; -import { Note } from '@site/src/components/Admonition'; - -# Lidarr Settings - - - Multiple instances can be specified for each *arr using this format, where `` starts from 0: -```yaml -__INSTANCES____URL -__INSTANCES____APIKEY -``` - - - \ No newline at end of file diff --git a/docs/docs/configuration/arrs/_category_.json b/docs/docs/configuration/arrs/_category_.json deleted file mode 100644 index 2fa3cacd..00000000 --- a/docs/docs/configuration/arrs/_category_.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "label": "Arrs settings", - "position": 7, - "link": { - "type": "generated-index", - "description": "Servarr settings." - } -} \ No newline at end of file diff --git a/docs/docs/configuration/content-blocker/1_general.mdx b/docs/docs/configuration/content-blocker/1_general.mdx deleted file mode 100644 index 188335e8..00000000 --- a/docs/docs/configuration/content-blocker/1_general.mdx +++ /dev/null @@ -1,25 +0,0 @@ ---- -sidebar_position: 1 ---- - -import ContentBlockerGeneralSettings from '@site/src/components/configuration/content-blocker/ContentBlockerGeneralSettings'; -import { Important } from '@site/src/components/Admonition'; -import Link from '@docusaurus/Link'; - -# General Settings - -These settings control the general behavior of the Content Blocker functionality. - -These environment variables are needed to enable the Content Blocker functionality: -- [SONARR__BLOCK__TYPE](/docs/configuration/arrs/sonarr?SONARR__BLOCK__TYPE) (if Sonarr is enabled) -- [SONARR__BLOCK__PATH](/docs/configuration/arrs/sonarr?SONARR__BLOCK__PATH) (if Sonarr is enabled) -- [RADARR__BLOCK__TYPE](/docs/configuration/arrs/radarr?RADARR__BLOCK__TYPE) (if Radarr is enabled) -- [RADARR__BLOCK__PATH](/docs/configuration/arrs/radarr?RADARR__BLOCK__PATH) (if Radarr is enabled) -- [LIDARR__BLOCK__TYPE](/docs/configuration/arrs/lidarr?LIDARR__BLOCK__TYPE) (if Lidarr is enabled) -- [LIDARR__BLOCK__PATH](/docs/configuration/arrs/lidarr?LIDARR__BLOCK__PATH) (if Lidarr is enabled) - - - These settings need a download client to be configured. - - - \ No newline at end of file diff --git a/docs/docs/configuration/content-blocker/_category_.json b/docs/docs/configuration/content-blocker/_category_.json deleted file mode 100644 index d83ac673..00000000 --- a/docs/docs/configuration/content-blocker/_category_.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "label": "Content Blocker", - "position": 4, - "link": { - "type": "generated-index", - "description": "Settings for the Content Blocker functionality." - } -} \ No newline at end of file diff --git a/docs/docs/configuration/content-blocker/index.mdx b/docs/docs/configuration/content-blocker/index.mdx new file mode 100644 index 00000000..8a75899d --- /dev/null +++ b/docs/docs/configuration/content-blocker/index.mdx @@ -0,0 +1,158 @@ +--- +sidebar_position: 2 +--- + +import { Important, Warning } from '@site/src/components/Admonition'; +import { + ConfigSection, + EnhancedImportant, + EnhancedWarning, + styles +} from '@site/src/components/documentation'; + +# Content Blocker + +The Content Blocker automatically blocks or removes downloads from your download client based on configurable blocklists. This helps prevent unwanted content from being downloaded and manages content filtering across your *arr applications. + +
+ + +These settings need a download client to be configured. + + +
+ + + +When enabled, the Content Blocker will run according to the configured schedule to automatically block or remove downloads based on the configured blocklists. + + + + + +Choose how to configure the Content Blocker schedule: +- **Basic**: Simple interval-based scheduling (every X minutes/hours/seconds) +- **Advanced**: Full cron expression control for complex schedules + + + + + +Enter a valid Quartz.NET cron expression to control when the Content Blocker runs. + +**Common Cron Examples:** +- `0 0/5 * ? * * *` - Every 5 minutes +- `0 0 * ? * * *` - Every hour +- `0 0 */6 ? * * *` - Every 6 hours + + + + + +When enabled, private torrents will be skipped from being processed during content blocking. + + + + + +When enabled, private torrents that match blocklist criteria will be deleted from the download client. Use with extreme caution as this permanently removes the download. + + +Setting this to true means private torrents will be permanently deleted, potentially affecting your private tracker account by receiving H&R if the seeding requirements are not met. + + + + +
+ +
+ +

+ ๐Ÿ“‹ + Arr Service Settings +

+ +

+ Configure blocklists for each *arr application. Each service can have its own blocklist configuration. +

+ + + +When enabled, the Content Blocker will use the configured blocklist to filter content. + + + + + +Path to the blocklist file or URL. This can be a local file path or a remote URL that will be fetched automatically. + +**Examples:** +- `/config/sonarr-blocklist.txt` +- `https://example.com/blocklist.txt` + +The blocklists support the following types of patters: +``` +*example // file name ends with "example" +example* // file name starts with "example" +*example* // file name has "example" in the name +example // file name is exactly the word "example" +regex: // regex that needs to be marked at the start of the line with "regex:" +``` + + + + + +Controls how the blocklist is interpreted: +- **Blacklist**: Files matching any pattern in the list will be blocked. +- **Whitelist**: Only files matching patterns in the list will be allowed. + +:::tip +[This blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist), [this permissive blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist_permissive) and [this whitelist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/whitelist) can be used for Sonarr and Radarr. +::: + + + +
+ +
\ No newline at end of file diff --git a/docs/docs/configuration/download-cleaner/1_general.mdx b/docs/docs/configuration/download-cleaner/1_general.mdx deleted file mode 100644 index d17e9ba2..00000000 --- a/docs/docs/configuration/download-cleaner/1_general.mdx +++ /dev/null @@ -1,11 +0,0 @@ ---- -sidebar_position: 1 ---- - -import DownloadCleanerGeneralSettings from '@site/src/components/configuration/download-cleaner/DownloadCleanerGeneralSettings'; - -# General Settings - -These settings control the basic functionality of the Download Cleaner. - - \ No newline at end of file diff --git a/docs/docs/configuration/download-cleaner/2_seeding.mdx b/docs/docs/configuration/download-cleaner/2_seeding.mdx deleted file mode 100644 index 44a20487..00000000 --- a/docs/docs/configuration/download-cleaner/2_seeding.mdx +++ /dev/null @@ -1,31 +0,0 @@ ---- -sidebar_position: 2 ---- - -import DownloadCleanerCleanupSettings from '@site/src/components/configuration/download-cleaner/DownloadCleanerCleanupSettings'; -import { Note, Important } from '@site/src/components/Admonition'; -import Link from '@docusaurus/Link'; - -# Seeding settings - -These settings control how the Download Cleaner handles different categories of downloads that need to be removed. - - - A download is cleaned when both `MAX_RATIO` and `MIN_SEED_TIME` or just `MAX_SEED_TIME` is reached. - - - - Multiple categories can be specified using this format, where `` starts from `0`: - ```yaml - DOWNLOADCLEANER__CATEGORIES____NAME - DOWNLOADCLEANER__CATEGORIES____MAX_RATIO - DOWNLOADCLEANER__CATEGORIES____MIN_SEED_TIME - DOWNLOADCLEANER__CATEGORIES____MAX_SEED_TIME - ``` - - - - These settings need a download client to be configured. - - - \ No newline at end of file diff --git a/docs/docs/configuration/download-cleaner/3_hardlinks.mdx b/docs/docs/configuration/download-cleaner/3_hardlinks.mdx deleted file mode 100644 index e204741e..00000000 --- a/docs/docs/configuration/download-cleaner/3_hardlinks.mdx +++ /dev/null @@ -1,28 +0,0 @@ ---- -sidebar_position: 3 ---- - -import DownloadCleanerHardlinksSettings from '@site/src/components/configuration/download-cleaner/DownloadCleanerHardlinksSettings'; -import { Important, Warning } from '@site/src/components/Admonition'; -import Link from '@docusaurus/Link'; - -# Hardlinks Settings - -These settings control how the Download Cleaner handles downloads with no hardlinks remaining (they are not available in the arrs anymore). - -The Download Cleaner will change the category of a download that has no hardlinks and the new category can be cleaned based on the rules configured [here](/docs/configuration/download-cleaner/seeding). - - - These settings need a download client to be configured. - - - - If you are using Docker, make sure to mount the downloads directory the same way it is mounted for the download client. - If your download client's download directory is `/downloads`, it should be the same for Cleanuperr. - - - - While it is not needed to configure the arrs for this feature, it is recommended you do. If the arrs are not configured, downloads that are waiting to be imported might be affected by it. - - - \ No newline at end of file diff --git a/docs/docs/configuration/download-cleaner/_category_.json b/docs/docs/configuration/download-cleaner/_category_.json deleted file mode 100644 index c59a748d..00000000 --- a/docs/docs/configuration/download-cleaner/_category_.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "label": "Download Cleaner", - "position": 5, - "link": { - "type": "generated-index", - "description": "Configure the Download Cleaner to automatically clean up downloads that have been seeding for a certain amount of time." - } -} \ No newline at end of file diff --git a/docs/docs/configuration/download-cleaner/index.mdx b/docs/docs/configuration/download-cleaner/index.mdx new file mode 100644 index 00000000..34694ae2 --- /dev/null +++ b/docs/docs/configuration/download-cleaner/index.mdx @@ -0,0 +1,249 @@ +--- +sidebar_position: 4 +--- + +import { Note, Important, Warning } from '@site/src/components/Admonition'; +import { + ConfigSection, + EnhancedNote, + EnhancedImportant, + EnhancedWarning, + styles +} from '@site/src/components/documentation'; + +# Download Cleaner + +The Download Cleaner automatically removes downloads from your download client after they have finished seeding according to configurable rules. This helps manage disk space and maintain optimal seeding ratios. + +
+ + +These settings need a download client to be configured. + + +
+ + + +When enabled, the Download Cleaner will run according to the configured schedule to automatically clean completed downloads from your download client. + + + + + +Choose how to configure the Download Cleaner schedule: +- **Basic**: Simple interval-based scheduling (every X minutes/hours/seconds) +- **Advanced**: Full cron expression control for complex schedules + + + + + +Enter a valid Quartz.NET cron expression to control when the Download Cleaner runs. The example above runs every hour. + +**Common Cron Examples:** +- `0 0/5 * ? * * *` - Every 5 minutes +- `0 0 * ? * * *` - Every hour +- `0 0 */6 ? * * *` - Every 6 hours + + + +
+ +
+ +

+ ๐ŸŒฑ + Seeding Settings +

+ +

+ Settings that control how downloads are cleaned after they finish downloading. +

+ + + +When enabled, private torrents will be deleted from the download client when they meet the cleanup criteria. Use with caution as this permanently removes the download. + + +Setting this to true means private torrents will be permanently deleted, potentially affecting your private tracker account by receiving H&R if the seeding requirements are not met. + + + + +
+ +
+ +

+ ๐Ÿ“ + Seeding Rules +

+ +

+ Categories define the cleanup rules for different types of downloads. Each category specifies when downloads should be removed based on ratio and time limits. +

+ + +A download is cleaned when both `Max Ratio` and `Min Seed Time` are reached, OR when `Max Seed Time` is reached regardless of ratio. + + + +Both Max Ratio and Max Seed Time cannot be disabled (-1) at the same time. At least one cleanup condition must be configured. + + + + +The name of the download client category to apply these rules to. Must match the category name exactly as configured in your download client. + +**Examples:** +- `tv-sonarr` +- `radarr` +- `lidarr` + + + + + +Maximum ratio to seed before considering the download for removal. Set to `-1` to disable ratio-based cleanup. + + + + + +Minimum time in hours to seed before removing a download that has reached the max ratio. Set to `0` to disable minimum time requirements. + + + + + +Maximum time in hours to seed before removing a download regardless of ratio. Set to `-1` to disable time-based cleanup. + + + +
+ +
+ +

+ ๐Ÿ”— + Unlinked Download Settings +

+ +

+ Settings for managing downloads that no longer have hardlinks to media files (indicating they may no longer be needed by the *arr applications). +

+ + + +Enable management of downloads that have no hardlinks remaining. This helps identify downloads that are no longer needed by your *arr applications. + + +If you are using Docker, make sure to mount the downloads directory the same way it is mounted for the download client. If your download client's download directory is `/downloads`, it should be the same for Cleanuparr. + + + + + + +Category to move unlinked downloads to. + + + + + +When enabled, uses a tag instead of category for marking unlinked downloads (qBittorrent only). + + + + + +Root directory to ignore when checking for unlinked downloads. Useful for cross-seed setups where you want to ignore hardlinks (even though a movie is not in Radarr anymore, it can have hardlinks from cross-seed). + +``` +/data +โ”œโ”€โ”€ downloads +โ”‚ โ”œโ”€โ”€ torrents +โ”‚ โ””โ”€โ”€ cross-seed +โ”œโ”€โ”€ movies +โ””โ”€โ”€ shows +``` + +For the example above, the ignored root directory should be set to `/data/downloads`. + + + + + +Categories to check for unlinked downloads. Only downloads in these categories will be checked for missing hardlinks. + + + +
+ +
\ No newline at end of file diff --git a/docs/docs/configuration/download-client/1_general.mdx b/docs/docs/configuration/download-client/1_general.mdx deleted file mode 100644 index 4628fb7d..00000000 --- a/docs/docs/configuration/download-client/1_general.mdx +++ /dev/null @@ -1,11 +0,0 @@ ---- -sidebar_position: 1 ---- - -import DownloadClientSettings from '@site/src/components/configuration/download-client/DownloadClientSettings'; - -# General settings - -These settings control how Cleanuperr interacts with your download client. - - \ No newline at end of file diff --git a/docs/docs/configuration/download-client/2_qbit.mdx b/docs/docs/configuration/download-client/2_qbit.mdx deleted file mode 100644 index 9ecf2d82..00000000 --- a/docs/docs/configuration/download-client/2_qbit.mdx +++ /dev/null @@ -1,7 +0,0 @@ -import QBittorrentSettings from '@site/src/components/configuration/download-client/QBittorrentSettings'; - -# qBittorrent settings - -Settings used to access your qBittorrent instance. - - \ No newline at end of file diff --git a/docs/docs/configuration/download-client/3_deluge.mdx b/docs/docs/configuration/download-client/3_deluge.mdx deleted file mode 100644 index ed4258cc..00000000 --- a/docs/docs/configuration/download-client/3_deluge.mdx +++ /dev/null @@ -1,7 +0,0 @@ -import DelugeSettings from '@site/src/components/configuration/download-client/DelugeSettings'; - -# Deluge settings - -Settings used to access your Deluge instance. - - \ No newline at end of file diff --git a/docs/docs/configuration/download-client/4_transmission.mdx b/docs/docs/configuration/download-client/4_transmission.mdx deleted file mode 100644 index f84200cf..00000000 --- a/docs/docs/configuration/download-client/4_transmission.mdx +++ /dev/null @@ -1,7 +0,0 @@ -import TransmissionSettings from '@site/src/components/configuration/download-client/TransmissionSettings'; - -# Transmission settings - -Settings used to access your Transmission instance. - - \ No newline at end of file diff --git a/docs/docs/configuration/download-client/_category_.json b/docs/docs/configuration/download-client/_category_.json deleted file mode 100644 index 8faf02be..00000000 --- a/docs/docs/configuration/download-client/_category_.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "label": "Download Client", - "position": 6, - "link": { - "type": "generated-index", - "description": "Configure the download client settings for Cleanuperr." - } -} \ No newline at end of file diff --git a/docs/docs/configuration/download-client/index.mdx b/docs/docs/configuration/download-client/index.mdx new file mode 100644 index 00000000..6bb8810e --- /dev/null +++ b/docs/docs/configuration/download-client/index.mdx @@ -0,0 +1,141 @@ +--- +sidebar_position: 6 +--- + +import { + ConfigSection, + styles +} from '@site/src/components/documentation'; + +# Download Client + +Configure download client connections for torrents and usenet. Cleanuparr supports qBittorrent, Deluge, and Transmission download clients. + +
+ +
+ + + +Controls whether this download client instance is active and will be used by Cleanuparr for operations. + +**When Enabled**: +- Client will be available for slow, stalled and private tracker operations. +- Health checks will monitor this client. + +**When Disabled**: +- Client will be ignored by all Cleanuparr operations. +- No health checks will be performed. + + + + + +A descriptive name to identify this download client instance in the Cleanuparr interface. + + + + + +Specifies which download client software this configuration connects to. + + + +
+ +
+ +

+ ๐ŸŒ + Connection Settings +

+ + + +The complete URL to access your download client's web interface. + +**Format**: `protocol://hostname:port` + +**Examples**: +- `http://localhost:8080` (local qBittorrent) +- `https://seedbox.example.com:8080` (remote qBittorrent with SSL) +- `http://192.168.1.100:8112` (local network Deluge) +- `http://transmission.lan:9091` (local Transmission) + + + + + +URL path prefix if your download client runs behind a reverse proxy with a subpath. + +**When to Use**: +- Client accessed via reverse proxy (Nginx, Apache, Traefik). +- Client uses non-root URL path. +- Multiple services share same domain/port. + +**Examples**: +- `qbittorrent` โ†’ Full URL: `http://domain.com/qbittorrent` +- `downloads/deluge` โ†’ Full URL: `http://domain.com/downloads/deluge` +- `transmission` โ†’ Full URL: `http://domain.com/transmission` + + + +
+ +
+ +

+ ๐Ÿ” + Authentication +

+ + + +Username for download client authentication if required. + + + + + +Password for download client authentication. + + + +
+ +
diff --git a/docs/docs/configuration/examples/1_docker.mdx b/docs/docs/configuration/examples/1_docker.mdx deleted file mode 100644 index 71d1ea7b..00000000 --- a/docs/docs/configuration/examples/1_docker.mdx +++ /dev/null @@ -1,397 +0,0 @@ -import { Note } from '@site/src/components/Admonition'; -import CodeBlock from '@theme/CodeBlock'; -import CodeBlockContainer from '@theme/CodeBlock/Container'; -import Link from '@docusaurus/Link'; -import styles from './examples.module.css'; - -# Docker compose - - - This example contains all settings and should be modified to fit your needs. - Remove the variables that you do not need. - - - - Click on an environment variable's name to go to its documentation. - - - -
- {`services: - cleanuperr: - image: ghcr.io/flmorg/cleanuperr:latest - restart: unless-stopped - volumes: - # if you want persistent logs - - ./cleanuperr/logs:/var/logs - # if you want to ignore certain downloads from being processed - - ./cleanuperr/ignored.txt:/ignored.txt - # if you're using cross-seed and the hardlinks functionality - - ./downloads:/downloads - environment: - - `} - TZ - {`=America/New_York - - `} - DRY_RUN - {`=false - - `} - HTTP_MAX_RETRIES - {`=0 - - `} - HTTP_TIMEOUT - {`=100 - - `} - HTTP_VALIDATE_CERT - {`=Enabled - - - `} - LOGGING__LOGLEVEL - {`=Information - - `} - LOGGING__FILE__ENABLED - {`=false - - `} - LOGGING__FILE__PATH - {`=/var/logs/ - - `} - LOGGING__ENHANCED - {`=true - - - `} - SEARCH_ENABLED - {`=true - - `} - SEARCH_DELAY - {`=30 - - - `} - TRIGGERS__QUEUECLEANER - {`=0 0/5 * * * ? - - `} - QUEUECLEANER__ENABLED - {`=true - - `} - QUEUECLEANER__IGNORED_DOWNLOADS_PATH - {`=/ignored.txt - - `} - QUEUECLEANER__RUNSEQUENTIALLY - {`=true - - - `} - QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES - {`=5 - - `} - QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE - {`=false - - `} - QUEUECLEANER__IMPORT_FAILED_DELETE_PRIVATE - {`=false - - `} - QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__0 - {`=title mismatch - - `} - QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1 - {`=manual import required - - - `} - QUEUECLEANER__STALLED_MAX_STRIKES - {`=5 - - `} - QUEUECLEANER__STALLED_RESET_STRIKES_ON_PROGRESS - {`=true - - `} - QUEUECLEANER__STALLED_IGNORE_PRIVATE - {`=false - - `} - QUEUECLEANER__STALLED_DELETE_PRIVATE - {`=false - - `} - QUEUECLEANER__DOWNLOADING_METADATA_MAX_STRIKES - {`=5 - - - `} - QUEUECLEANER__SLOW_MAX_STRIKES - {`=5 - - `} - QUEUECLEANER__SLOW_RESET_STRIKES_ON_PROGRESS - {`=true - - `} - QUEUECLEANER__SLOW_IGNORE_PRIVATE - {`=false - - `} - QUEUECLEANER__SLOW_DELETE_PRIVATE - {`=false - - `} - QUEUECLEANER__SLOW_MIN_SPEED - {`=1MB - - `} - QUEUECLEANER__SLOW_MAX_TIME - {`=20 - - `} - QUEUECLEANER__SLOW_IGNORE_ABOVE_SIZE - {`=60GB - - - `} - TRIGGERS__CONTENTBLOCKER - {`=0 0/5 * * * ? - - `} - CONTENTBLOCKER__ENABLED - {`=true - - `} - CONTENTBLOCKER__IGNORED_DOWNLOADS_PATH - {`=/ignored.txt - - `} - CONTENTBLOCKER__IGNORE_PRIVATE - {`=false - - `} - CONTENTBLOCKER__DELETE_PRIVATE - {`=false - - - `} - TRIGGERS__DOWNLOADCLEANER - {`=0 0 * * * ? - - `} - DOWNLOADCLEANER__ENABLED - {`=true - - `} - DOWNLOADCLEANER__IGNORED_DOWNLOADS_PATH - {`=/ignored.txt - - `} - DOWNLOADCLEANER__DELETE_PRIVATE - {`=false - - - `} - DOWNLOADCLEANER__CATEGORIES__0__NAME - {`=tv-sonarr - - `} - DOWNLOADCLEANER__CATEGORIES__0__MAX_RATIO - {`=1 - - `} - DOWNLOADCLEANER__CATEGORIES__0__MIN_SEED_TIME - {`=0 - - `} - DOWNLOADCLEANER__CATEGORIES__0__MAX_SEED_TIME - {`=240 - - `} - DOWNLOADCLEANER__CATEGORIES__1__NAME - {`=radarr - - `} - DOWNLOADCLEANER__CATEGORIES__1__MAX_RATIO - {`=1 - - `} - DOWNLOADCLEANER__CATEGORIES__1__MIN_SEED_TIME - {`=0 - - `} - DOWNLOADCLEANER__CATEGORIES__1__MAX_SEED_TIME - {`=240 - - `} - DOWNLOADCLEANER__CATEGORIES__2__NAME - {`=cleanuperr-unlinked - - `} - DOWNLOADCLEANER__CATEGORIES__2__MAX_RATIO - {`=1 - - `} - DOWNLOADCLEANER__CATEGORIES__2__MIN_SEED_TIME - {`=0 - - `} - DOWNLOADCLEANER__CATEGORIES__2__MAX_SEED_TIME - {`=240 - - - `} - DOWNLOADCLEANER__UNLINKED_TARGET_CATEGORY - {`=cleanuperr-unlinked - - `} - DOWNLOADCLEANER__UNLINKED_USE_TAG - {`=false - - `} - DOWNLOADCLEANER__UNLINKED_IGNORED_ROOT_DIR - {`=/downloads - - `} - DOWNLOADCLEANER__UNLINKED_CATEGORIES__0 - {`=tv-sonarr - - `} - DOWNLOADCLEANER__UNLINKED_CATEGORIES__1 - {`=radarr - - - `} - DOWNLOAD_CLIENT - {`=none - # OR - # - `} - DOWNLOAD_CLIENT - {`=disabled - # OR - # - `} - DOWNLOAD_CLIENT - {`=qBittorrent - # - `} - QBITTORRENT__URL - {`=http://localhost:8080 - # - `} - QBITTORRENT__URL_BASE - {`=myCustomPath - # - `} - QBITTORRENT__USERNAME - {`=user - # - `} - QBITTORRENT__PASSWORD - {`=pass - # OR - # - `} - DOWNLOAD_CLIENT - {`=deluge - # - `} - DELUGE__URL - {`=http://localhost:8112 - # - `} - DELUGE__URL_BASE - {`=myCustomPath - # - `} - DELUGE__PASSWORD - {`=pass - # OR - # - `} - DOWNLOAD_CLIENT - {`=transmission - # - `} - TRANSMISSION__URL - {`=http://localhost:9091 - # - `} - TRANSMISSION__URL_BASE - {`=myCustomPath - # - `} - TRANSMISSION__USERNAME - {`=user - # - `} - TRANSMISSION__PASSWORD - {`=pass - - - `} - SONARR__ENABLED - {`=true - - `} - SONARR__IMPORT_FAILED_MAX_STRIKES - {`=-1 - - `} - SONARR__SEARCHTYPE - {`=Episode - - `} - SONARR__BLOCK__TYPE - {`=blacklist - - `} - SONARR__BLOCK__PATH - {`=https://example.com/path/to/file.txt - - `} - SONARR__INSTANCES__0__URL - {`=http://localhost:8989 - - `} - SONARR__INSTANCES__0__APIKEY - {`=sonarrSecret1 - - `} - SONARR__INSTANCES__1__URL - {`=http://localhost:8990 - - `} - SONARR__INSTANCES__1__APIKEY - {`=sonarrSecret2 - - - `} - RADARR__ENABLED - {`=true - - `} - RADARR__IMPORT_FAILED_MAX_STRIKES - {`=-1 - - `} - RADARR__BLOCK__TYPE - {`=blacklist - - `} - RADARR__BLOCK__PATH - {`=https://example.com/path/to/file.txt - - `} - RADARR__INSTANCES__0__URL - {`=http://localhost:7878 - - `} - RADARR__INSTANCES__0__APIKEY - {`=radarrSecret1 - - `} - RADARR__INSTANCES__1__URL - {`=http://localhost:7879 - - `} - RADARR__INSTANCES__1__APIKEY - {`=radarrSecret2 - - - `} - LIDARR__ENABLED - {`=true - - `} - LIDARR__IMPORT_FAILED_MAX_STRIKES - {`=-1 - - `} - LIDARR__BLOCK__TYPE - {`=blacklist - - `} - LIDARR__BLOCK__PATH - {`=https://example.com/path/to/file.txt - - `} - LIDARR__INSTANCES__0__URL - {`=http://localhost:8686 - - `} - LIDARR__INSTANCES__0__APIKEY - {`=lidarrSecret1 - - `} - LIDARR__INSTANCES__1__URL - {`=http://localhost:8687 - - `} - LIDARR__INSTANCES__1__APIKEY - {`=lidarrSecret2 - - - `} - NOTIFIARR__ON_IMPORT_FAILED_STRIKE - {`=true - - `} - NOTIFIARR__ON_STALLED_STRIKE - {`=true - - `} - NOTIFIARR__ON_SLOW_STRIKE - {`=true - - `} - NOTIFIARR__ON_QUEUE_ITEM_DELETED - {`=true - - `} - NOTIFIARR__ON_DOWNLOAD_CLEANED - {`=true - - `} - NOTIFIARR__ON_CATEGORY_CHANGED - {`=true - - `} - NOTIFIARR__API_KEY - {`=notifiarrSecret - - `} - NOTIFIARR__CHANNEL_ID - {`=discordChannelId - - - `} - APPRISE__ON_IMPORT_FAILED_STRIKE - {`=true - - `} - APPRISE__ON_STALLED_STRIKE - {`=true - - `} - APPRISE__ON_SLOW_STRIKE - {`=true - - `} - APPRISE__ON_QUEUE_ITEM_DELETED - {`=true - - `} - APPRISE__ON_DOWNLOAD_CLEANED - {`=true - - `} - APPRISE__ON_CATEGORY_CHANGED - {`=true - - `} - APPRISE__URL - {`=http://apprise:8000 - - `}APPRISE__KEY - {`=myConfigKey`} -
-
\ No newline at end of file diff --git a/docs/docs/configuration/examples/2_config-file.mdx b/docs/docs/configuration/examples/2_config-file.mdx deleted file mode 100644 index 61b9bd59..00000000 --- a/docs/docs/configuration/examples/2_config-file.mdx +++ /dev/null @@ -1,191 +0,0 @@ -import { Note } from '@site/src/components/Admonition'; - -# Configuration file (when not using Docker) - - - This example contains all settings and should be modified to fit your needs. - - - - Click on an environment variable's name to go to its documentation. - - -``` -{ - "TZ": "America/New_York", - "DRY_RUN": true, - "HTTP_MAX_RETRIES": 0, - "HTTP_TIMEOUT": 10, - "Logging": { - "LogLevel": "Information", - "Enhanced": true, - "File": { - "Enabled": false, - "Path": "/var/logs" - } - }, - "SEARCH_ENABLED": true, - "SEARCH_DELAY": 30, - "Triggers": { - "QueueCleaner": "0 0/5 * * * ?", - "ContentBlocker": "0 0/5 * * * ?", - "DownloadCleaner": "0 0 * * * ?" - }, - "QueueCleaner": { - "Enabled": true, - "RunSequentially": true, - "IGNORED_DOWNLOADS_PATH": "/ignored.txt", - "IMPORT_FAILED_MAX_STRIKES": 5, - "IMPORT_FAILED_IGNORE_PRIVATE": false, - "IMPORT_FAILED_DELETE_PRIVATE": false, - "IMPORT_FAILED_IGNORE_PATTERNS": [ - "title mismatch", - "manual import required" - ], - "STALLED_MAX_STRIKES": 5, - "STALLED_RESET_STRIKES_ON_PROGRESS": true, - "STALLED_IGNORE_PRIVATE": false, - "STALLED_DELETE_PRIVATE": false, - "DOWNLOADING_METADATA_MAX_STRIKES": 5, - "SLOW_MAX_STRIKES": 5, - "SLOW_RESET_STRIKES_ON_PROGRESS": true, - "SLOW_IGNORE_PRIVATE": false, - "SLOW_DELETE_PRIVATE": false, - "SLOW_MIN_SPEED": "1MB", - "SLOW_MAX_TIME": 20, - "SLOW_IGNORE_ABOVE_SIZE": "60GB" - }, - "ContentBlocker": { - "Enabled": true, - "IGNORE_PRIVATE": false, - "DELETE_PRIVATE": false, - "IGNORED_DOWNLOADS_PATH": "/ignored.txt" - }, - "DownloadCleaner": { - "Enabled": false, - "DELETE_PRIVATE": false, - "CATEGORIES": [ - { - "Name": "tv-sonarr", - "MAX_RATIO": 1, - "MIN_SEED_TIME": 0, - "MAX_SEED_TIME": 240 - }, - { - "Name": "radarr", - "MAX_RATIO": 1, - "MIN_SEED_TIME": 0, - "MAX_SEED_TIME": 240 - }, - { - "Name": "cleanuperr-unlinked", - "MAX_RATIO": 1, - "MIN_SEED_TIME": 0, - "MAX_SEED_TIME": 240 - } - ], - "UNLINKED_TARGET_CATEGORY": "cleanuperr-unlinked", - "DOWNLOADCLEANER__UNLINKED_USE_TAG": false, - "UNLINKED_IGNORED_ROOT_DIR": "/downloads", - "UNLINKED_CATEGORIES": [ - "tv-sonarr", - "radarr" - ], - "IGNORED_DOWNLOADS_PATH": "/ignored.txt" - }, - "DOWNLOAD_CLIENT": "none", - "qBittorrent": { - "Url": "http://localhost:8080", - "URL_BASE": "myCustomPath", - "Username": "user", - "Password": "pass" - }, - "Deluge": { - "Url": "http://localhost:8112", - "URL_BASE": "myCustomPath", - "Password": "pass" - }, - "Transmission": { - "Url": "http://localhost:9091", - "URL_BASE": "myCustomPath", - "Username": "user", - "Password": "pass" - }, - "Sonarr": { - "Enabled": true, - "IMPORT_FAILED_MAX_STRIKES=-1 - "SearchType": "Episode", - "Block": { - "Type": "blacklist", - "Path": "https://example.com/path/to/file.txt" - }, - "Instances": [ - { - "Url": "http://localhost:8989", - "ApiKey": "sonarrSecret1" - }, - { - "Url": "http://localhost:8990", - "ApiKey": "sonarrSecret2" - }, - ] - }, - "Radarr": { - "Enabled": true, - "IMPORT_FAILED_MAX_STRIKES": -1, - "Block": { - "Type": "blacklist", - "Path": "https://example.com/path/to/file.txt" - }, - "Instances": [ - { - "Url": "http://localhost:7878", - "ApiKey": "sonarrSecret1" - }, - { - "Url": "http://localhost:7879", - "ApiKey": "sonarrSecret2" - } - ] - }, - "Lidarr": { - "Enabled": true, - "IMPORT_FAILED_MAX_STRIKES": -1, - "Block": { - "Type": "blacklist", - "Path": "https://example.com/path/to/file.txt" - }, - "Instances": [ - { - "Url": "http://localhost:8686", - "ApiKey": "lidarrSecret1" - }, - { - "Url": "http://localhost:8687", - "ApiKey": "lidarrSecret2" - } - ] - }, - "Notifiarr": { - "ON_IMPORT_FAILED_STRIKE": true, - "ON_STALLED_STRIKE": true, - "ON_SLOW_STRIKE": true, - "ON_QUEUE_ITEM_DELETED": true, - "ON_DOWNLOAD_CLEANED": true, - "ON_CATEGORY_CHANGED": true, - "API_KEY": "notifiarr_secret", - "CHANNEL_ID": "discord_channel_id" - }, - "Apprise": { - "ON_IMPORT_FAILED_STRIKE": true, - "ON_STALLED_STRIKE": true, - "ON_SLOW_STRIKE": true, - "ON_QUEUE_ITEM_DELETED": true, - "ON_DOWNLOAD_CLEANED": true, - "ON_CATEGORY_CHANGED": true, - "URL": "http://localhost:8000", - "KEY": "myConfigKey" - } -} - -``` \ No newline at end of file diff --git a/docs/docs/configuration/examples/_category_.json b/docs/docs/configuration/examples/_category_.json deleted file mode 100644 index a09fa1b0..00000000 --- a/docs/docs/configuration/examples/_category_.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "label": "Examples", - "position": 1, - "link": { - "type": "generated-index" - } -} \ No newline at end of file diff --git a/docs/docs/configuration/examples/examples.module.css b/docs/docs/configuration/examples/examples.module.css deleted file mode 100644 index e1529ab9..00000000 --- a/docs/docs/configuration/examples/examples.module.css +++ /dev/null @@ -1,11 +0,0 @@ -[data-theme='light'] code a { - color: inherit; -} - -[data-theme='dark'] code a { - color: inherit; -} - -code a:hover { - text-decoration: none; -} \ No newline at end of file diff --git a/docs/docs/configuration/general/index.mdx b/docs/docs/configuration/general/index.mdx new file mode 100644 index 00000000..b39b6070 --- /dev/null +++ b/docs/docs/configuration/general/index.mdx @@ -0,0 +1,180 @@ +--- +sidebar_position: 1 +--- + +import { Note, Warning } from '@site/src/components/Admonition'; +import { + ConfigSection, + EnhancedNote, + EnhancedWarning, + styles +} from '@site/src/components/documentation'; + +# General + +General configuration settings that apply to the entire Cleanuparr application. + +
+ +
+ +

+ โš™๏ธ + System Settings +

+ + + +Show the support section on the dashboard with links to GitHub and sponsors. + + + + + +When enabled, logs irreversible operations (like deletions and notifications) without making actual changes. This is useful for testing configurations without affecting your actual downloads. + + + +
+ +
+ +

+ ๐ŸŒ + HTTP Configuration +

+ + + +The number of times to retry a failed HTTP call. Applies when communicating with *arrs, download clients and other services through HTTP calls. + + + + + +The number of seconds to wait before failing an HTTP call. Applies to calls to *arrs, download clients, and other services. + + + + + +Controls whether to validate SSL certificates for HTTPS connections. Set to "Disabled" to ignore SSL certificate errors, but this reduces security. + +**Options:** +- **Enabled:** Validate all SSL certificates +- **Disabled for Local Addresses:** Skip validation for local/private IP addresses +- **Disabled:** Skip all SSL certificate validation + + + +
+ +
+ +

+ ๐Ÿ” + Search Settings +

+ + + +Enables searching for replacements after a download has been removed from an *arr application. + + +If you are using [Huntarr](https://github.com/plexguide/Huntarr.io), you may want to set disable this setting to let Huntarr handle the searching. + + + + + + +If searching for replacements is enabled, this setting will delay the searches by the specified number of seconds. This is useful to avoid overwhelming the indexer with too many requests at once. + + +A lower value or `0` will result in faster searches, but may cause issues such as being rate limited or banned by the indexer. + + + + +
+ +
+ +

+ ๐Ÿ“ + Logging Settings +

+ + + +Controls the detail level of application logs. Lower levels include all higher level messages. + + + +
+ +
+ +

+ ๐Ÿ“ฆ + Download Management +

+ + + +Downloads matching these patterns will be ignored during all cleaning operations. Patterns can match any of these: +- torrent hash +- qBittorrent tag or category +- Deluge label +- Transmission category (last directory from the save location) +- torrent tracker domain + +**Examples:** +``` +fa800a7d7c443a2c3561d1f8f393c089036dade1 +tv-sonarr +qbit-tag +mytracker.com +``` + + + +
+ +
\ No newline at end of file diff --git a/docs/docs/configuration/notifications/1_notifiarr.mdx b/docs/docs/configuration/notifications/1_notifiarr.mdx deleted file mode 100644 index 9a0165c4..00000000 --- a/docs/docs/configuration/notifications/1_notifiarr.mdx +++ /dev/null @@ -1,7 +0,0 @@ -import NotifiarrSettings from '@site/src/components/configuration/notifications/NotifiarrSettings'; - -# Notifiarr Settings - -These settings control how Cleanuperr sends notifications through [Notifiarr](https://notifiarr.com/). - - \ No newline at end of file diff --git a/docs/docs/configuration/notifications/2_apprise.mdx b/docs/docs/configuration/notifications/2_apprise.mdx deleted file mode 100644 index 8d4cc2d3..00000000 --- a/docs/docs/configuration/notifications/2_apprise.mdx +++ /dev/null @@ -1,7 +0,0 @@ -import AppriseSettings from '@site/src/components/configuration/notifications/AppriseSettings'; - -# Apprise Settings - -These settings control how Cleanuperr sends notifications through [Apprise](https://github.com/caronc/apprise-api). - - \ No newline at end of file diff --git a/docs/docs/configuration/notifications/_category_.json b/docs/docs/configuration/notifications/_category_.json deleted file mode 100644 index 374a0b5a..00000000 --- a/docs/docs/configuration/notifications/_category_.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "label": "Notifications", - "position": 8, - "link": { - "type": "generated-index", - "description": "Settings for receiving notifications." - } -} \ No newline at end of file diff --git a/docs/docs/configuration/notifications/index.mdx b/docs/docs/configuration/notifications/index.mdx new file mode 100644 index 00000000..5c51a94b --- /dev/null +++ b/docs/docs/configuration/notifications/index.mdx @@ -0,0 +1,167 @@ +--- +sidebar_position: 7 +--- + +import { Important } from '@site/src/components/Admonition'; +import { + ConfigSection, + EnhancedImportant, + styles +} from '@site/src/components/documentation'; + +# Notifications + +Configure notification services to receive alerts about Cleanuparr operations. + +
+ +
+ +

+ ๐Ÿš€ + Notifiarr +

+ +

+ Notifiarr is a notification service that can send alerts to Discord. +

+ + + +Your Notifiarr API key for authentication. This key is obtained from your Notifiarr dashboard. + + +Requires Notifiarr's [Passthrough](https://notifiarr.wiki/pages/integrations/passthrough/) integration to work. + + + + + + +The Discord channel ID where notifications will be sent. This determines the destination for your alerts. + + + +
+ +
+ +

+ ๐Ÿ“ก + Apprise Configuration +

+ +

+ Apprise is a universal notification library that supports over 80 different notification services. +

+ + + +The Apprise server URL where notification requests will be sent. + + + + + +The key that identifies your Apprise configuration. This corresponds to a configuration defined in your Apprise server. + + + +
+ +
+ +

+ โšก + Event Triggers +

+ + + +**Triggered When**: A download receives a strike for failed import. + + + + + +**Triggered When**: A download receives a strike for being stalled. + + + + + +**Triggered When**: A download receives a strike for slow speed. + + + + + +**Triggered When**: A download is removed from the queue. + + + + + +**Triggered When**: Download Cleaner removes completed downloads. + + + + + +**Triggered When**: Download Cleaner changes a download's category. + + + +
+ +
\ No newline at end of file diff --git a/docs/docs/configuration/queue-cleaner/1_general.mdx b/docs/docs/configuration/queue-cleaner/1_general.mdx deleted file mode 100644 index 1a4e33f6..00000000 --- a/docs/docs/configuration/queue-cleaner/1_general.mdx +++ /dev/null @@ -1,11 +0,0 @@ ---- -sidebar_position: 1 ---- - -import QueueCleanerGeneralSettings from '@site/src/components/configuration/queue-cleaner/QueueCleanerGeneralSettings'; - -# General Settings - -These settings control the general behavior of the Queue Cleaner functionality. - - \ No newline at end of file diff --git a/docs/docs/configuration/queue-cleaner/2_import-failed.mdx b/docs/docs/configuration/queue-cleaner/2_import-failed.mdx deleted file mode 100644 index 439b3b7d..00000000 --- a/docs/docs/configuration/queue-cleaner/2_import-failed.mdx +++ /dev/null @@ -1,11 +0,0 @@ ---- -sidebar_position: 2 ---- - -import QueueCleanerImportFailedSettings from '@site/src/components/configuration/queue-cleaner/QueueCleanerImportFailedSettings'; - -# Import Failed Settings - -These settings control how the Queue Cleaner handles failed imports. - - \ No newline at end of file diff --git a/docs/docs/configuration/queue-cleaner/3_stalled.mdx b/docs/docs/configuration/queue-cleaner/3_stalled.mdx deleted file mode 100644 index 3cde81c3..00000000 --- a/docs/docs/configuration/queue-cleaner/3_stalled.mdx +++ /dev/null @@ -1,17 +0,0 @@ ---- -sidebar_position: 3 ---- - -import QueueCleanerStalledSettings from '@site/src/components/configuration/queue-cleaner/QueueCleanerStalledSettings'; -import { Important } from '@site/src/components/Admonition'; -import Link from '@docusaurus/Link'; - -# Stalled Downloads Settings - -These settings control how the Queue Cleaner handles stalled downloads. - - - These settings need a download client to be configured. - - - \ No newline at end of file diff --git a/docs/docs/configuration/queue-cleaner/4_slow.mdx b/docs/docs/configuration/queue-cleaner/4_slow.mdx deleted file mode 100644 index 95e597be..00000000 --- a/docs/docs/configuration/queue-cleaner/4_slow.mdx +++ /dev/null @@ -1,17 +0,0 @@ ---- -sidebar_position: 4 ---- - -import QueueCleanerSlowSettings from '@site/src/components/configuration/queue-cleaner/QueueCleanerSlowSettings'; -import { Important } from '@site/src/components/Admonition'; -import Link from '@docusaurus/Link'; - -# Slow Downloads Settings - -These settings control how the Queue Cleaner handles slow downloads. - - - These settings need a download client to be configured. - - - \ No newline at end of file diff --git a/docs/docs/configuration/queue-cleaner/_category_.json b/docs/docs/configuration/queue-cleaner/_category_.json deleted file mode 100644 index 3a3fe358..00000000 --- a/docs/docs/configuration/queue-cleaner/_category_.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "label": "Queue Cleaner", - "position": 3, - "link": { - "type": "generated-index", - "description": "Settings for the Queue Cleaner functionality." - } -} \ No newline at end of file diff --git a/docs/docs/configuration/queue-cleaner/index.mdx b/docs/docs/configuration/queue-cleaner/index.mdx new file mode 100644 index 00000000..bee2ab0e --- /dev/null +++ b/docs/docs/configuration/queue-cleaner/index.mdx @@ -0,0 +1,288 @@ +--- +sidebar_position: 3 +--- + +import { Important, Warning } from '@site/src/components/Admonition'; +import { + ConfigSection, + EnhancedImportant, + EnhancedWarning, + styles +} from '@site/src/components/documentation'; + +# Queue Cleaner + +The Queue Cleaner monitors your *arr's queues and automatically removes downloads based on configurable criteria. This helps keep your queue clean and prevents problematic downloads from being stuck. + +
+ +
+ + + +When enabled, the Queue Cleaner will run according to the configured schedule to automatically clean downloads from your download client queue. + + + + + +Choose how to configure the Queue Cleaner schedule: +- **Basic**: Simple interval-based scheduling (every X minutes/hours/seconds) +- **Advanced**: Full cron expression control for complex schedules + + + + + +Enter a valid Quartz cron expression to control when the Queue Cleaner runs. + +**Common Cron Examples:** +- `0 0/5 * ? * * *` - Every 5 minutes +- `0 0 * ? * * *` - Every hour +- `0 0 */6 ? * * *` - Every 6 hours + + + +
+ +
+ +

+ โŒ + Failed Import Settings +

+ + + +Number of strikes before a failed import download is removed from the queue. Set to 0 to disable failed import cleaning, minimum 3 to enable. + + + + + +When enabled, private torrents will be skipped during failed import cleaning. This is useful if you want to preserve private tracker content even when imports fail. + + +This setting needs a download client to be configured. + + + + + + +When enabled, private torrents that reach the maximum strikes will be deleted from the download client. Use with caution as this will permanently remove the download. + + +Setting this to true means private torrents will be permanently deleted, potentially affecting your private tracker account by receiving H&R if the seeding requirements are not met. + + + +This setting needs a download client to be configured. + + + + + + +**Examples:** +- `title mismatch` +- `manual import required` +- `recently aired` + +Failed imports containing these patterns in their name will be skipped during cleaning. Useful for avoiding removal of legitimate downloads that may have temporary import issues. + + + +
+ +
+ +

+ โธ๏ธ + Stalled Download Settings +

+ +

+ Stalled downloads are those that have stopped downloading and show no progress. +

+ + +These settings need a download client to be configured. + + + + +Number of strikes before a stalled download is removed from the queue. Set to 0 to disable stalled download cleaning, minimum 3 to enable. + + + + + +When enabled, strikes will be reset to zero if the download shows progress again. This prevents removal of downloads that temporarily stall but resume downloading. + + + + + +When enabled, private torrents will be skipped during stalled download cleaning. + + + + + +When enabled, private torrents that reach the maximum strikes will be deleted from the download client. + + +Setting this to true means private torrents will be permanently deleted, potentially affecting your private tracker account by receiving H&R if the seeding requirements are not met. + + + + + + +Number of strikes before a download stuck in "Downloading Metadata" state is removed (qBittorrent only). + + + +
+ +
+ +

+ ๐ŸŒ + Slow Download Settings +

+ +

+ Slow downloads are those downloading below a specified speed threshold. +

+ + +These settings need a download client to be configured. + + + + +Number of strikes before a slow download is removed from the queue. Set to 0 to disable slow download cleaning, minimum 3 to enable. + + + + + +When enabled, strikes will be reset if the download speed improves above the minimum threshold. + + + + + +When enabled, private torrents will be skipped during slow download cleaning. + + + + + +When enabled, private torrents that reach the maximum strikes will be deleted from the download client. + + +Setting this to true means private torrents will be permanently deleted, potentially affecting your private tracker account by receiving H&R if the seeding requirements are not met. + + + + + + +Minimum download speed threshold. Downloads consistently below this speed will accumulate strikes. + + + + + +Maximum time (in hours) that a download can take. Set to 0 to disable time-based removal. This works alongside the speed threshold. + + + + + +Downloads larger than this size will be ignored by slow download cleaning. Large files often download slower and may need more time to complete. + + + +
+ +
\ No newline at end of file diff --git a/docs/docs/1_cleanuperr.mdx b/docs/docs/index.mdx similarity index 60% rename from docs/docs/1_cleanuperr.mdx rename to docs/docs/index.mdx index 288ccfb9..dd6e30ce 100644 --- a/docs/docs/1_cleanuperr.mdx +++ b/docs/docs/index.mdx @@ -3,22 +3,29 @@ sidebar_position: 1 --- import { Warning } from '@site/src/components/Admonition'; +import { + EnhancedWarning, + styles +} from '@site/src/components/documentation'; -# Cleanuperr +# About -Cleanuperr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, and supported download clients like qBittorrent. It removes incomplete or blocked downloads, updates queues, and enforces blacklists or whitelists to manage file selection. After removing blocked content, cleanuperr can also trigger a search to replace the deleted shows/movies. +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. -Cleanuperr was created primarily to address malicious files, such as `*.lnk` or `*.zipx`, that were getting stuck in Sonarr/Radarr and required manual intervention. Some of the reddit posts that made cleanuperr come to life can be found [here](https://www.reddit.com/r/sonarr/comments/1gqnx16/psa_sonarr_downloaded_a_virus/), [here](https://www.reddit.com/r/sonarr/comments/1gqwklr/sonar_downloaded_a_mkv_file_which_looked_like_a/), [here](https://www.reddit.com/r/sonarr/comments/1gpw2wa/downloaded_waiting_to_import/) and [here](https://www.reddit.com/r/sonarr/comments/1gpi344/downloads_not_importing_no_files_found/). +
- +
+ +

+Cleanuparr was created primarily to address malicious files, such as `*.lnk` or `*.zipx`, that were getting stuck in Sonarr/Radarr and required manual intervention. Some of the reddit posts that made Cleanuparr come to life can be found [here](https://www.reddit.com/r/sonarr/comments/1gqnx16/psa_sonarr_downloaded_a_virus/), [here](https://www.reddit.com/r/sonarr/comments/1gqwklr/sonar_downloaded_a_mkv_file_which_looked_like_a/), [here](https://www.reddit.com/r/sonarr/comments/1gpw2wa/downloaded_waiting_to_import/) and [here](https://www.reddit.com/r/sonarr/comments/1gpi344/downloads_not_importing_no_files_found/). +

+ + Because this tool is actively developed and still a work in progress, using the `latest` Docker tag may result in breaking changes. Join the Discord server if you want to reach out to me quickly (or just stay updated on new releases) so we can squash those pesky bugs together: https://discord.gg/SCtMCgtsc4 - + +
-## Naming choice - -I've had people asking why it's `cleanuperr` and not `cleanuparr` and that I should change it. This name was intentional. - -I've seen a few discussions on this type of naming and I've decided that I didn't deserve the `arr` moniker since `cleanuperr` is not a fork of `NZB.Drone` and it does not have any affiliation with the arrs. I still wanted to keep the naming style close enough though, to suggest a correlation between them. \ No newline at end of file +
\ No newline at end of file diff --git a/docs/docs/installation/1_quick-start.mdx b/docs/docs/installation/1_quick-start.mdx deleted file mode 100644 index a5a2106f..00000000 --- a/docs/docs/installation/1_quick-start.mdx +++ /dev/null @@ -1,17 +0,0 @@ -import { Note } from '@site/src/components/Admonition'; - -# Quick start - -1. **Docker (Recommended)** - Pull the Docker image from `ghcr.io/flmorg/cleanuperr:latest`. - [Configuration example here.](/docs/configuration/examples/docker) -2. **Unraid (for Unraid users)** - Use the Unraid Community App. - [Configuration example here.](/docs/configuration/examples/docker) -3. **Manual Installation (if you're not using Docker)** - Go to [Windows](/docs/installation/windows), [Linux](/docs/installation/linux) or [MacOS](/docs/installation/macos). - [Configuration example here.](/docs/configuration/examples/config-file) - - - Refer to the [Configuration](/docs/category/configuration) section for detailed configuration instructions. - \ No newline at end of file diff --git a/docs/docs/installation/2_windows.mdx b/docs/docs/installation/2_windows.mdx deleted file mode 100644 index 0bba7dca..00000000 --- a/docs/docs/installation/2_windows.mdx +++ /dev/null @@ -1,31 +0,0 @@ -import { Note } from '@site/src/components/Admonition'; - -# Windows - - - The preferred method of installation method is using Docker. - - -1. Download the zip file from [releases](https://github.com/flmorg/cleanuperr/releases). -2. Extract the zip file into `C:\example\directory`. -3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](/docs/category/configuration). -4. Execute `cleanuperr.exe`. - - -### Run as a Windows Service -1. Download latest nssm build from `https://nssm.cc/builds`. -2. Unzip `nssm.exe` in `C:\example\directory`. -3. Open a terminal with Administrator rights and execute these commands: -``` -nssm.exe install Cleanuperr "C:\example\directory\cleanuperr.exe" -nssm.exe set Cleanuperr AppDirectory "C:\example\directory\" -nssm.exe set Cleanuperr AppStdout "C:\example\directory\cleanuperr.log" -nssm.exe set Cleanuperr AppStderr "C:\example\directory\cleanuperr.crash.log" -nssm.exe set Cleanuperr AppRotateFiles 1 -nssm.exe set Cleanuperr AppRotateOnline 1 -nssm.exe set Cleanuperr AppRotateBytes 10485760 -nssm.exe set Cleanuperr AppRotateFiles 10 -nssm.exe set Cleanuperr Start SERVICE_AUTO_START -nssm.exe start Cleanuperr -``` - \ No newline at end of file diff --git a/docs/docs/installation/3_linux.mdx b/docs/docs/installation/3_linux.mdx deleted file mode 100644 index f5312f4f..00000000 --- a/docs/docs/installation/3_linux.mdx +++ /dev/null @@ -1,17 +0,0 @@ -import { Note } from '@site/src/components/Admonition'; - -# Linux - - - The preferred method of installation method is using Docker. - - -1. Download the zip file from [releases](https://github.com/flmorg/cleanuperr/releases). -2. Extract the zip file into `/example/directory`. -3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](/docs/category/configuration). -4. Open a terminal and execute these commands: - ``` - cd /example/directory - chmod +x cleanuperr - ./cleanuperr - ``` \ No newline at end of file diff --git a/docs/docs/installation/4_macos.mdx b/docs/docs/installation/4_macos.mdx deleted file mode 100644 index 9acd4e9a..00000000 --- a/docs/docs/installation/4_macos.mdx +++ /dev/null @@ -1,25 +0,0 @@ -import { Important, Note } from '@site/src/components/Admonition'; - -# MacOS - - - The preferred method of installation method is using Docker. - - -1. Download the zip file from [releases](https://github.com/flmorg/cleanuperr/releases). -2. Extract the zip file into `/example/directory`. -3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](/docs/category/configuration). -4. Open a terminal and execute these commands: - ``` - cd /example/directory - chmod +x cleanuperr - ./cleanuperr - ``` - - -Some people have experienced problems when trying to execute cleanuperr on MacOS because the system actively blocked the file for not being signed. -As per [this comment](https://stackoverflow.com/a/77907937), you may need to also execute this command: -``` -codesign --sign - --force --preserve-metadata=entitlements,requirements,flags,runtime /example/directory/cleanuperr -``` - diff --git a/docs/docs/installation/5_freebsd.mdx b/docs/docs/installation/5_freebsd.mdx deleted file mode 100644 index 9fa6a11f..00000000 --- a/docs/docs/installation/5_freebsd.mdx +++ /dev/null @@ -1,53 +0,0 @@ -import { Note } from '@site/src/components/Admonition'; - -# FreeBSD - - - The preferred method of installation method is using Docker. - - -1. Installation: - ``` - # install dependencies - pkg install -y git icu libinotify libunwind wget - - # set up the dotnet SDK - cd ~ - wget -q https://github.com/Thefrank/dotnet-freebsd-crossbuild/releases/download/v9.0.104-amd64-freebsd-14/dotnet-sdk-9.0.104-freebsd-x64.tar.gz - export DOTNET_ROOT=$(pwd)/.dotnet - mkdir -p "$DOTNET_ROOT" && tar zxf dotnet-sdk-9.0.104-freebsd-x64.tar.gz -C "$DOTNET_ROOT" - export PATH=$PATH:$DOTNET_ROOT:$DOTNET_ROOT/tools - - # download NuGet dependencies - mkdir -p /tmp/nuget - wget -q -P /tmp/nuget/ https://github.com/Thefrank/dotnet-freebsd-crossbuild/releases/download/v9.0.104-amd64-freebsd-14/Microsoft.AspNetCore.App.Runtime.freebsd-x64.9.0.3.nupkg - wget -q -P /tmp/nuget/ https://github.com/Thefrank/dotnet-freebsd-crossbuild/releases/download/v9.0.104-amd64-freebsd-14/Microsoft.NETCore.App.Host.freebsd-x64.9.0.3.nupkg - wget -q -P /tmp/nuget/ https://github.com/Thefrank/dotnet-freebsd-crossbuild/releases/download/v9.0.104-amd64-freebsd-14/Microsoft.NETCore.App.Runtime.freebsd-x64.9.0.3.nupkg - - # add NuGet source - dotnet nuget add source /tmp/nuget --name tmp - - # add GitHub NuGet source - # a PAT (Personal Access Token) can be generated here https://github.com/settings/tokens - dotnet nuget add source --username --password --store-password-in-clear-text --name flmorg https://nuget.pkg.github.com/flmorg/index.json - ``` -2. Building: - ``` - # clone the project - git clone https://github.com/flmorg/cleanuperr.git - cd cleanuperr - - # build and publish the app - dotnet publish code/Executable/Executable.csproj -c Release --self-contained -o artifacts /p:PublishSingleFile=true - - # move the files to permanent destination - mv artifacts/cleanuperr /example/directory/ - mv artifacts/appsettings.json /example/directory/ - ``` -3. Edit **appsettings.json**. The paths from this json file correspond with the docker env vars, as described [here](/docs/category/configuration). -4. Run the app: - ``` - cd /example/directory - chmod +x cleanuperr - ./cleanuperr - ``` \ No newline at end of file diff --git a/docs/docs/installation/_category_.json b/docs/docs/installation/_category_.json index b733097a..51f29b7f 100644 --- a/docs/docs/installation/_category_.json +++ b/docs/docs/installation/_category_.json @@ -2,6 +2,6 @@ "label": "Installation", "position": 5, "link": { - "type": "generated-index", + "type": "generated-index" } } diff --git a/docs/docs/installation/detailed.mdx b/docs/docs/installation/detailed.mdx new file mode 100644 index 00000000..e788e28e --- /dev/null +++ b/docs/docs/installation/detailed.mdx @@ -0,0 +1,275 @@ +--- +sidebar_position: 2 +--- + +import { Note, Important } from '@site/src/components/Admonition'; + +# Installation Guide + +This guide will walk you through the installation process for Cleanuparr. Cleanuparr can be installed in several ways depending on your preference and system configuration. + + +For most users, we recommend the Docker installation method as it provides the most consistent experience across all platforms. + + +## Table of Contents + +- [Docker Installation (Recommended)](#docker-installation-recommended) + - [Docker Run Method](#docker-run-method) + - [Docker Compose Method](#docker-compose-method) +- [Windows Installation](#windows-installation) + - [Windows Installer](#windows-installer) + - [Windows Portable](#windows-portable) +- [macOS Installation](#macos-installation) + - [macOS Installer](#macos-installer) + - [macOS Portable](#macos-portable) +- [Linux Installation](#linux-installation) +- [Post Installation](#post-installation) +- [Troubleshooting](#troubleshooting) + +--- + +## Docker Installation (Recommended) + +Docker is the preferred installation method as it ensures all dependencies are correctly installed and provides consistent behavior across all platforms. + +### Prerequisites +- Docker (version 20.10 or newer) +- Docker Compose (optional, for compose method) + +### Docker Run Method + +The simplest way to run Cleanuparr is with a single Docker command: + +#### Option 1: GitHub Container Registry (Recommended) +```bash +docker run -d --name cleanuparr \ + --restart unless-stopped \ + -p 11011:11011 \ + -v /path/to/config:/config \ + -e PORT=11011 \ + -e BASE_PATH= \ + -e PUID=1000 \ + -e PGID=1000 \ + -e UMASK=022 \ + -e TZ=Etc/UTC \ + ghcr.io/cleanuparr:latest +``` + +#### Option 2: DockerHub +```bash +docker run -d --name cleanuparr \ + --restart unless-stopped \ + -p 11011:11011 \ + -v /path/to/config:/config \ + -e PORT=11011 \ + -e BASE_PATH= \ + -e PUID=1000 \ + -e PGID=1000 \ + -e UMASK=022 \ + -e TZ=Etc/UTC \ + cleanuparr/cleanuparr:latest +``` + +### Docker Compose Method + +For easier management, create a `docker-compose.yml` file: + +#### Option 1: GitHub Container Registry +```yaml +services: + cleanuparr: + image: ghcr.io/cleanuparr/cleanuparr:latest + container_name: cleanuparr + restart: unless-stopped + ports: + - "11011:11011" + volumes: + - /path/to/config:/config + environment: + - PORT=11011 + - BASE_PATH= + - PUID=1000 + - PGID=1000 + - UMASK=022 + - TZ=Etc/UTC +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | 11011 | Port for the web interface | +| `BASE_PATH` | *(empty)* | Base path for reverse proxy setups | +| `PUID` | 1000 | User ID for file permissions | +| `PGID` | 1000 | Group ID for file permissions | +| `UMASK` | 022 | File creation mask | +| `TZ` | Etc/UTC | Timezone setting | + +### Volume Mounts + +| Container Path | Description | +|----------------|-------------| +| `/config` | Configuration files and database | + + +Replace `/path/to/config` with your desired configuration directory path on the host system. + + +--- + +## Windows Installation + +### Windows Installer + +The easiest way to install Cleanuparr on Windows is using the provided installer. + +#### Installation Steps +1. Download the Windows installer (`.exe`) from the [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases) page +2. Double-click the downloaded `.exe` file to start installation +3. **Important**: When Windows Defender SmartScreen appears, click "More info" and then "Run anyway" +4. Follow the on-screen instructions to complete installation +5. Cleanuparr will be installed as a Windows service and start automatically + +#### Default Configuration +- **Web Interface**: `http://localhost:11011` +- **Service Name**: Cleanuparr +- **Installation Directory**: `C:\Program Files\Cleanuparr\` +- **Configuration**: `C:\ProgramFiles\Cleanuparr\config\` + +### Windows Portable + +For users who prefer a portable installation: + +1. Download the `Cleanuparr-{version}-win-amd64.zip` from [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases) +2. Extract the zip file to your desired directory (e.g., `C:\Tools\Cleanuparr\`) +3. Run `Cleanuparr.exe` to start the application +4. Access the web interface at `http://localhost:11011` + + +The portable version requires manual startup and doesn't install as a Windows service. + + +--- + +## macOS Installation + +### macOS Installer + +Cleanuparr provides native macOS installers for both Intel and Apple Silicon Macs. + +#### Installation Steps +1. Download the appropriate `.pkg` installer from [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases): + - `Cleanuparr-{version}-macos-intel.pkg` for Intel Macs + - `Cleanuparr-{version}-macos-arm64.pkg` for Apple Silicon Macs +2. Double-click the downloaded `.pkg` file +3. When macOS shows a security warning, go to **System Settings โ†’ Privacy & Security** +4. Scroll down and click **"Open Anyway"** to proceed with installation +5. Follow the installation prompts +6. Cleanuparr will be installed as a system service and start automatically + +#### Default Configuration +- **Web Interface**: `http://localhost:11011` +- **Application**: `/Applications/Cleanuparr.app` +- **Configuration**: `/Applications/Cleanuparr.app/Contents/MacOS/config/` +- **Service**: Managed by `launchd` + + +macOS will show security warnings for unsigned applications. This is normal - click "Open Anyway" in System Settings to proceed. + + +### macOS Portable + +For users who prefer a portable installation: + +1. Download the appropriate zip file from [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases): + - `Cleanuparr-{version}-osx-amd64.zip` for Intel Macs + - `Cleanuparr-{version}-osx-arm64.zip` for Apple Silicon Macs +2. Extract the zip file to your desired directory +3. Open Terminal and navigate to the extracted directory +4. Make the binary executable: `chmod +x Cleanuparr` +5. Run: `./Cleanuparr` +6. Access the web interface at `http://localhost:11011` + +--- + +## Linux Installation + +Linux users can use the portable executables, as we don't provide distribution-specific packages. + +### Portable Installation + +1. Download the appropriate zip file from [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases): + - `Cleanuparr-{version}-linux-amd64.zip` for x86_64 systems + - `Cleanuparr-{version}-linux-arm64.zip` for ARM64 systems +2. Extract the zip file: + ```bash + unzip Cleanuparr-{version}-linux-amd64.zip + cd Cleanuparr-{version}-linux-amd64/ + ``` +3. Make the binary executable: + ```bash + chmod +x Cleanuparr + ``` +4. Run Cleanuparr: + ```bash + ./Cleanuparr + ``` +5. Access the web interface at `http://localhost:11011` + +### Running as a Service (Systemd) + +To run Cleanuparr as a systemd service: + +1. Create a service file: + ```bash + sudo nano /etc/systemd/system/cleanuparr.service + ``` + +2. Add the following content: + ```ini + [Unit] + Description=Cleanuparr + After=network.target + + [Service] + Type=simple + User=cleanuparr + Group=cleanuparr + ExecStart=/opt/cleanuparr/Cleanuparr + WorkingDirectory=/opt/cleanuparr + Restart=always + RestartSec=5 + Environment=PORT=11011 + Environment=BASE_PATH= + + [Install] + WantedBy=multi-user.target + ``` + +3. Create a dedicated user: + ```bash + sudo useradd -r -s /bin/false cleanuparr + ``` + +4. Move Cleanuparr to `/opt/cleanuparr` and set ownership: + ```bash + sudo mkdir -p /opt/cleanuparr + sudo cp Cleanuparr /opt/cleanuparr/ + sudo chown -R cleanuparr:cleanuparr /opt/cleanuparr + ``` + +5. Enable and start the service: + ```bash + sudo systemctl enable cleanuparr + sudo systemctl start cleanuparr + ``` + +--- + +## Post Installation + +#### Default Configuration +- **Web Interface**: `http://localhost:11011` +- **Base Path**: *(empty)* (for reverse proxy setups, change `BASE_PATH` environment variable) +- **Configuration Location**: Varies by platform and installation method diff --git a/docs/docs/installation/freebsd.mdx b/docs/docs/installation/freebsd.mdx new file mode 100644 index 00000000..cd35a329 --- /dev/null +++ b/docs/docs/installation/freebsd.mdx @@ -0,0 +1,160 @@ +--- +sidebar_position: 3 +--- + +import { Note } from '@site/src/components/Admonition'; +import { + StepGuide, + Step, + EnhancedNote, + styles +} from '@site/src/components/documentation'; + +# FreeBSD + +Build Cleanuparr from source on FreeBSD systems with full dependency management and native compilation. + + +The preferred method of installation method is using Docker. + + +
+ +
+ + + +Install the required packages and set up the development environment: + +```bash +# Install basic dependencies +pkg install -y git icu libinotify libunwind wget node npm +``` + + + +Download and configure the .NET SDK for FreeBSD: + +```bash +# Navigate to home directory +cd ~ + +# Set up variables for cleaner commands +DOTNET_VERSION="v9.0.104-amd64-freebsd-14" +DOTNET_BASE_URL="https://github.com/Thefrank/dotnet-freebsd-crossbuild/releases/download" + +# Download .NET SDK +wget -q "${DOTNET_BASE_URL}/${DOTNET_VERSION}/dotnet-sdk-9.0.104-freebsd-x64.tar.gz" + +# Set up .NET environment +export DOTNET_ROOT=$(pwd)/.dotnet +mkdir -p "$DOTNET_ROOT" +tar zxf dotnet-sdk-9.0.104-freebsd-x64.tar.gz -C "$DOTNET_ROOT" +export PATH=$PATH:$DOTNET_ROOT:$DOTNET_ROOT/tools +``` + + + +Install Angular CLI globally for frontend build: + +```bash +npm install -g @angular/cli +``` + + + +Download required NuGet packages for FreeBSD: + +```bash +# Create NuGet directory +mkdir -p /tmp/nuget + +# Set up variables for package URLs +NUGET_BASE_URL="${DOTNET_BASE_URL}/${DOTNET_VERSION}" +RUNTIME_VERSION="9.0.3" + +# Download required packages +wget -q -P /tmp/nuget/ \ + "${NUGET_BASE_URL}/Microsoft.AspNetCore.App.Runtime.freebsd-x64.${RUNTIME_VERSION}.nupkg" + +wget -q -P /tmp/nuget/ \ + "${NUGET_BASE_URL}/Microsoft.NETCore.App.Host.freebsd-x64.${RUNTIME_VERSION}.nupkg" + +wget -q -P /tmp/nuget/ \ + "${NUGET_BASE_URL}/Microsoft.NETCore.App.Runtime.freebsd-x64.${RUNTIME_VERSION}.nupkg" +``` + + + +Add NuGet package sources: + +```bash +# Add local NuGet source +dotnet nuget add source /tmp/nuget --name tmp + +# Add GitHub NuGet source +# Note: Generate a PAT at https://github.com/settings/tokens +dotnet nuget add source \ + --username \ + --password \ + --store-password-in-clear-text \ + --name Cleanuparr \ + https://nuget.pkg.github.com/Cleanuparr/index.json +``` + +**Important:** Replace `` and `` with your actual GitHub credentials. + + + +Clone the repository and build the frontend: + +```bash +# Clone the project +git clone https://github.com/Cleanuparr/Cleanuparr.git +cd Cleanuparr + +# Build the frontend +cd code/frontend +npm ci +npm run build +cd ../.. +``` + + + +Copy frontend assets and build the backend: + +```bash +# Copy frontend build to backend +mkdir -p code/backend/Cleanuparr.Api/wwwroot +cp -r code/frontend/dist/ui/browser/* \ + code/backend/Cleanuparr.Api/wwwroot/ + +# Build and publish the backend +dotnet publish code/backend/Cleanuparr.Api/Cleanuparr.Api.csproj \ + -c Release \ + --self-contained \ + -o artifacts \ + /p:PublishSingleFile=true + +# Move to final destination +mv artifacts/Cleanuparr /example/directory/ +``` + + + +Make the binary executable and start Cleanuparr: + +```bash +cd /example/directory +chmod +x Cleanuparr +./Cleanuparr +``` + +The application will start and be available at `http://localhost:11011` by default. + + + +
+ +
\ No newline at end of file diff --git a/docs/docs/installation/index.mdx b/docs/docs/installation/index.mdx new file mode 100644 index 00000000..f63458b1 --- /dev/null +++ b/docs/docs/installation/index.mdx @@ -0,0 +1,62 @@ +--- +sidebar_position: 1 +--- + +import { Note } from '@site/src/components/Admonition'; + +# Quick Start + +The fastest way to get started with Cleanuparr: + +## ๐Ÿณ Docker (Recommended) + +**Option 1: Docker Run** +```bash +docker run -d --name cleanuparr \ + --restart unless-stopped \ + -p 11011:11011 \ + -v /path/to/config:/config \ + -e PORT=11011 \ + -e BASE_PATH= \ + -e PUID=1000 \ + -e PGID=1000 \ + -e UMASK=022 \ + -e TZ=Etc/UTC \ + ghcr.io/cleanuparr/cleanuparr:latest +``` + +**Option 2: Docker Compose** +```yaml +services: + cleanuparr: + image: ghcr.io/cleanuparr/cleanuparr:latest + container_name: cleanuparr + restart: unless-stopped + ports: + - "11011:11011" + volumes: + - /path/to/config:/config + environment: + - PORT=11011 + - BASE_PATH= + - PUID=1000 + - PGID=1000 + - UMASK=022 + - TZ=Etc/UTC +``` + +## ๐Ÿ–ฅ๏ธ Other Installation Methods + +- **Windows**: Download the installer from [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases) +- **macOS**: Download the `.pkg` installer for your Mac type (Intel/Apple Silicon) +- **Linux**: Download and extract the portable executable + + +For detailed installation instructions, security notes, troubleshooting, and all platform-specific options, see the [Complete Installation Guide](/docs/installation/installation). + + +## ๐Ÿš€ After Installation + +1. Open your browser and go to `http://localhost:11011` +2. Configure your download clients and *arr applications +3. Refer to the [Configuration](/docs/category/configuration) section for detailed setup instructions \ No newline at end of file diff --git a/docs/docs/setup-scenarios/1_qbit-built-in.mdx b/docs/docs/setup-scenarios/1_qbit-built-in.mdx index 0d1dcea6..2b9b0f53 100644 --- a/docs/docs/setup-scenarios/1_qbit-built-in.mdx +++ b/docs/docs/setup-scenarios/1_qbit-built-in.mdx @@ -1,15 +1,44 @@ import { Note } from '@site/src/components/Admonition'; +import { + StepGuide, + Step, + EnhancedNote, + styles +} from '@site/src/components/documentation'; -# Using qBit's built-in blacklist (torrent) +# Using qBittorrent's Built-in Blacklist -1. Go to qBittorrent -> Options -> Downloads -> make sure `Excluded file names` is checked -> Paste an exclusion list that you have copied. - - [blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist), or - - [permissive blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist_permissive), or - - create your own -2. qBittorrent will block files from being downloaded. In the case of malicious content, **nothing is downloaded and the torrent is marked as complete**. -3. Start **cleanuperr** with `QUEUECLEANER__ENABLED` set to `true`. -4. The **Queue Cleaner** will perform a cleanup process as described in the [How it works](/docs/how_it_works) section. +Configure qBittorrent's native file exclusion features combined with Cleanuparr's Queue Cleaner for automatic malicious content blocking. - - This scenario is an example for blocking malicious files. Other features can be included here by configuring the environment variables. - \ No newline at end of file +
+ +
+ + + + Go to qBittorrent โ†’ Options โ†’ Downloads โ†’ make sure `Excluded file names` is checked โ†’ Paste an exclusion list that you have copied. + - [blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist), or + - [permissive blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist_permissive), or + - create your own + + + + qBittorrent will block files from being downloaded. In the case of malicious content, **nothing is downloaded and the torrent is marked as complete**. + + + + Use **Cleanuparr** with `Queue Cleaner` enabled. + + + + The **Queue Cleaner** will perform a cleanup process as described in the [How it works](/docs/how_it_works) section. + + + + +This scenario is an example for blocking malicious files. Other features can be included here by configuring the environment variables. + + +
+ +
\ No newline at end of file diff --git a/docs/docs/setup-scenarios/2_cleanuperr-blocklist.mdx b/docs/docs/setup-scenarios/2_cleanuperr-blocklist.mdx index e0f3c51e..f285ccf2 100644 --- a/docs/docs/setup-scenarios/2_cleanuperr-blocklist.mdx +++ b/docs/docs/setup-scenarios/2_cleanuperr-blocklist.mdx @@ -1,13 +1,40 @@ import { Note } from '@site/src/components/Admonition'; +import { + StepGuide, + Step, + EnhancedNote, + styles +} from '@site/src/components/documentation'; -# Using Cleanuperr's blocklist feature (torrent) +# Using Cleanuparr's Content Blocker -1. Set both `QUEUECLEANER__ENABLED` and `CONTENTBLOCKER__ENABLED` to `true` in your environment variables. -2. Configure and enable either a **blacklist** or a **whitelist** as described in the [Arr settings](/docs/category/arrs-settings) section. -3. Once configured, cleanuperr will perform the following tasks: - - Execute the **Content Blocker** job, as explained in the [How it works](/docs/how_it_works) section. - - Execute the **Queue Cleaner** job, as explained in the [How it works](/docs/how_it_works) section. +Configure Cleanuparr's Content Blocker feature to automatically filter downloads based on custom blocklists. - - This scenario is an example for blocking malicious files. Other features can be included here by configuring the environment variables. - \ No newline at end of file +
+ +
+ + + + Use **Cleanuparr** with `Content Blocker` enabled. + + + + Configure the `Blocklist Path` to use a blacklist: + - [blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist), or + - [permissive blacklist](https://raw.githubusercontent.com/Cleanuparr/Cleanuparr/refs/heads/main/blacklist_permissive), or + - create your own + + + + The **Content Blocker** will perform a cleanup process as described in the [How it works](/docs/how_it_works) section. + + + + +This scenario is an example for blocking malicious files. Other features can be included here by configuring the environment variables. + + +
+ +
\ No newline at end of file diff --git a/docs/docs/setup-scenarios/3_failed-imports.mdx b/docs/docs/setup-scenarios/3_failed-imports.mdx index 6951aeb6..008ab636 100644 --- a/docs/docs/setup-scenarios/3_failed-imports.mdx +++ b/docs/docs/setup-scenarios/3_failed-imports.mdx @@ -1,16 +1,37 @@ -import { Important } from '@site/src/components/Admonition'; +import { Note } from '@site/src/components/Admonition'; +import { + StepGuide, + Step, + EnhancedNote, + styles +} from '@site/src/components/documentation'; -# Using Cleanuperr just for failed imports (torrent and usenet) +# Managing Failed Imports -1. Set `QUEUECLEANER__ENABLED` to `true`. -2. Set `QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES` to a desired value. -3. Optionally set failed import message patterns to ignore using `QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__`. -4. Set `DOWNLOAD_CLIENT` to `none`(works only for usenet) or `disabled` (works for both usenet and torrent). +Automatically handle downloads that fail to import properly into your *arr applications using the Queue Cleaner's strike system. - - When `DOWNLOAD_CLIENT=disabled`, no other action involving a download client would work (e.g. content blocking, removing stalled downloads, excluding private trackers). +
- When the download client is set to `disabled`, the queue cleaner will be able to remove items that are failed to be imported even if there is no download client configured. This means that all downloads, including private ones, will be completely removed. +
- Setting `DOWNLOAD_CLIENT=disabled` means you don't care about seeding, ratio, H&R and potentially losing your private tracker account. - \ No newline at end of file + + + Use **Cleanuparr** with `Queue Cleaner` enabled. + + + + Configure `Failed Import Max Strikes` to set how many failed attempts before removal. + + + + The **Queue Cleaner** will monitor import failures and apply strikes as described in the [How it works](/docs/how_it_works) section. + + + + +This scenario focuses on handling import failures. Other features can be combined by configuring additional Queue Cleaner settings. + + +
+ +
\ No newline at end of file diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index ccfe7c17..8b30b9f2 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -3,15 +3,15 @@ import type {Config} from '@docusaurus/types'; import type * as Preset from '@docusaurus/preset-classic'; const config: Config = { - title: 'Cleanuperr', - tagline: 'Cleaning arrs since \'24.', - favicon: 'img/16.png', + title: 'Cleanuparr', + tagline: 'Cleaning *arrs since \'24.', + favicon: 'img/favicon.ico', - url: 'https://flmorg.github.io', - baseUrl: '/cleanuperr/', + url: 'https://cleanuparr.github.io', + baseUrl: '/cleanuparr/', - organizationName: 'flmorg', - projectName: 'cleanuperr', + organizationName: 'Cleanuparr', + projectName: 'Cleanuparr', onBrokenLinks: 'throw', onBrokenMarkdownLinks: 'warn', @@ -42,10 +42,10 @@ const config: Config = { respectPrefersColorScheme: false, }, navbar: { - title: 'Cleanuperr', + title: 'Cleanuparr', logo: { - alt: 'Cleanuperr Logo', - src: 'img/cleanuperr.svg', + alt: 'Cleanuparr Logo', + src: 'img/cleanuparr.svg', }, items: [ { @@ -56,7 +56,12 @@ const config: Config = { activeBasePath: '/docs', }, { - href: 'https://github.com/flmorg/cleanuperr', + to: '/support', + label: 'Support', + position: 'left', + }, + { + href: 'https://github.com/Cleanuparr/Cleanuparr', label: 'GitHub', position: 'right', }, @@ -70,12 +75,13 @@ const config: Config = { footer: { style: 'dark', links: [], - copyright: `Copyright ยฉ ${new Date().getFullYear()} Cleanuperr. Built with Docusaurus.`, + copyright: `Copyright ยฉ ${new Date().getFullYear()} Cleanuparr. Built with Docusaurus.`, }, prism: { theme: prismThemes.github, darkTheme: prismThemes.dracula, }, + /* algolia: { // The application ID provided by Algolia appId: 'Y4APRVTFUQ', @@ -98,6 +104,7 @@ const config: Config = { //... other Algolia params }, + */ } satisfies Preset.ThemeConfig, }; diff --git a/docs/src/components/configuration/arrs/LidarrSettings.tsx b/docs/src/components/configuration/arrs/LidarrSettings.tsx deleted file mode 100644 index 5e9b3430..00000000 --- a/docs/src/components/configuration/arrs/LidarrSettings.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from "react"; -import EnvVars, { EnvVarProps } from "../EnvVars"; - -const settings: EnvVarProps[] = [ - { - name: "LIDARR__ENABLED", - description: [ - "Enables or disables Lidarr cleanup." - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - }, - { - name: "LIDARR__IMPORT_FAILED_MAX_STRIKES", - description: [ - "Number of strikes before removing a failed import. Set to `0` to never remove failed imports.", - "A strike is given when an item fails to be imported." - ], - type: "integer number", - defaultValue: "-1", - required: false, - notes: [ - "If the value is a positive number, it overwrites the values of [QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES](/cleanuperr/docs/configuration/queue-cleaner/import-failed?QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES).", - "`0` means to never remove failed imports.", - "If not set to `0` or a negative number, the minimum value is `3`.", - ], - warnings: [ - "The value is not restricted to be a certain positive number. Use a low value (e.g. `1`) at your own risk." - ] - }, - { - name: "LIDARR__BLOCK__TYPE", - description: [ - "Determines how file blocking works for Lidarr." - ], - type: "text", - defaultValue: "blacklist", - required: false, - acceptedValues: ["blacklist", "whitelist"], - }, - { - name: "LIDARR__BLOCK__PATH", - description: [ - "Path to the blocklist file (local file or URL).", - "The value must be JSON compatible.", - { - type: "code", - title: "The blocklists support the following patterns:", - content: `*example // file name ends with \"example\" -example* // file name starts with \"example\" -*example* // file name has \"example\" in the name -example // file name is exactly the word \"example\" -regex: // regex that needs to be marked at the start of the line with \"regex:\"`, - } - ], - type: "text", - defaultValue: "Empty", - required: false, - examples: ["/blocklist.json", "https://example.com/blocklist.json"] - }, - { - name: "LIDARR__INSTANCES__0__URL", - description: [ - "URL of the Lidarr instance." - ], - type: "text", - defaultValue: "http://localhost:8686", - required: false, - examples: ["http://localhost:8686", "http://lidarr:8686"], - }, - { - name: "LIDARR__INSTANCES__0__APIKEY", - description: [ - "API key for the Lidarr instance." - ], - type: "text", - defaultValue: "Empty", - required: false, - } -]; - -export default function LidarrSettings() { - return ; -} \ No newline at end of file diff --git a/docs/src/components/configuration/arrs/RadarrSettings.tsx b/docs/src/components/configuration/arrs/RadarrSettings.tsx deleted file mode 100644 index 1ed62fcc..00000000 --- a/docs/src/components/configuration/arrs/RadarrSettings.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from "react"; -import EnvVars, { EnvVarProps } from "../EnvVars"; - -const settings: EnvVarProps[] = [ - { - name: "RADARR__ENABLED", - description: [ - "Enables or disables Radarr cleanup." - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - }, - { - name: "RADARR__IMPORT_FAILED_MAX_STRIKES", - description: [ - "Number of strikes before removing a failed import. Set to `0` to never remove failed imports.", - "A strike is given when an item fails to be imported." - ], - type: "integer number", - defaultValue: "-1", - required: false, - notes: [ - "If the value is a positive number, it overwrites the values of [QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES](/cleanuperr/docs/configuration/queue-cleaner/import-failed?QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES).", - "`0` means to never remove failed imports.", - "If not set to `0` or a negative number, the minimum value is `3`.", - ], - warnings: [ - "The value is not restricted to be a certain positive number. Use a low value (e.g. `1`) at your own risk." - ] - }, - { - name: "RADARR__BLOCK__TYPE", - description: [ - "Determines how file blocking works for Radarr." - ], - type: "text", - defaultValue: "blacklist", - required: false, - acceptedValues: ["blacklist", "whitelist"], - }, - { - name: "RADARR__BLOCK__PATH", - description: [ - "Path to the blocklist file (local file or URL).", - "The value must be JSON compatible.", - { - type: "code", - title: "The blocklists support the following patterns:", - content: `*example // file name ends with \"example\" -example* // file name starts with \"example\" -*example* // file name has \"example\" in the name -example // file name is exactly the word \"example\" -regex: // regex that needs to be marked at the start of the line with \"regex:\"`, - } - ], - type: "text", - defaultValue: "Empty", - required: false, - examples: ["/blocklist.json", "https://example.com/blocklist.json"], - notes: [ - "[This blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist), [this permissive blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist_permissive) and [this whitelist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/whitelist) can be used for Sonarr and Radarr." - ] - }, - { - name: "RADARR__INSTANCES__0__URL", - description: [ - "URL of the Radarr instance." - ], - type: "text", - defaultValue: "http://localhost:7878", - required: false, - examples: ["http://localhost:7878", "http://radarr:7878"], - }, - { - name: "RADARR__INSTANCES__0__APIKEY", - description: [ - "API key for the Radarr instance." - ], - type: "text", - defaultValue: "Empty", - required: false - } -]; - -export default function RadarrSettings() { - return ; -} \ No newline at end of file diff --git a/docs/src/components/configuration/arrs/SonarrSettings.tsx b/docs/src/components/configuration/arrs/SonarrSettings.tsx deleted file mode 100644 index 807fd3cb..00000000 --- a/docs/src/components/configuration/arrs/SonarrSettings.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from "react"; -import EnvVars, { EnvVarProps } from "../EnvVars"; - -const settings: EnvVarProps[] = [ - { - name: "SONARR__ENABLED", - description: [ - "Enables or disables Sonarr cleanup." - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - }, - { - name: "SONARR__IMPORT_FAILED_MAX_STRIKES", - description: [ - "Number of strikes before removing a failed import. Set to `0` to never remove failed imports.", - "A strike is given when an item fails to be imported." - ], - type: "integer number", - defaultValue: "-1", - required: false, - notes: [ - "If the value is a positive number, it overwrites the values of [QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES](/cleanuperr/docs/configuration/queue-cleaner/import-failed?QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES).", - "`0` means to never remove failed imports.", - "If not set to `0` or a negative number, the minimum value is `3`.", - ], - warnings: [ - "The value is not restricted to be a certain positive number. Use a low value (e.g. `1`) at your own risk." - ] - }, - { - name: "SONARR__BLOCK__TYPE", - description: [ - "Determines how file blocking works for Sonarr." - ], - type: "text", - defaultValue: "blacklist", - required: false, - acceptedValues: ["blacklist", "whitelist"], - }, - { - name: "SONARR__BLOCK__PATH", - description: [ - "Path to the blocklist file (local file or URL).", - "The value must be JSON compatible.", - { - type: "code", - title: "The blocklists support the following patterns:", - content: `*example // file name ends with \"example\" -example* // file name starts with \"example\" -*example* // file name has \"example\" in the name -example // file name is exactly the word \"example\" -regex: // regex that needs to be marked at the start of the line with \"regex:\"`, - } - ], - type: "text", - defaultValue: "Empty", - required: false, - examples: ["/blocklist.json", "https://example.com/blocklist.json"], - notes: [ - "[This blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist), [this permissive blacklist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist_permissive) and [this whitelist](https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/whitelist) can be used for Sonarr and Radarr." - ] - }, - { - name: "SONARR__SEARCHTYPE", - description: [ - "Determines what to search for after removing a queue item." - ], - type: "text", - defaultValue: "Episode", - required: false, - acceptedValues: ["Episode", "Season", "Series"], - }, - { - name: "SONARR__INSTANCES__0__URL", - description: [ - "URL of the Sonarr instance." - ], - type: "text", - defaultValue: "http://localhost:8989", - required: false, - examples: ["http://localhost:8989", "http://sonarr:8989"], - }, - { - name: "SONARR__INSTANCES__0__APIKEY", - description: [ - "API key for the Sonarr instance." - ], - type: "text", - defaultValue: "Empty", - required: false - } -]; - -export default function SonarrSettings() { - return ; -} \ No newline at end of file diff --git a/docs/src/components/configuration/content-blocker/ContentBlockerGeneralSettings.tsx b/docs/src/components/configuration/content-blocker/ContentBlockerGeneralSettings.tsx deleted file mode 100644 index 2659c770..00000000 --- a/docs/src/components/configuration/content-blocker/ContentBlockerGeneralSettings.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React from "react"; -import EnvVars, { EnvVarProps } from "../EnvVars"; - -const settings: EnvVarProps[] = [ - { - name: "CONTENTBLOCKER__ENABLED", - description: [ - "Enables or disables the Content Blocker functionality.", - "When enabled, processes all items in the *arr queue and marks unwanted files." - ], - type: "boolean", - defaultValue: "false", - required: false, - examples: ["true", "false"], - }, - { - name: "TRIGGERS__CONTENTBLOCKER", - description: [ - "Cron schedule for the Content Blocker job." - ], - type: "text", - reference: "https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html", - defaultValue: "0 0/5 * * * ?", - defaultValueComment: "every 5 minutes", - required: "Only required if CONTENTBLOCKER__ENABLED is true", - examples: ["0 0/5 * * * ?", "0 0 * * * ?", "0 0 0/1 * * ?"], - notes: [ - "Maximum interval is 6 hours." - ] - }, - { - name: "CONTENTBLOCKER__IGNORED_DOWNLOADS_PATH", - description: [ - "Local path to the file containing downloads to be ignored from being processed by Cleanuperr.", - "If the contents of the file are changed, they will be reloaded on the next job run.", - "This file is not automatically created, so you need to create it manually.", - { - type: "list", - title: - "Accepted values inside the file (each value needs to be on a new line):", - content: [ - "torrent hash", - "qBitTorrent tag or category", - "Deluge label", - "Transmission category (last directory from the save location)", - "torrent tracker domain", - ], - }, - { - type: "code", - title: "Example of file contents:", - content: `fa800a7d7c443a2c3561d1f8f393c089036dade1 -tv-sonarr -qbit-tag -mytracker.com -...`, - }, - ], - type: "text", - defaultValue: "Empty", - required: false, - examples: ["/ignored.txt", "/config/ignored.txt"], - warnings: [ - "Some people have experienced problems using Docker where the mounted file would not update inside the container if it was modified on the host. This is a Docker configuration problem and can not be solved by cleanuperr.", - ], - }, - { - name: "CONTENTBLOCKER__IGNORE_PRIVATE", - description: [ - "Controls whether to ignore downloads from private trackers from being processed by Cleanuperr." - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - }, - { - name: "CONTENTBLOCKER__DELETE_PRIVATE", - description: [ - "Controls whether to delete private downloads that have all files blocked from the download client.", - "Has no effect if CONTENTBLOCKER__IGNORE_PRIVATE is true." - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - important: [ - "Setting CONTENTBLOCKER__DELETE_PRIVATE=true means you don't care about seeding, ratio, H&R and potentially losing your private tracker account." - ] - } -]; - -export default function ContentBlockerGeneralSettings() { - return ; -} \ No newline at end of file diff --git a/docs/src/components/configuration/download-cleaner/DownloadCleanerCleanupSettings.tsx b/docs/src/components/configuration/download-cleaner/DownloadCleanerCleanupSettings.tsx deleted file mode 100644 index 3b085db8..00000000 --- a/docs/src/components/configuration/download-cleaner/DownloadCleanerCleanupSettings.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from "react"; -import EnvVars, { EnvVarProps } from "../EnvVars"; - -const settings: EnvVarProps[] = [ - { - name: "DOWNLOADCLEANER__CATEGORIES__0__NAME", - description: ["Name of the category to clean."], - type: "text", - defaultValue: "Empty", - required: false, - examples: ["tv-sonarr", "movies-radarr", "music-lidarr"], - notes: [ - "The category name must match the category that was set in the *arr.", - "For qBittorrent, the category name is the name of the download category.", - "For Deluge, the category name is the name of the label.", - "For Transmission, the category name is the last directory from the save location.", - ], - }, - { - name: "DOWNLOADCLEANER__CATEGORIES__0__MAX_RATIO", - description: ["Maximum ratio to reach before removing a download."], - type: "decimal number", - defaultValue: "-1", - required: false, - examples: ["-1", "1.0", "2.0", "3.0"], - notes: ["`-1` means no limit/disabled."], - }, - { - name: "DOWNLOADCLEANER__CATEGORIES__0__MIN_SEED_TIME", - description: [ - "Minimum number of hours to seed before removing a download, if the ratio has been met.", - "Used with `MAX_RATIO` to ensure a minimum seed time.", - ], - type: "positive decimal number", - defaultValue: "0", - required: false, - examples: ["0", "24", "48", "72"], - }, - { - name: "DOWNLOADCLEANER__CATEGORIES__0__MAX_SEED_TIME", - description: [ - "Maximum number of hours to seed before removing a download.", - ], - type: "decimal number", - defaultValue: "-1", - required: false, - examples: ["-1", "24", "48", "72"], - notes: ["`-1` means no limit/disabled."], - }, -]; - -export default function DownloadCleanerCleanupSettings() { - return ; -} diff --git a/docs/src/components/configuration/download-cleaner/DownloadCleanerGeneralSettings.tsx b/docs/src/components/configuration/download-cleaner/DownloadCleanerGeneralSettings.tsx deleted file mode 100644 index ccab2145..00000000 --- a/docs/src/components/configuration/download-cleaner/DownloadCleanerGeneralSettings.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React from "react"; -import EnvVars, { EnvVarProps } from "../EnvVars"; - -const settings: EnvVarProps[] = [ - { - name: "DOWNLOADCLEANER__ENABLED", - description: [ - "Enables or disables the Download Cleaner functionality.", - "When enabled, automatically cleans up downloads that have been seeding for a certain amount of time." - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - }, - { - name: "TRIGGERS__DOWNLOADCLEANER", - description: [ - "Cron schedule for the Download Cleaner job." - ], - type: "text", - reference: "https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html", - defaultValue: "0 0 * * * ?", - defaultValueComment: "every hour", - required: false, - notes: [ - "Maximum interval is 6 hours." - ] - }, - { - name: "DOWNLOADCLEANER__IGNORED_DOWNLOADS_PATH", - description: [ - "Local path to the file containing ignored downloads.", - "If the contents of the file are changed, they will be reloaded on the next job run.", - { - type: "list", - title: "Accepted values inside the file (each value needs to be on a new line):", - content: [ - "torrent hash", - "qBitTorrent tag or category", - "Deluge label", - "Transmission category (last directory from the save location)", - "torrent tracker domain" - ] - }, - { - type: "code", - title: "Example of file contents:", - content: `fa800a7d7c443a2c3561d1f8f393c089036dade1 -tv-sonarr -qbit-tag -mytracker.com -...` - } - ], - type: "text", - defaultValue: "Empty", - required: false, - examples: ["/ignored.txt", "/config/ignored.txt"], - warnings: [ - "Some people have experienced problems using Docker where the mounted file would not update inside the container if it was modified on the host. This is a Docker configuration problem and can not be solved by cleanuperr." - ] - }, - { - name: "DOWNLOADCLEANER__DELETE_PRIVATE", - description: [ - "Controls whether to delete private downloads." - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - important: [ - "Setting `DOWNLOADCLEANER__DELETE_PRIVATE=true` means you don't care about seeding, ratio, H&R and potentially losing your private tracker account." - ] - } -]; - -export default function DownloadCleanerGeneralSettings() { - return ; -} \ No newline at end of file diff --git a/docs/src/components/configuration/download-cleaner/DownloadCleanerHardlinksSettings.tsx b/docs/src/components/configuration/download-cleaner/DownloadCleanerHardlinksSettings.tsx deleted file mode 100644 index 0cbd3bcd..00000000 --- a/docs/src/components/configuration/download-cleaner/DownloadCleanerHardlinksSettings.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from "react"; -import EnvVars, { EnvVarProps } from "../EnvVars"; - -const settings: EnvVarProps[] = [ - { - name: "DOWNLOADCLEANER__UNLINKED_TARGET_CATEGORY", - description: [ - "The category to set on downloads that do not have hardlinks." - ], - type: "text", - defaultValue: "cleanuperr-unlinked", - required: false, - }, - { - name: "DOWNLOADCLEANER__UNLINKED_USE_TAG", - description: [ - "If set to true, a tag will be set instead of changing the category.", - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - notes: [ - "Works only for qBittorrent.", - ], - - }, - { - name: "DOWNLOADCLEANER__UNLINKED_IGNORED_ROOT_DIR", - description: [ - "This is useful if you are using [cross-seed](https://www.cross-seed.org/).", - "The downloads root directory where the original and cross-seed hardlinks reside. All other hardlinks from this directory will be treated as if they do not exist (e.g. if you have a download with the original file and a cross-seed hardlink, it will be deleted).", - ], - type: "text", - defaultValue: "Empty", - required: false, - }, - { - name: "DOWNLOADCLEANER__UNLINKED_CATEGORIES__0", - description: [ - "The categories of downloads to check for available hardlinks.", - { - type: "code", - title: "Multiple patterns can be specified using incrementing numbers starting from 0.", - content: `DOWNLOADCLEANER__UNLINKED_CATEGORIES__0=tv-sonarr -DOWNLOADCLEANER__UNLINKED_CATEGORIES__1=radarr` - }, - ], - type: "text", - defaultValue: "Empty", - required: false, - notes: [ - "The category name must match the category that was set in the *arr.", - "For qBittorrent, the category name is the name of the download category.", - "For Deluge, the category name is the name of the label.", - "For Transmission, the category name is the last directory from the save location.", - ], - } -]; - -export default function DownloadCleanerHardlinksSettings() { - return ; -} diff --git a/docs/src/components/configuration/download-client/DownloadClientSettings.tsx b/docs/src/components/configuration/download-client/DownloadClientSettings.tsx index 308f6eca..b394d91c 100644 --- a/docs/src/components/configuration/download-client/DownloadClientSettings.tsx +++ b/docs/src/components/configuration/download-client/DownloadClientSettings.tsx @@ -12,7 +12,7 @@ const settings: EnvVarProps[] = [ required: false, acceptedValues: ["none", "qbittorrent", "deluge", "transmission", "disabled"], notes: [ - "Only one download client can be enabled at a time. If you have more than one download client, you should deploy multiple instances of Cleanuperr." + "Only one download client can be enabled at a time. If you have more than one download client, you should deploy multiple instances of Cleanuparr." ], warnings: [ "When the download client is set to `disabled`, the Queue Cleaner will be able to remove items that are failed to be imported even if there is no download client configured. This means that all downloads, including private ones, will be completely removed.", diff --git a/docs/src/components/configuration/notifications/AppriseSettings.tsx b/docs/src/components/configuration/notifications/AppriseSettings.tsx index f7b59565..d927b6e6 100644 --- a/docs/src/components/configuration/notifications/AppriseSettings.tsx +++ b/docs/src/components/configuration/notifications/AppriseSettings.tsx @@ -17,7 +17,7 @@ const settings: EnvVarProps[] = [ }, { name: "APPRISE__KEY", - description: ["[Apprise configuration key](https://github.com/caronc/apprise-api?tab=readme-ov-file#screenshots) containing all 3rd party notification providers which Cleanuperr would notify."], + description: ["[Apprise configuration key](https://github.com/caronc/apprise-api?tab=readme-ov-file#screenshots) containing all 3rd party notification providers which Cleanuparr would notify."], type: "text", defaultValue: "Empty", required: false, diff --git a/docs/src/components/configuration/queue-cleaner/QueueCleanerGeneralSettings.tsx b/docs/src/components/configuration/queue-cleaner/QueueCleanerGeneralSettings.tsx deleted file mode 100644 index 0330266c..00000000 --- a/docs/src/components/configuration/queue-cleaner/QueueCleanerGeneralSettings.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from "react"; -import EnvVars, { EnvVarProps } from "../EnvVars"; - -const settings: EnvVarProps[] = [ - { - name: "QUEUECLEANER__ENABLED", - description: [ - "Enables or disables the queue cleaning functionality. When enabled, processes all items in the *arr queue." - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - }, - { - name: "TRIGGERS__QUEUECLEANER", - description: [ - "Cron schedule for the Queue Cleaner job." - ], - type: "text", - reference: "https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html", - defaultValue: "0 0/5 * * * ?", - defaultValueComment: "every 5 minutes", - required: "Only required if QUEUECLEANER__ENABLED is true", - examples: ["0 0/5 * * * ?", "0 0 * * * ?", "0 0 0/1 * * ?"], - notes: [ - "Maximum interval is 6 hours.", - "Is ignored if `QUEUECLEANER__RUNSEQUENTIALLY=true` and `CONTENTBLOCKER__ENABLED=true`." - ] - }, - { - name: "QUEUECLEANER__IGNORED_DOWNLOADS_PATH", - description: [ - "Local path to the file containing downloads to be ignored from being processed by Cleanuperr.", - "If the contents of the file are changed, they will be reloaded on the next job run.", - "This file is not automatically created, so you need to create it manually.", - { - type: "list", - title: "Accepted values inside the file (each value needs to be on a new line):", - content: [ - "torrent hash", - "qBitTorrent tag or category", - "Deluge label", - "Transmission category (last directory from the save location)", - "torrent tracker domain" - ] - }, - { - type: "code", - title: "Example of file contents:", - content: `fa800a7d7c443a2c3561d1f8f393c089036dade1 -tv-sonarr -qbit-tag -mytracker.com -...` - } - ], - type: "text", - defaultValue: "Empty", - required: false, - examples: ["/ignored.txt", "/config/ignored.txt"], - warnings: [ - "Some people have experienced problems using Docker where the mounted file would not update inside the container if it was modified on the host. This is a Docker configuration problem and can not be solved by cleanuperr." - ] - }, - { - name: "QUEUECLEANER__RUNSEQUENTIALLY", - description: [ - "Controls whether Queue Cleaner runs after Content Blocker instead of in parallel. When true, streamlines the cleaning process by running immediately after Content Blocker." - ], - type: "boolean", - defaultValue: "true", - required: false, - acceptedValues: ["true", "false"] - } -]; - -export default function QueueCleanerGeneralSettings() { - return ; -} \ No newline at end of file diff --git a/docs/src/components/configuration/queue-cleaner/QueueCleanerImportFailedSettings.tsx b/docs/src/components/configuration/queue-cleaner/QueueCleanerImportFailedSettings.tsx deleted file mode 100644 index 8daab802..00000000 --- a/docs/src/components/configuration/queue-cleaner/QueueCleanerImportFailedSettings.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import EnvVars, { EnvVarProps } from "../EnvVars"; - -const settings: EnvVarProps[] = [ - { - name: "QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES", - description: [ - "Number of strikes before removing a failed import. Set to `0` to never remove failed imports.", - "A strike is given when an item fails to be imported." - ], - type: "positive integer number", - defaultValue: "0", - required: false, - notes: [ - "`0` means to never remove failed imports.", - "If not set to `0`, the minimum value is `3`." - ] - }, - { - name: "QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE", - description: [ - "Controls whether to ignore failed imports from private trackers from being processed by Cleanuperr." - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - }, - { - name: "QUEUECLEANER__IMPORT_FAILED_DELETE_PRIVATE", - description: [ - "Controls whether to delete failed imports from private trackers from the download client.", - "Has no effect if QUEUECLEANER__IMPORT_FAILED_IGNORE_PRIVATE is true." - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - important: [ - "Setting this to true means you don't care about seeding, ratio, H&R and potentially losing your private tracker account." - ] - }, - { - name: "QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__0", - description: [ - "Patterns to look for in failed import messages that should be ignored.", - "Multiple patterns can be specified using incrementing numbers starting from 0.", - { - type: "code", - title: "Configuration example:", - content: `QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__0=title mismatch -QUEUECLEANER__IMPORT_FAILED_IGNORE_PATTERNS__1=manual import required` - } - ], - type: "text array", - defaultValue: "Empty", - required: false, - examples: ["title mismatch", "manual import required"], - } -]; - -export default function QueueCleanerImportFailedSettings() { - return ; -} \ No newline at end of file diff --git a/docs/src/components/configuration/queue-cleaner/QueueCleanerSlowSettings.tsx b/docs/src/components/configuration/queue-cleaner/QueueCleanerSlowSettings.tsx deleted file mode 100644 index 8a1d7a79..00000000 --- a/docs/src/components/configuration/queue-cleaner/QueueCleanerSlowSettings.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from "react"; -import EnvVars, { EnvVarProps } from "../EnvVars"; - -const settings: EnvVarProps[] = [ - { - name: "QUEUECLEANER__SLOW_MAX_STRIKES", - description: [ - "Number of strikes before removing a slow download. Set to `0` to never remove slow downloads.", - "A strike is given when an item is slow." - ], - type: "positive integer number", - defaultValue: "0", - required: false, - examples: ["0", "3", "10"], - notes: [ - "If not set to 0, the minimum value is `3`." - ] - }, - { - name: "QUEUECLEANER__SLOW_RESET_STRIKES_ON_PROGRESS", - description: [ - "Controls whether to remove the given strikes if the download speed or estimated time are not slow anymore." - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - }, - { - name: "QUEUECLEANER__SLOW_IGNORE_PRIVATE", - description: [ - "Controls whether to ignore slow downloads from private trackers from being processed by Cleanuperr." - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - }, - { - name: "QUEUECLEANER__SLOW_DELETE_PRIVATE", - description: [ - "Controls whether slow downloads from private trackers should be removed from the download client.", - "Has no effect if QUEUECLEANER__SLOW_IGNORE_PRIVATE is true." - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - important: [ - "Setting this to true means you don't care about seeding, ratio, H&R and potentially losing your private tracker account." - ] - }, - { - name: "QUEUECLEANER__SLOW_MIN_SPEED", - description: [ - "The minimum speed a download should have.", - "Downloads receive strikes if their speed falls below this value.", - "If not specified, downloads will not receive strikes for slow download speed." - ], - type: "text", - defaultValue: "Empty", - required: false, - examples: ["1.5KB", "400KB", "2MB"], - }, - { - name: "QUEUECLEANER__SLOW_MAX_TIME", - description: [ - "The maximum estimated hours a download should take to finish. Downloads receive strikes if their estimated finish time is above this value. If not specified (or 0), downloads will not receive strikes for slow estimated finish time." - ], - type: "positive number", - defaultValue: "0", - required: false, - examples: ["0", "1.5", "24", "48"], - }, - { - name: "QUEUECLEANER__SLOW_IGNORE_ABOVE_SIZE", - description: [ - "Downloads above this size will not be removed for being slow." - ], - type: "text", - defaultValue: "Empty", - required: false, - examples: ["10KB", "200MB", "3GB"], - } -]; - -export default function QueueCleanerSlowSettings() { - return ; -} \ No newline at end of file diff --git a/docs/src/components/configuration/queue-cleaner/QueueCleanerStalledSettings.tsx b/docs/src/components/configuration/queue-cleaner/QueueCleanerStalledSettings.tsx deleted file mode 100644 index a738ed36..00000000 --- a/docs/src/components/configuration/queue-cleaner/QueueCleanerStalledSettings.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from "react"; -import EnvVars, { EnvVarProps } from "../EnvVars"; - -const settings: EnvVarProps[] = [ - { - name: "QUEUECLEANER__STALLED_MAX_STRIKES", - description: [ - "Number of strikes before removing a stalled download. Set to `0` to never remove stalled downloads.", - "A strike is given when an item is stalled (not downloading) or stuck while downloading metadata (qBitTorrent only)." - ], - type: "positive integer number", - defaultValue: "0", - required: false, - notes: [ - "`0` means to never remove stalled downloads.", - "If not set to 0, the minimum value is `3`." - ] - }, - { - name: "QUEUECLEANER__STALLED_RESET_STRIKES_ON_PROGRESS", - description: [ - "Controls whether to remove the given strikes if any download progress was made since last checked." - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - }, - { - name: "QUEUECLEANER__STALLED_IGNORE_PRIVATE", - description: [ - "Controls whether to ignore stalled downloads from private trackers from being processed by Cleanuperr." - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - }, - { - name: "QUEUECLEANER__STALLED_DELETE_PRIVATE", - description: [ - "Controls whether stalled downloads from private trackers should be removed from the download client.", - "Has no effect if QUEUECLEANER__STALLED_IGNORE_PRIVATE is true." - ], - type: "boolean", - defaultValue: "false", - required: false, - acceptedValues: ["true", "false"], - important: [ - "Setting this to true means you don't care about seeding, ratio, H&R and potentially losing your private tracker account." - ] - }, - { - name: "QUEUECLEANER__DOWNLOADING_METADATA_MAX_STRIKES", - description: [ - "Number of strikes before removing a download stuck while downloading metadata.", - ], - type: "positive integer number", - defaultValue: "0", - required: false, - notes: [ - "`0` means to never remove downloads stuck while downloading metadata.", - "If not set to `0`, the minimum value is `3`." - ] - } -]; - -export default function QueueCleanerStalledSettings() { - return ; -} \ No newline at end of file diff --git a/docs/src/components/documentation/ConfigSection.tsx b/docs/src/components/documentation/ConfigSection.tsx new file mode 100644 index 00000000..9c788418 --- /dev/null +++ b/docs/src/components/documentation/ConfigSection.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import styles from './documentation.module.css'; + +interface ConfigSectionProps { + id?: string; + title: string; + description?: string; + icon?: string; + badge?: 'required' | 'optional' | 'advanced'; + children: React.ReactNode; + className?: string; +} + +export default function ConfigSection({ + id, + title, + description, + icon, + badge, + children, + className +}: ConfigSectionProps) { + return ( +
+
+

+ {icon && ( + + {icon} + + )} + {title} +

+ {badge && ( + + {badge} + + )} +
+ {description && ( +

{description}

+ )} +
{children}
+
+ ); +} \ No newline at end of file diff --git a/docs/src/components/documentation/EnhancedAdmonition.tsx b/docs/src/components/documentation/EnhancedAdmonition.tsx new file mode 100644 index 00000000..150deb40 --- /dev/null +++ b/docs/src/components/documentation/EnhancedAdmonition.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import styles from './documentation.module.css'; + +type AdmonitionType = 'note' | 'important' | 'warning'; + +interface EnhancedAdmonitionProps { + type: AdmonitionType; + title?: string; + children: React.ReactNode; + className?: string; +} + +const admonitionConfig = { + note: { + icon: '๐Ÿ’ก', + defaultTitle: 'Note' + }, + important: { + icon: 'โš ๏ธ', + defaultTitle: 'Important' + }, + warning: { + icon: '๐Ÿšจ', + defaultTitle: 'Warning' + } +}; + +export default function EnhancedAdmonition({ + type, + title, + children, + className +}: EnhancedAdmonitionProps) { + const config = admonitionConfig[type]; + const displayTitle = title || config.defaultTitle; + + return ( +
+
+ + {config.icon} + + {displayTitle} +
+
{children}
+
+ ); +} + +// Convenience components +export function EnhancedNote({ title, children, className }: Omit) { + return {children}; +} + +export function EnhancedImportant({ title, children, className }: Omit) { + return {children}; +} + +export function EnhancedWarning({ title, children, className }: Omit) { + return {children}; +} \ No newline at end of file diff --git a/docs/src/components/documentation/InstallationMethod.tsx b/docs/src/components/documentation/InstallationMethod.tsx new file mode 100644 index 00000000..4e827a26 --- /dev/null +++ b/docs/src/components/documentation/InstallationMethod.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import styles from './documentation.module.css'; + +interface InstallationMethodProps { + title: string; + description: string; + icon: string; + features?: string[]; + recommended?: boolean; + color?: string; + children: React.ReactNode; + className?: string; +} + +export default function InstallationMethod({ + title, + description, + icon, + features = [], + recommended = false, + color, + children, + className +}: InstallationMethodProps) { + const cardClass = `${styles.methodCard} ${recommended ? styles.recommended : ''} ${className || ''}`; + + const cardStyle = color ? { '--method-color': color } as React.CSSProperties : {}; + + return ( +
+
+ + {icon} + +

{title}

+
+ +

{description}

+ + {features.length > 0 && ( +
    + {features.map((feature, index) => ( +
  • {feature}
  • + ))} +
+ )} + +
{children}
+
+ ); +} \ No newline at end of file diff --git a/docs/src/components/documentation/PageHeader.tsx b/docs/src/components/documentation/PageHeader.tsx new file mode 100644 index 00000000..25dfbb84 --- /dev/null +++ b/docs/src/components/documentation/PageHeader.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import styles from './documentation.module.css'; + +interface PageHeaderProps { + title: string; + subtitle: string; + icon?: string; + className?: string; +} + +export default function PageHeader({ title, subtitle, icon, className }: PageHeaderProps) { + return ( +
+
+

+ {icon && ( + + {icon} + + )} + {title} +

+

{subtitle}

+
+
+ ); +} \ No newline at end of file diff --git a/docs/src/components/documentation/QuickNav.tsx b/docs/src/components/documentation/QuickNav.tsx new file mode 100644 index 00000000..b8c56596 --- /dev/null +++ b/docs/src/components/documentation/QuickNav.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import styles from './documentation.module.css'; + +interface NavItem { + label: string; + href: string; + icon?: string; +} + +interface QuickNavProps { + title?: string; + items: NavItem[]; + className?: string; +} + +export default function QuickNav({ + title = "Quick Navigation", + items, + className +}: QuickNavProps) { + return ( + + ); +} \ No newline at end of file diff --git a/docs/src/components/documentation/StepGuide.tsx b/docs/src/components/documentation/StepGuide.tsx new file mode 100644 index 00000000..77ac6cc7 --- /dev/null +++ b/docs/src/components/documentation/StepGuide.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import styles from './documentation.module.css'; + +interface StepProps { + title: string; + children: React.ReactNode; +} + +interface StepGuideProps { + children: React.ReactElement[]; + className?: string; +} + +export function Step({ title, children }: StepProps) { + return ( +
+
+
+

{title}

+ {children} +
+
+ ); +} + +export default function StepGuide({ children, className }: StepGuideProps) { + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/docs/src/components/documentation/documentation.module.css b/docs/src/components/documentation/documentation.module.css new file mode 100644 index 00000000..0906b39b --- /dev/null +++ b/docs/src/components/documentation/documentation.module.css @@ -0,0 +1,662 @@ +/* Documentation Components Styles */ + +.documentationPage { + --doc-primary: #3e0d60; + --doc-primary-light: #6b3fa0; + --doc-secondary: #f8f9fa; + --doc-accent: #007bff; + --doc-success: #28a745; + --doc-warning: #ffc107; + --doc-danger: #dc3545; + --doc-info: #17a2b8; + + /* Spacing variables */ + --doc-spacing-xs: 0.5rem; + --doc-spacing-sm: 1rem; + --doc-spacing-md: 1.5rem; + --doc-spacing-lg: 2rem; + --doc-spacing-xl: 3rem; + + /* Border radius */ + --doc-radius-sm: 6px; + --doc-radius-md: 12px; + --doc-radius-lg: 16px; + + /* Shadows */ + --doc-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.08); + --doc-shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12); + --doc-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.16); + + /* Theme-aware colors */ + --doc-bg-primary: var(--ifm-background-color); + --doc-bg-secondary: var(--ifm-background-surface-color); + --doc-text-primary: var(--ifm-font-color-base); + --doc-text-secondary: var(--ifm-color-content-secondary); + --doc-border-color: var(--ifm-color-emphasis-300); + --doc-border-hover: var(--ifm-color-emphasis-400); +} + +/* Enhanced Page Header */ +.pageHeader { + background: linear-gradient(135deg, var(--doc-primary), var(--doc-primary-light)); + color: white; + padding: var(--doc-spacing-xl) var(--doc-spacing-lg); + margin: calc(-1 * var(--ifm-navbar-height)) calc(-1 * var(--ifm-spacing-horizontal)) var(--doc-spacing-xl); + border-radius: 0 0 var(--doc-radius-lg) var(--doc-radius-lg); + position: relative; + overflow: hidden; +} + +.pageHeader::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(45deg, rgba(255,255,255,0.1) 0%, transparent 50%); + pointer-events: none; +} + +.headerContent { + position: relative; + z-index: 1; + max-width: 1200px; + margin: 0 auto; +} + +.headerTitle { + font-size: 2.5rem; + font-weight: 700; + margin: 0 0 var(--doc-spacing-sm) 0; + display: flex; + align-items: center; + gap: var(--doc-spacing-sm); +} + +.headerIcon { + font-size: 2rem; + opacity: 0.9; +} + +.headerSubtitle { + font-size: 1.25rem; + opacity: 0.9; + margin: 0; + line-height: 1.6; + max-width: 600px; +} + +/* Section Container */ +.section { + margin: var(--doc-spacing-md) 0; +} + +.sectionTitle { + font-size: 1.875rem; + font-weight: 600; + color: var(--doc-text-primary); + margin: 0 0 var(--doc-spacing-lg) 0; + display: flex; + align-items: center; + gap: var(--doc-spacing-sm); + border-bottom: 3px solid var(--doc-primary); + padding-bottom: var(--doc-spacing-sm); +} + +.sectionIcon { + font-size: 1.5rem; + color: var(--doc-primary); +} + +.sectionDescription { + font-size: 1.1rem; + color: var(--doc-text-secondary); + margin: 0 0 var(--doc-spacing-lg) 0; + line-height: 1.6; +} + +/* Configuration Cards */ +.configSection { + background: var(--doc-bg-secondary); + border: 1px solid var(--doc-border-color); + border-radius: var(--doc-radius-md); + padding: var(--doc-spacing-sm); + margin: var(--doc-spacing-sm) 0; + box-shadow: var(--doc-shadow-sm); + transition: all 0.3s ease; + scroll-margin-top: 100px; +} + +.configSection:hover { + border-color: var(--doc-border-hover); + box-shadow: var(--doc-shadow-md); + transform: translateY(-2px); +} + +.configHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: var(--doc-spacing-md); + gap: var(--doc-spacing-md); +} + +.configTitle { + font-size: 1.5rem; + font-weight: 600; + color: var(--doc-text-primary); + margin: 0; + display: flex; + align-items: center; + gap: var(--doc-spacing-sm); +} + +.configIcon { + font-size: 1.25rem; + color: var(--doc-primary); + opacity: 0.8; +} + +.configBadge { + background: var(--doc-primary); + color: white; + padding: 0.25rem 0.75rem; + border-radius: 50px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; +} + +.configBadge.required { + background: var(--doc-danger); +} + +.configBadge.optional { + background: var(--doc-info); +} + +.configBadge.advanced { + background: var(--doc-warning); + color: #000; +} + +.configDescription { + color: var(--doc-text-secondary); + line-height: 1.6; + margin: 0 0 var(--doc-spacing-md) 0; +} + +/* Enhanced Code Blocks */ +.codeExample { + background: var(--ifm-code-background); + border: 1px solid var(--doc-border-color); + border-radius: var(--doc-radius-sm); + padding: var(--doc-spacing-md); + margin: var(--doc-spacing-md) 0; + font-family: var(--ifm-font-family-monospace); + font-size: 0.875rem; + line-height: 1.5; + overflow-x: auto; + position: relative; +} + +.codeExample::before { + content: attr(data-language); + position: absolute; + top: 0.5rem; + right: 0.75rem; + font-size: 0.75rem; + color: var(--doc-text-secondary); + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.5px; +} + +/* Installation Method Cards */ +.methodGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--doc-spacing-lg); + margin: var(--doc-spacing-lg) 0; +} + +.methodCard { + background: var(--doc-bg-secondary); + border: 2px solid var(--doc-border-color); + border-radius: var(--doc-radius-md); + padding: var(--doc-spacing-lg); + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.methodCard::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--method-color, var(--doc-primary)); + transition: height 0.3s ease; +} + +.methodCard:hover { + border-color: var(--method-color, var(--doc-primary)); + box-shadow: var(--doc-shadow-lg); + transform: translateY(-4px); +} + +.methodCard:hover::before { + height: 8px; +} + +.methodCard.recommended { + border-color: var(--doc-success); + box-shadow: 0 4px 20px rgba(40, 167, 69, 0.2); +} + +.methodCard.recommended::after { + content: 'Recommended'; + position: absolute; + top: var(--doc-spacing-md); + right: var(--doc-spacing-md); + background: var(--doc-success); + color: white; + padding: 0.25rem 0.75rem; + border-radius: 50px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.methodHeader { + display: flex; + align-items: center; + gap: var(--doc-spacing-sm); + margin-bottom: var(--doc-spacing-md); +} + +.methodIcon { + font-size: 2rem; + color: var(--method-color, var(--doc-primary)); +} + +.methodTitle { + font-size: 1.5rem; + font-weight: 600; + color: var(--doc-text-primary); + margin: 0; +} + +.methodDescription { + color: var(--doc-text-secondary); + line-height: 1.6; + margin: 0 0 var(--doc-spacing-md) 0; +} + +.methodFeatures { + list-style: none; + padding: 0; + margin: 0 0 var(--doc-spacing-md) 0; +} + +.methodFeatures li { + display: flex; + align-items: center; + gap: var(--doc-spacing-sm); + padding: 0.25rem 0; + color: var(--doc-text-secondary); + font-size: 0.9rem; +} + +.methodFeatures li::before { + content: 'โœ“'; + color: var(--doc-success); + font-weight: 600; + font-size: 1rem; +} + +/* Enhanced Tables */ +.configTable { + width: 100%; + border-collapse: collapse; + margin: var(--doc-spacing-lg) 0; + background: var(--doc-bg-secondary); + border-radius: var(--doc-radius-md); + overflow: hidden; + box-shadow: var(--doc-shadow-sm); +} + +.configTable th { + background: var(--doc-primary); + color: white; + padding: var(--doc-spacing-md); + text-align: left; + font-weight: 600; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.configTable td { + padding: var(--doc-spacing-md); + border-bottom: 1px solid var(--doc-border-color); + vertical-align: top; +} + +.configTable tbody tr:hover { + background: rgba(62, 13, 96, 0.05); +} + +.configTable code { + background: rgba(62, 13, 96, 0.1); + color: var(--doc-primary); + padding: 0.25rem 0.5rem; + border-radius: var(--doc-radius-sm); + font-size: 0.875rem; +} + +/* Step-by-step Guide */ +.stepGuide { + counter-reset: step-counter; + margin: var(--doc-spacing-lg) 0; +} + +.step { + counter-increment: step-counter; + display: flex; + gap: var(--doc-spacing-md); + margin: var(--doc-spacing-lg) 0; + padding: var(--doc-spacing-lg); + background: var(--doc-bg-secondary); + border-radius: var(--doc-radius-md); + border-left: 4px solid var(--doc-primary); + position: relative; +} + +.stepNumber { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + background: var(--doc-primary); + color: white; + border-radius: 50%; + font-weight: 600; + font-size: 1.1rem; + flex-shrink: 0; + position: relative; +} + +.stepNumber::before { + content: counter(step-counter); +} + +.stepContent h4 { + margin: 0 0 var(--doc-spacing-sm) 0; + color: var(--doc-text-primary); + font-weight: 600; +} + +.stepContent p { + margin: 0 0 var(--doc-spacing-sm) 0; + color: var(--doc-text-secondary); + line-height: 1.6; +} + +/* Code blocks within steps */ +.stepContent pre, +.stepContent code { + max-width: 100%; + overflow-x: auto; +} + +.stepContent pre { + background: var(--ifm-code-background); + border: 1px solid var(--doc-border-color); + border-radius: var(--doc-radius-sm); + padding: var(--doc-spacing-md); + margin: var(--doc-spacing-md) 0; + font-family: var(--ifm-font-family-monospace); + font-size: 0.875rem; + line-height: 1.5; + white-space: pre; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.stepContent pre code { + background: none; + border: none; + padding: 0; + margin: 0; + font-size: inherit; + white-space: pre; + word-break: normal; + overflow-wrap: normal; +} + +/* Ensure step content doesn't overflow its container */ +.stepContent { + min-width: 0; + flex: 1; + overflow: hidden; +} + +/* Quick Access Navigation */ +.quickNav { + background: var(--doc-bg-secondary); + border: 1px solid var(--doc-border-color); + border-radius: var(--doc-radius-md); + padding: var(--doc-spacing-lg); + margin: var(--doc-spacing-lg) 0; + position: sticky; + top: calc(var(--ifm-navbar-height) + 1rem); + z-index: 10; +} + +.quickNavTitle { + font-weight: 600; + margin: 0 0 var(--doc-spacing-md) 0; + color: var(--doc-text-primary); + display: flex; + align-items: center; + gap: var(--doc-spacing-sm); +} + +.quickNavList { + list-style: none; + padding: 0; + margin: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--doc-spacing-sm); +} + +.quickNavItem a { + display: flex; + align-items: center; + gap: var(--doc-spacing-sm); + padding: var(--doc-spacing-sm); + border-radius: var(--doc-radius-sm); + text-decoration: none; + color: var(--doc-text-secondary); + transition: all 0.3s ease; + font-size: 0.9rem; +} + +.quickNavItem a:hover { + background: var(--doc-primary); + color: white; + transform: translateX(4px); +} + +/* Enhanced Admonitions */ +.enhancedAdmonition { + margin: var(--doc-spacing-sm) 0; + padding: var(--doc-spacing-sm); + border-radius: var(--doc-radius-md); + border-left: 4px solid; + position: relative; + overflow: hidden; +} + +.enhancedAdmonition::before { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 100px; + height: 100px; + opacity: 0.1; + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +.enhancedAdmonition.note { + background: rgba(23, 162, 184, 0.1); + border-left-color: var(--doc-info); +} + +.enhancedAdmonition.important { + background: rgba(220, 53, 69, 0.1); + border-left-color: var(--doc-danger); +} + +.enhancedAdmonition.warning { + background: rgba(255, 193, 7, 0.1); + border-left-color: var(--doc-warning); +} + +.admonitionHeader { + display: flex; + align-items: center; + gap: var(--doc-spacing-sm); + margin-bottom: var(--doc-spacing-md); + font-weight: 600; + font-size: 1.1rem; +} + +.admonitionIcon { + font-size: 1.25rem; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .pageHeader { + padding: var(--doc-spacing-lg) var(--doc-spacing-md); + margin-left: calc(-1 * var(--ifm-spacing-horizontal)); + margin-right: calc(-1 * var(--ifm-spacing-horizontal)); + } + + .headerTitle { + font-size: 2rem; + } + + .headerSubtitle { + font-size: 1.1rem; + } + + .methodGrid { + grid-template-columns: 1fr; + gap: var(--doc-spacing-md); + } + + .configHeader { + flex-direction: column; + align-items: flex-start; + gap: var(--doc-spacing-sm); + } + + .step { + flex-direction: column; + text-align: center; + } + + .stepNumber { + align-self: center; + } + + .quickNav { + position: static; + } + + .quickNavList { + grid-template-columns: 1fr; + } +} + +@media (max-width: 480px) { + .headerTitle { + font-size: 1.75rem; + flex-direction: column; + text-align: center; + gap: var(--doc-spacing-sm); + } + + .configSection { + padding: var(--doc-spacing-md); + } + + .methodCard { + padding: var(--doc-spacing-md); + } +} + +/* Dark mode adjustments */ +[data-theme='dark'] .documentationPage { + --doc-bg-secondary: var(--ifm-background-surface-color); + --doc-border-color: var(--ifm-color-emphasis-200); + --doc-border-hover: var(--ifm-color-emphasis-300); +} + +[data-theme='dark'] .pageHeader { + background: linear-gradient(135deg, #2a0845, #4a1a6b); +} + +[data-theme='dark'] .configTable tbody tr:hover { + background: rgba(107, 63, 160, 0.1); +} + +[data-theme='dark'] .configTable code { + background: rgba(107, 63, 160, 0.2); + color: var(--doc-primary-light); +} + +/* Animation utilities */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.fadeInUp { + animation: fadeInUp 0.6s ease-out; +} + +/* Print styles */ +@media print { + .pageHeader, + .quickNav { + display: none; + } + + .configSection, + .methodCard { + page-break-inside: avoid; + box-shadow: none; + border: 1px solid #ccc; + } +} \ No newline at end of file diff --git a/docs/src/components/documentation/index.ts b/docs/src/components/documentation/index.ts new file mode 100644 index 00000000..350b75ae --- /dev/null +++ b/docs/src/components/documentation/index.ts @@ -0,0 +1,10 @@ +// Main documentation components +export { default as PageHeader } from './PageHeader'; +export { default as ConfigSection } from './ConfigSection'; +export { default as InstallationMethod } from './InstallationMethod'; +export { default as StepGuide, Step } from './StepGuide'; +export { default as QuickNav } from './QuickNav'; +export { default as EnhancedAdmonition, EnhancedNote, EnhancedImportant, EnhancedWarning } from './EnhancedAdmonition'; + +// CSS classes for direct use +export { default as styles } from './documentation.module.css'; \ No newline at end of file diff --git a/docs/src/components/support/AlternativeSupport.tsx b/docs/src/components/support/AlternativeSupport.tsx new file mode 100644 index 00000000..2a1b5270 --- /dev/null +++ b/docs/src/components/support/AlternativeSupport.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import styles from './support.module.css'; +import { alternativeSupport } from './config/donationConfig'; + +export default function AlternativeSupport() { + return ( +
+

+ ๐Ÿค + Other Ways to Help +

+

+ Not able to contribute financially? No problem! There are many other ways you can help support + the Cleanuparr project and our community: +

+ +
+ {alternativeSupport.map((item, index) => ( +
+ + {item.icon} + +
+

{item.title}

+

+ {item.description} + {item.link && item.linkText && ( + <> + {' '} + + {item.linkText} + + + )} +

+
+
+ ))} +
+ +
+

+ ๐Ÿ™ Thank You! +

+

+ Every contribution, whether financial or through community participation, helps make Cleanuparr better for everyone. + We're grateful for your support and involvement in our open-source community. +

+
+
+ ); +} \ No newline at end of file diff --git a/docs/src/components/support/CryptoModal.tsx b/docs/src/components/support/CryptoModal.tsx new file mode 100644 index 00000000..b6fc3ce4 --- /dev/null +++ b/docs/src/components/support/CryptoModal.tsx @@ -0,0 +1,190 @@ +import React, { useState, useEffect } from 'react'; +import styles from './support.module.css'; +import { cryptoCurrencies, CryptoCurrency } from './config/donationConfig'; +import { useClipboard } from './hooks/useClipboard'; + +interface CryptoModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function CryptoModal({ isOpen, onClose }: CryptoModalProps) { + const [selectedCrypto, setSelectedCrypto] = useState(null); + const [step, setStep] = useState<'selection' | 'address'>('selection'); + const { copied, copyToClipboard } = useClipboard(); + + // Reset modal state when opened + useEffect(() => { + if (isOpen) { + setStep('selection'); + setSelectedCrypto(null); + } + }, [isOpen]); + + // Close modal when clicking outside + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + const handleCryptoSelect = (crypto: CryptoCurrency) => { + setSelectedCrypto(crypto); + setStep('address'); + }; + + const handleBack = () => { + setStep('selection'); + setSelectedCrypto(null); + }; + + const handleCopyAddress = async () => { + if (selectedCrypto) { + const success = await copyToClipboard(selectedCrypto.address); + if (success) { + // Optional: Show toast notification + console.log('Address copied to clipboard!'); + } + } + }; + + const generateQRCodeUrl = (address: string) => { + // Using QR Server API for QR code generation + return `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(address)}`; + }; + + if (!isOpen) return null; + + return ( +
+
+ + × + + + {step === 'selection' && ( + <> +

+ โ‚ฟ + Choose Cryptocurrency +

+

+ Select your preferred cryptocurrency to view the donation address: +

+
+ {cryptoCurrencies.map((crypto) => ( +
handleCryptoSelect(crypto)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + handleCryptoSelect(crypto); + } + }} + aria-label={`Select ${crypto.name}`} + > + + {crypto.icon} + +

{crypto.name}

+

+ {crypto.symbol} +

+
+ ))} +
+ + )} + + {step === 'address' && selectedCrypto && ( + <> +

+ + {selectedCrypto.icon} + + {selectedCrypto.name} Address +

+ +

+ Send {selectedCrypto.name} to this address: +

+ +
+ {selectedCrypto.address} +
+ +
+ +
+ +
+

+ Or scan this QR code: +

+
+ {`QR +
+
+ +
+ +
+ + )} +
+
+ ); +} \ No newline at end of file diff --git a/docs/src/components/support/DonationCard.tsx b/docs/src/components/support/DonationCard.tsx new file mode 100644 index 00000000..3b5adb2c --- /dev/null +++ b/docs/src/components/support/DonationCard.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import styles from './support.module.css'; +import { DonationMethod } from './config/donationConfig'; + +interface DonationCardProps { + method: DonationMethod; + onOpenModal?: () => void; +} + +export default function DonationCard({ method, onOpenModal }: DonationCardProps) { + const handleClick = () => { + if (method.type === 'modal' && onOpenModal) { + onOpenModal(); + } else if (method.type === 'link' && method.url) { + window.open(method.url, '_blank', 'noopener,noreferrer'); + } + }; + + const cardClass = `${styles.donationCard} ${method.featured ? styles.featured : ''}`; + + // Set CSS custom property for accent color + const cardStyle = { + '--card-accent-color': method.accentColor + } as React.CSSProperties; + + const getButtonClass = () => { + const baseClass = styles.donateBtn; + switch (method.id) { + case 'github': + return `${baseClass} ${styles.githubBtn}`; + case 'buymeacoffee': + return `${baseClass} ${styles.buymeacoffeeBtn}`; + case 'crypto': + return `${baseClass} ${styles.cryptoBtn}`; + default: + return baseClass; + } + }; + + return ( +
+ + {method.icon} + +

{method.title}

+

{method.description}

+ +
+ ); +} \ No newline at end of file diff --git a/docs/src/components/support/DonationMethods.tsx b/docs/src/components/support/DonationMethods.tsx new file mode 100644 index 00000000..71600f72 --- /dev/null +++ b/docs/src/components/support/DonationMethods.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import styles from './support.module.css'; +import DonationCard from './DonationCard'; +import CryptoModal from './CryptoModal'; +import { donationMethods } from './config/donationConfig'; +import { useModal } from './hooks/useModal'; + +export default function DonationMethods() { + const { isOpen, openModal, closeModal } = useModal(); + + return ( +
+

Support Our Development

+
+ {donationMethods.map((method) => ( + + ))} +
+ + +
+ ); +} \ No newline at end of file diff --git a/docs/src/components/support/SupportBanner.tsx b/docs/src/components/support/SupportBanner.tsx new file mode 100644 index 00000000..4216d912 --- /dev/null +++ b/docs/src/components/support/SupportBanner.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import Link from '@docusaurus/Link'; + +interface SupportBannerProps { + compact?: boolean; + showDismiss?: boolean; + onDismiss?: () => void; +} + +export default function SupportBanner({ compact = false, showDismiss = false, onDismiss }: SupportBannerProps) { + const bannerStyle: React.CSSProperties = { + background: 'linear-gradient(135deg, #3b82f6, #10b981)', + color: 'white', + padding: compact ? '1rem 2rem' : '1.5rem 2rem', + borderRadius: '12px', + textAlign: 'center', + margin: '2rem 0', + position: 'relative', + boxShadow: '0 4px 20px rgba(0, 0, 0, 0.1)', + }; + + const titleStyle: React.CSSProperties = { + fontSize: compact ? '1.25rem' : '1.5rem', + fontWeight: '600', + margin: '0 0 0.5rem 0', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '0.5rem', + }; + + const descriptionStyle: React.CSSProperties = { + margin: compact ? '0 0 1rem 0' : '0 0 1.5rem 0', + opacity: 0.95, + fontSize: compact ? '0.9rem' : '1rem', + lineHeight: 1.5, + }; + + const buttonStyle: React.CSSProperties = { + background: 'rgba(255, 255, 255, 0.2)', + color: 'white', + border: '2px solid rgba(255, 255, 255, 0.3)', + padding: compact ? '0.5rem 1.5rem' : '0.75rem 2rem', + borderRadius: '8px', + textDecoration: 'none', + fontWeight: '600', + fontSize: compact ? '0.9rem' : '1rem', + display: 'inline-flex', + alignItems: 'center', + gap: '0.5rem', + transition: 'all 0.3s ease', + backdropFilter: 'blur(10px)', + }; + + const dismissStyle: React.CSSProperties = { + position: 'absolute', + top: '0.75rem', + right: '1rem', + background: 'transparent', + border: 'none', + color: 'white', + fontSize: '1.5rem', + cursor: 'pointer', + opacity: 0.7, + transition: 'opacity 0.3s ease', + }; + + return ( +
+ {showDismiss && ( + + )} + +

+ โค๏ธ + {compact ? 'Support Cleanuparr' : 'Love Cleanuparr?'} +

+ +

+ {compact + ? 'Help us keep improving Cleanuparr for everyone!' + : 'Help us maintain and improve Cleanuparr by supporting our development efforts.' + } +

+ + { + e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)'; + e.currentTarget.style.transform = 'translateY(-2px)'; + }} + onMouseOut={(e) => { + e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)'; + e.currentTarget.style.transform = 'translateY(0)'; + }} + > + ๐Ÿš€ + {compact ? 'Support Us' : 'Support the Project'} + +
+ ); +} \ No newline at end of file diff --git a/docs/src/components/support/SupportHero.tsx b/docs/src/components/support/SupportHero.tsx new file mode 100644 index 00000000..5c3bc736 --- /dev/null +++ b/docs/src/components/support/SupportHero.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import styles from './support.module.css'; + +export default function SupportHero() { + return ( +
+
+

+ โค๏ธ + Support Cleanuparr +

+

+ Help us grow Cleanuparr into a full-featured download manager. Your support enables us to maintain the project, + add new features, and provide better documentation for the community. +

+
+
+ ); +} \ No newline at end of file diff --git a/docs/src/components/support/SupportPage.tsx b/docs/src/components/support/SupportPage.tsx new file mode 100644 index 00000000..11eb094b --- /dev/null +++ b/docs/src/components/support/SupportPage.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import styles from './support.module.css'; +import SupportHero from './SupportHero'; +import DonationMethods from './DonationMethods'; +import AlternativeSupport from './AlternativeSupport'; + +export default function SupportPage() { + return ( +
+ +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/docs/src/components/support/config/donationConfig.ts b/docs/src/components/support/config/donationConfig.ts new file mode 100644 index 00000000..ee632e72 --- /dev/null +++ b/docs/src/components/support/config/donationConfig.ts @@ -0,0 +1,121 @@ +export interface DonationMethod { + id: string; + title: string; + description: string; + icon: string; + buttonText: string; + url?: string; + featured?: boolean; + accentColor: string; + type: 'link' | 'modal'; +} + +export interface CryptoCurrency { + id: string; + name: string; + symbol: string; + icon: string; + address: string; + color: string; +} + +export interface AlternativeSupport { + title: string; + description: string; + icon: string; + link?: string; + linkText?: string; +} + +export const donationMethods: DonationMethod[] = [ + { + id: 'github', + title: 'GitHub Sponsors', + description: 'Support us through GitHub Sponsors with monthly or one-time contributions. This helps us maintain and improve Cleanuparr.', + icon: '๐Ÿ’–', + buttonText: 'Sponsor on GitHub', + url: 'https://github.com/sponsors/Cleanuparr', + featured: true, + accentColor: 'var(--support-github)', + type: 'link' + }, + { + id: 'buymeacoffee', + title: 'Buy Me A Coffee', + description: 'Support us with a coffee! Quick and easy one-time donations to fuel our development efforts.', + icon: 'โ˜•', + buttonText: 'Buy Me A Coffee', + url: 'https://buymeacoffee.com/flaminel', + accentColor: 'var(--support-buymeacoffee)', + type: 'link' + }, + { + id: 'crypto', + title: 'Cryptocurrency', + description: 'Support us with Bitcoin, Ethereum, or other cryptocurrencies. Decentralized donations welcome!', + icon: 'โ‚ฟ', + buttonText: 'View Crypto Options', + accentColor: 'var(--support-crypto)', + type: 'modal' + } +]; + +export const cryptoCurrencies: CryptoCurrency[] = [ + { + id: 'bitcoin', + name: 'Bitcoin', + symbol: 'BTC', + icon: 'โ‚ฟ', + address: '36dmTE24ovkLMR2SAevf6jsVS3XHZSgRTk', + color: '#f7931a' + }, + { + id: 'ethereum', + name: 'Ethereum', + symbol: 'ETH', + icon: 'ฮž', + address: '0xB71b3B1Cc801DcAF76DB7855927dd68A4D310357', + color: '#627eea' + } +]; + +export const alternativeSupport: AlternativeSupport[] = [ + { + title: 'Star on GitHub', + description: 'Give us a star on GitHub to help increase visibility and show your support.', + icon: 'โญ', + link: 'https://github.com/Cleanupparr/Cleanupparr', + linkText: 'Star the Repository' + }, + { + title: 'Report Bugs', + description: 'Help improve Cleanuparr by reporting bugs and issues you encounter.', + icon: '๐Ÿ›', + link: 'https://github.com/Cleanupparr/Cleanupparr/issues', + linkText: 'Report an Issue' + }, + { + title: 'Join Discord', + description: 'Join our Discord community to help other users and participate in discussions.', + icon: '๐Ÿ’ฌ', + link: 'https://discord.gg/SCtMCgtsc4', + linkText: 'Join Discord' + }, + { + title: 'Share the Project', + description: 'Help spread the word about Cleanuparr by sharing it with friends and communities.', + icon: '๐Ÿ“ข' + }, + { + title: 'Contribute Code', + description: 'Submit pull requests to help improve the codebase and add new features.', + icon: '๐Ÿ’ป', + link: 'https://github.com/Cleanuparr/Cleanuparr/pulls', + linkText: 'View Pull Requests' + }, + { + title: 'Write Documentation', + description: 'Help improve our documentation to make Cleanuparr easier to use for everyone.', + icon: '๐Ÿ“š' + } +]; \ No newline at end of file diff --git a/docs/src/components/support/hooks/useClipboard.ts b/docs/src/components/support/hooks/useClipboard.ts new file mode 100644 index 00000000..fdbc7764 --- /dev/null +++ b/docs/src/components/support/hooks/useClipboard.ts @@ -0,0 +1,39 @@ +import { useState, useCallback } from 'react'; + +export function useClipboard() { + const [copied, setCopied] = useState(false); + + const copyToClipboard = useCallback(async (text: string) => { + try { + if (navigator.clipboard && window.isSecureContext) { + // Use the modern clipboard API + await navigator.clipboard.writeText(text); + } else { + // Fallback for older browsers or non-secure contexts + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'absolute'; + textArea.style.left = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + } + + setCopied(true); + // Reset copied state after 2 seconds + setTimeout(() => setCopied(false), 2000); + return true; + } catch (error) { + console.error('Failed to copy to clipboard:', error); + setCopied(false); + return false; + } + }, []); + + return { + copied, + copyToClipboard + }; +} \ No newline at end of file diff --git a/docs/src/components/support/hooks/useModal.ts b/docs/src/components/support/hooks/useModal.ts new file mode 100644 index 00000000..96e629ec --- /dev/null +++ b/docs/src/components/support/hooks/useModal.ts @@ -0,0 +1,33 @@ +import { useState, useEffect } from 'react'; + +export function useModal() { + const [isOpen, setIsOpen] = useState(false); + + const openModal = () => setIsOpen(true); + const closeModal = () => setIsOpen(false); + + // Close modal on Escape key press + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && isOpen) { + closeModal(); + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleEscape); + document.body.style.overflow = 'hidden'; // Prevent background scrolling + } + + return () => { + document.removeEventListener('keydown', handleEscape); + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + return { + isOpen, + openModal, + closeModal + }; +} \ No newline at end of file diff --git a/docs/src/components/support/index.ts b/docs/src/components/support/index.ts new file mode 100644 index 00000000..5523fe5d --- /dev/null +++ b/docs/src/components/support/index.ts @@ -0,0 +1,15 @@ +// Main components +export { default as SupportPage } from './SupportPage'; +export { default as SupportHero } from './SupportHero'; +export { default as DonationMethods } from './DonationMethods'; +export { default as DonationCard } from './DonationCard'; +export { default as CryptoModal } from './CryptoModal'; +export { default as AlternativeSupport } from './AlternativeSupport'; +export { default as SupportBanner } from './SupportBanner'; + +// Hooks +export { useModal } from './hooks/useModal'; +export { useClipboard } from './hooks/useClipboard'; + +// Configuration +export * from './config/donationConfig'; \ No newline at end of file diff --git a/docs/src/components/support/support.module.css b/docs/src/components/support/support.module.css new file mode 100644 index 00000000..5302f1a1 --- /dev/null +++ b/docs/src/components/support/support.module.css @@ -0,0 +1,597 @@ +/* Support Page Styles */ + +.supportPage { + --support-primary: #3b82f6; + --support-secondary: #10b981; + --support-accent: #f59e0b; + --support-github: #ea4aaa; + --support-buymeacoffee: #ff813f; + --support-crypto: #f7931a; + --support-bitcoin: #f7931a; + --support-ethereum: #627eea; + --support-dogecoin: #c2a633; + + /* Theme-aware colors */ + --support-card-bg: var(--ifm-card-background-color); + --support-card-hover-bg: var(--ifm-hover-overlay); + --support-text-color: var(--ifm-font-color-base); + --support-border-color: var(--ifm-color-emphasis-300); + --support-shadow: var(--ifm-global-shadow-lw); + + min-height: 100vh; + padding: 0; +} + +/* Hero Section */ +.hero { + background: linear-gradient(135deg, var(--support-primary), var(--support-secondary)); + color: white; + padding: 4rem 2rem; + text-align: center; + margin: 0; + border-radius: 0; + position: relative; + overflow: hidden; +} + +.hero::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(45deg, rgba(255,255,255,0.1) 0%, transparent 50%); + pointer-events: none; +} + +.heroContent { + position: relative; + z-index: 1; + max-width: 800px; + margin: 0 auto; +} + +.heroTitle { + font-size: 3rem; + margin: 0 0 1rem 0; + font-weight: 700; + text-shadow: 0 2px 4px rgba(0,0,0,0.3); + animation: fadeInUp 0.8s ease-out; +} + +.heroSubtitle { + font-size: 1.25rem; + margin: 0; + opacity: 0.95; + line-height: 1.6; + animation: fadeInUp 0.8s ease-out 0.2s both; +} + +.heroIcon { + font-size: 2rem; + margin-right: 1rem; + animation: pulse 2s infinite; +} + +/* Content Container */ +.contentContainer { + max-width: 1200px; + margin: 0 auto; + padding: 3rem 2rem; +} + +/* Donation Methods Grid */ +.donationMethodsSection { + margin: 3rem 0; +} + +.sectionTitle { + font-size: 2rem; + text-align: center; + margin-bottom: 3rem; + color: var(--support-text-color); + font-weight: 600; +} + +.donationGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 2rem; + margin: 2rem 0; +} + +/* Donation Cards */ +.donationCard { + background: var(--support-card-bg); + border-radius: 16px; + padding: 2rem; + text-align: center; + transition: all 0.3s ease; + border: 1px solid var(--support-border-color); + box-shadow: var(--support-shadow); + position: relative; + overflow: hidden; +} + +.donationCard::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--card-accent-color, var(--support-primary)); + transition: height 0.3s ease; +} + +.donationCard:hover { + transform: translateY(-8px); + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15); + background: var(--support-card-hover-bg); +} + +.donationCard:hover::before { + height: 8px; +} + +.donationCard.featured { + border: 2px solid var(--support-github); + box-shadow: 0 8px 25px rgba(234, 74, 170, 0.2); +} + +.donationCard.featured::after { + content: 'Recommended'; + position: absolute; + top: 1rem; + right: 1rem; + background: var(--support-github); + color: white; + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.donationIcon { + font-size: 3.5rem; + margin-bottom: 1.5rem; + display: block; + color: var(--card-accent-color, var(--support-primary)); + transition: transform 0.3s ease; +} + +.donationCard:hover .donationIcon { + transform: scale(1.1); +} + +.donationTitle { + font-size: 1.5rem; + margin-bottom: 1rem; + color: var(--support-text-color); + font-weight: 600; +} + +.donationDescription { + margin-bottom: 2rem; + opacity: 0.8; + line-height: 1.6; + color: var(--support-text-color); +} + +/* Donation Buttons */ +.donateBtn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.875rem 2rem; + border-radius: 8px; + text-decoration: none; + font-weight: 600; + font-size: 1.1rem; + transition: all 0.3s ease; + border: none; + cursor: pointer; + background: var(--card-accent-color, var(--support-primary)); + color: white; + min-width: 180px; + gap: 0.5rem; +} + +.donateBtn:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2); + text-decoration: none; + color: white; +} + +.donateBtn:active { + transform: translateY(0); +} + +/* Button variants */ +.githubBtn { + background: var(--support-github); +} + +.githubBtn:hover { + background: #d63384; + color: white; +} + +.buymeacoffeeBtn { + background: var(--support-buymeacoffee); +} + +.buymeacoffeeBtn:hover { + background: #e8732e; + color: white; +} + +.cryptoBtn { + background: var(--support-crypto); +} + +.cryptoBtn:hover { + background: #e6850e; + color: white; +} + +/* Alternative Support Section */ +.alternativeSection { + background: var(--support-card-bg); + border-radius: 16px; + padding: 2.5rem; + margin: 3rem 0; + border: 1px solid var(--support-border-color); +} + +.alternativeTitle { + font-size: 1.75rem; + margin-bottom: 1.5rem; + color: var(--support-text-color); + display: flex; + align-items: center; + gap: 0.75rem; +} + +.alternativeDescription { + margin-bottom: 2rem; + opacity: 0.9; + line-height: 1.6; + color: var(--support-text-color); +} + +.alternativeGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; +} + +.alternativeItem { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 1rem; + border-radius: 8px; + transition: background-color 0.3s ease; +} + +.alternativeItem:hover { + background: var(--support-card-hover-bg); +} + +.alternativeIcon { + font-size: 1.5rem; + color: var(--support-secondary); + margin-top: 0.25rem; + flex-shrink: 0; +} + +.alternativeContent h4 { + margin: 0 0 0.5rem 0; + font-weight: 600; + color: var(--support-text-color); +} + +.alternativeContent p { + margin: 0; + opacity: 0.8; + font-size: 0.9rem; + line-height: 1.5; +} + +.alternativeLink { + color: var(--support-primary); + text-decoration: none; + font-weight: 500; +} + +.alternativeLink:hover { + text-decoration: underline; +} + +/* Modal Styles */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); +} + +.modal.open { + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.3s ease; +} + +.modalContent { + background-color: var(--support-card-bg); + margin: 2rem; + padding: 2rem; + border-radius: 16px; + width: 90%; + max-width: 500px; + position: relative; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: slideInUp 0.3s ease; +} + +.modalClose { + color: var(--support-text-color); + float: right; + font-size: 28px; + font-weight: bold; + cursor: pointer; + position: absolute; + right: 1.5rem; + top: 1rem; + opacity: 0.7; + transition: opacity 0.3s ease; +} + +.modalClose:hover, +.modalClose:focus { + opacity: 1; +} + +.modalTitle { + text-align: center; + margin-bottom: 1.5rem; + color: var(--support-text-color); + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; +} + +/* Crypto Selection */ +.cryptoSelection { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 1rem; + margin: 1.5rem 0; +} + +.cryptoOption { + background: var(--ifm-color-emphasis-100); + border: 2px solid var(--support-border-color); + border-radius: 12px; + padding: 1.5rem 1rem; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; +} + +.cryptoOption:hover { + border-color: var(--support-crypto); + background: var(--support-card-hover-bg); + transform: translateY(-2px); +} + +.cryptoOption .cryptoIcon { + font-size: 2.5rem; + margin-bottom: 0.75rem; + display: block; +} + +.cryptoOption h4 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--support-text-color); +} + +/* Crypto Address Display */ +.cryptoAddress { + background: var(--ifm-color-emphasis-100); + padding: 1rem; + border-radius: 8px; + margin: 1.5rem 0; + font-family: 'SFMono-Regular', 'Monaco', 'Inconsolata', 'Liberation Mono', 'Courier New', monospace; + word-break: break-all; + border: 1px solid var(--support-border-color); + font-size: 0.9rem; +} + +.copyBtn { + background: var(--support-crypto); + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 6px; + cursor: pointer; + margin-top: 1rem; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-weight: 500; +} + +.copyBtn:hover { + background: #e6850e; + transform: translateY(-1px); +} + +.copyBtn.copied { + background: var(--support-secondary); +} + +.qrCode { + text-align: center; + margin: 1.5rem 0; +} + +.qrCodeImage { + background: white; + padding: 1rem; + border-radius: 8px; + display: inline-block; + margin-top: 1rem; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .hero { + padding: 3rem 1rem; + } + + .heroTitle { + font-size: 2.5rem; + } + + .heroSubtitle { + font-size: 1.1rem; + } + + .contentContainer { + padding: 2rem 1rem; + } + + .donationGrid { + grid-template-columns: 1fr; + gap: 1.5rem; + } + + .donationCard { + padding: 1.5rem; + } + + .alternativeGrid { + grid-template-columns: 1fr; + } + + .modalContent { + margin: 1rem; + padding: 1.5rem; + } + + .cryptoSelection { + grid-template-columns: 1fr; + } +} + +@media (max-width: 480px) { + .heroTitle { + font-size: 2rem; + } + + .heroIcon { + font-size: 1.5rem; + margin-right: 0.5rem; + } + + .donationIcon { + font-size: 3rem; + } + + .donateBtn { + width: 100%; + padding: 1rem; + } +} + +/* Animations */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideInUp { + from { + opacity: 0; + transform: translateY(50px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } +} + +/* Accessibility */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* Focus styles for accessibility */ +.donateBtn:focus, +.cryptoOption:focus, +.copyBtn:focus { + outline: 2px solid var(--support-primary); + outline-offset: 2px; +} + +/* Dark mode adjustments */ +[data-theme='dark'] .supportPage { + --support-card-bg: var(--ifm-background-surface-color); + --support-card-hover-bg: var(--ifm-color-emphasis-100); + --support-border-color: var(--ifm-color-emphasis-200); +} + +[data-theme='dark'] .hero { + background: linear-gradient(135deg, #1e40af, #059669); +} + +[data-theme='dark'] .qrCodeImage { + background: white; +} \ No newline at end of file diff --git a/docs/src/pages/index.module.css b/docs/src/pages/index.module.css index 9f71a5da..b9f07383 100644 --- a/docs/src/pages/index.module.css +++ b/docs/src/pages/index.module.css @@ -3,19 +3,392 @@ * and scoped locally. */ +/* Hero Section */ .heroBanner { - padding: 4rem 0; + position: relative; + padding: 6rem 0; + text-align: left; + overflow: hidden; + background: linear-gradient(135deg, #3e0d60 0%, #6b3fa0 50%, #9d5cb7 100%); + color: white; +} + +.heroBackground { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 25% 25%, rgba(255, 255, 255, 0.1) 0%, transparent 50%), + radial-gradient(circle at 75% 75%, rgba(255, 255, 255, 0.05) 0%, transparent 50%); + animation: float 20s ease-in-out infinite; +} + +@keyframes float { + 0%, 100% { transform: translateY(0px) rotate(0deg); } + 50% { transform: translateY(-20px) rotate(1deg); } +} + +.heroContent { + display: grid; + grid-template-columns: 1fr auto; + gap: 4rem; + align-items: center; + position: relative; + z-index: 1; +} + +.heroText { + max-width: 600px; +} + +.heroTitle { + margin-bottom: 1.5rem; + font-size: 3.5rem; + font-weight: 800; + line-height: 1.1; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.heroTitleMain { + background: linear-gradient(45deg, #ffffff, #e0c3fc); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.heroTitleSub { + font-size: 1.25rem; + font-weight: 400; + opacity: 0.9; + color: #e0c3fc; +} + +.heroSubtitle { + font-size: 1.25rem; + line-height: 1.6; + margin-bottom: 2rem; + opacity: 0.9; +} + +.heroButtons { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.heroVisual { + display: flex; + justify-content: center; +} + +.heroStats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; text-align: center; +} + +.statItem { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 16px; + padding: 1.5rem 1rem; + backdrop-filter: blur(10px); + transition: transform 0.3s ease; +} + +.statItem:hover { + transform: translateY(-5px); +} + +.statNumber { + font-size: 2rem; + margin-bottom: 0.5rem; +} + +.statLabel { + font-size: 0.875rem; + font-weight: 600; + opacity: 0.9; +} + +/* Section Headers */ +.sectionHeader { + text-align: center; + margin-bottom: 4rem; +} + +.sectionHeader h2 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 1rem; + color: var(--ifm-heading-color); +} + +.sectionHeader p { + font-size: 1.125rem; + color: var(--ifm-color-emphasis-700); + max-width: 600px; + margin: 0 auto; +} + +/* Features Section */ +.featuresSection { + padding: 6rem 0; + background: var(--ifm-background-color); +} + +.featuresGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 2rem; +} + +.featureCard { + background: var(--ifm-card-background-color); + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 16px; + padding: 2rem; + text-align: center; + transition: all 0.3s ease; position: relative; overflow: hidden; } -@media screen and (max-width: 996px) { - .heroBanner { - padding: 2rem; +.featureCard::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--accent-color); + transform: scaleX(0); + transition: transform 0.3s ease; +} + +.featureCard:hover { + transform: translateY(-8px); + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); + border-color: var(--accent-color); +} + +.featureCard:hover::before { + transform: scaleX(1); +} + +.featureIcon { + font-size: 3rem; + margin-bottom: 1rem; + display: block; +} + +.featureTitle { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--ifm-heading-color); +} + +.featureDescription { + line-height: 1.6; + color: var(--ifm-color-emphasis-700); +} + +/* Quick Start Section */ +.quickStartSection { + padding: 6rem 0; + background: var(--ifm-color-emphasis-100); +} + +.quickStartGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: 2rem; +} + +.quickStartCard { + background: var(--ifm-card-background-color); + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 16px; + padding: 2rem; + transition: all 0.3s ease; +} + +.quickStartCard:hover { + transform: translateY(-4px); + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1); + border-color: var(--ifm-color-primary); +} + +.quickStartHeader { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.quickStartIcon { + font-size: 2rem; +} + +.quickStartTitle { + font-size: 1.5rem; + font-weight: 600; + margin: 0; + color: var(--ifm-heading-color); +} + +.quickStartDescription { + margin-bottom: 1.5rem; + line-height: 1.6; + color: var(--ifm-color-emphasis-700); +} + +.commandBlock { + background: var(--ifm-code-background); + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1.5rem; + font-family: var(--ifm-font-family-monospace); + overflow-x: auto; + white-space: pre-line; +} + +.commandBlock code { + background: none; + border: none; + padding: 0; + font-size: 0.875rem; + color: var(--ifm-code-color); + white-space: pre-line; +} + +/* Integrations Section */ +.integrationsSection { + padding: 6rem 0; + background: var(--ifm-background-color); +} + +.integrationsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1.5rem; +} + +.integrationItem { + background: var(--ifm-card-background-color); + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 12px; + padding: 1.5rem; + text-align: center; + transition: all 0.3s ease; + cursor: pointer; +} + +.integrationItem:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); + border-color: var(--app-color); +} + +.integrationIcon { + font-size: 2rem; + display: block; + margin-bottom: 0.5rem; +} + +.integrationName { + font-weight: 600; + color: var(--ifm-heading-color); + font-size: 0.875rem; +} + +/* Responsive Design */ +@media (max-width: 996px) { + .heroContent { + grid-template-columns: 1fr; + text-align: center; + gap: 3rem; + } + + .heroTitle { + font-size: 2.5rem; + } + + .heroStats { + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + } + + .featuresGrid { + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + } + + .quickStartGrid { + grid-template-columns: 1fr; + } + + .integrationsGrid { + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); } } +@media (max-width: 768px) { + .heroBanner { + padding: 4rem 0; + } + + .heroTitle { + font-size: 2rem; + } + + .heroButtons { + justify-content: center; + } + + .heroStats { + grid-template-columns: 1fr; + gap: 1rem; + } + + .featuresGrid { + grid-template-columns: 1fr; + } + + .sectionHeader h2 { + font-size: 2rem; + } + + .featuresSection, + .quickStartSection, + .integrationsSection { + padding: 4rem 0; + } +} + +/* Dark theme adjustments */ +[data-theme='dark'] .heroBackground { + background: + radial-gradient(circle at 25% 25%, rgba(255, 255, 255, 0.05) 0%, transparent 50%), + radial-gradient(circle at 75% 75%, rgba(255, 255, 255, 0.02) 0%, transparent 50%); +} + +[data-theme='dark'] .statItem { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.1); +} + +[data-theme='dark'] .quickStartSection { + background: var(--ifm-background-surface-color); +} + +/* Legacy styles for backward compatibility */ .buttons { display: flex; align-items: center; diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index 697e9cb0..53dedc1f 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -4,38 +4,252 @@ import Link from "@docusaurus/Link"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import Layout from "@theme/Layout"; import Heading from "@theme/Heading"; +import SupportBanner from "../components/support/SupportBanner"; import styles from "./index.module.css"; +interface FeatureCardProps { + icon: string; + title: string; + description: string; + color: string; +} + +function FeatureCard({ icon, title, description, color }: FeatureCardProps) { + return ( +
+
{icon}
+

{title}

+

{description}

+
+ ); +} + +interface QuickStartCardProps { + icon: string; + title: string; + description: string; + command: string; + buttonText: string; + buttonLink: string; +} + +function QuickStartCard({ icon, title, description, command, buttonText, buttonLink }: QuickStartCardProps) { + return ( +
+
+ {icon} +

{title}

+
+

{description}

+
+ {command} +
+ + {buttonText} + +
+ ); +} + function HomepageHeader() { const { siteConfig } = useDocusaurusContext(); return ( -
+
+
- - {siteConfig.title} - -

{siteConfig.tagline}

-
- - Documentation - +
+
+ + {siteConfig.title} + Automated Download Management + +

+ Automatically clean up unwanted, stalled, and malicious downloads from your *arr applications and download clients. + Keep your queues clean and your media library safe. +

+
+ + ๐Ÿš€ Get Started + + + โœจ View Features + +
+
+
+
+
+
๐Ÿงน
+
Auto Cleanup
+
+
+
โšก
+
Strike System
+
+
+
๐Ÿ›ก๏ธ
+
Malware Protection
+
+
+
); } +function FeaturesSection() { + const features: FeatureCardProps[] = [ + { + icon: "๐Ÿšซ", + title: "Content Blocking", + description: "Automatically block and remove malicious files using customizable blocklists and whitelists.", + color: "#dc3545" + }, + { + icon: "โšก", + title: "Strike System", + description: "Intelligent strike-based removal for failed imports, stalled downloads, and slow transfers.", + color: "#ffc107" + }, + { + icon: "๐Ÿ”", + title: "Auto Search", + description: "Automatically trigger replacement searches when problematic downloads are removed.", + color: "#28a745" + }, + { + icon: "๐ŸŒฑ", + title: "Seeding Management", + description: "Clean up completed downloads based on seeding time and ratio requirements.", + color: "#17a2b8" + }, + { + icon: "๐Ÿ”—", + title: "Orphaned Detection", + description: "Remove downloads no longer referenced by your *arr applications with hardlink checking.", + color: "#6f42c1" + }, + { + icon: "๐Ÿ””", + title: "Smart Notifications", + description: "Get alerted about strikes, removals, and cleanup operations via Discord or Apprise.", + color: "#fd7e14" + } + ]; + + return ( +
+
+
+

Why Choose Cleanuparr?

+

Powerful automation features to keep your download ecosystem clean and efficient.

+
+
+ {features.map((props, idx) => ( + + ))} +
+
+
+ ); +} + +function QuickStartSection() { + const quickStartOptions: QuickStartCardProps[] = [ + { + icon: "๐Ÿณ", + title: "Docker (Recommended)", + description: "Get up and running in seconds with Docker Compose", + command: "docker run -d --name cleanuparr -p 11011:11011 cleanuparr/cleanuparr:latest", + buttonText: "Docker Setup Guide", + buttonLink: "/docs/installation" + }, + { + icon: "๐Ÿ’ป", + title: "Standalone Application", + description: "Download pre-built binaries for Windows, macOS, and Linux", + command: "# Download from GitHub Releases\n# Extract and run the executable", + buttonText: "Setup Guide", + buttonLink: "/docs/installation/detailed" + } + ]; + + return ( +
+
+
+

Quick Start

+

Choose your preferred installation method and get started immediately.

+
+
+ {quickStartOptions.map((props, idx) => ( + + ))} +
+
+
+ ); +} + +function IntegrationsSection() { + const supportedApps = [ + { name: "Sonarr", icon: "๐Ÿ“บ", color: "#3578e5" }, + { name: "Radarr", icon: "๐ŸŽฌ", color: "#ffc107" }, + { name: "Lidarr", icon: "๐ŸŽต", color: "#28a745" }, + //{ name: "Readarr", icon: "๐Ÿ“š", color: "#6f42c1" }, + //{ name: "Whisparr", icon: "๐Ÿ”ž", color: "#dc3545" }, + { name: "qBittorrent", icon: "โฌ‡๏ธ", color: "#17a2b8" }, + { name: "Deluge", icon: "๐ŸŒŠ", color: "#fd7e14" }, + { name: "Transmission", icon: "๐Ÿ“ก", color: "#e83e8c" } + ]; + + return ( +
+
+
+

Seamless Integrations

+

Works with all your favorite *arr applications and download clients.

+
+
+ {supportedApps.map((app, idx) => ( +
+ {app.icon} + {app.name} +
+ ))} +
+
+
+ ); +} + export default function Home(): ReactNode { const { siteConfig } = useDocusaurusContext(); return ( +
+ + + +
+ +
+
); } diff --git a/docs/src/pages/support.tsx b/docs/src/pages/support.tsx new file mode 100644 index 00000000..89217d0d --- /dev/null +++ b/docs/src/pages/support.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import Layout from '@theme/Layout'; +import SupportPage from '../components/support/SupportPage'; + +export default function Support() { + return ( + + + + ); +} \ No newline at end of file diff --git a/docs/static/img/cleanuperr.svg b/docs/static/img/cleanuparr.svg similarity index 100% rename from docs/static/img/cleanuperr.svg rename to docs/static/img/cleanuparr.svg diff --git a/docs/static/img/favicon.ico b/docs/static/img/favicon.ico index c01d54bc..c82f0265 100644 Binary files a/docs/static/img/favicon.ico and b/docs/static/img/favicon.ico differ diff --git a/installers/windows/cleanuparr-installer.iss b/installers/windows/cleanuparr-installer.iss new file mode 100644 index 00000000..d7a530f4 --- /dev/null +++ b/installers/windows/cleanuparr-installer.iss @@ -0,0 +1,170 @@ +#define MyAppName "Cleanuparr" +#define MyAppVersion GetEnv("APP_VERSION") +#define MyAppPublisher "Cleanuparr Team" +#define MyAppURL "https://github.com/Cleanuparr/Cleanuparr" +#define MyAppExeName "Cleanuparr.exe" +#define MyServiceName "Cleanuparr" + +[Setup] +AppId={{E8B2C9D4-6F87-4E42-B5C3-29E121D4BDFF} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +AllowNoIcons=yes +LicenseFile=LICENSE +OutputDir=.\installer +OutputBaseFilename=Cleanuparr_Setup +Compression=lzma +SolidCompression=yes +PrivilegesRequired=admin +ArchitecturesInstallIn64BitMode=x64 +DisableDirPage=no +DisableProgramGroupPage=yes +UninstallDisplayIcon={app}\{#MyAppExeName} +SetupIconFile=Logo\favicon.ico +WizardStyle=modern +CloseApplications=yes +RestartApplications=no + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked; OnlyBelowVersion: 0,6.1 +Name: "installservice"; Description: "Install as Windows Service (Recommended)"; GroupDescription: "Service Installation"; Flags: checkedonce + +[Files] +Source: "dist\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "Logo\favicon.ico"; DestDir: "{app}"; Flags: ignoreversion + +[Dirs] +Name: "{app}\config"; Permissions: everyone-full + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\favicon.ico" +Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" +Name: "{commondesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\favicon.ico"; Tasks: desktopicon + +[Run] +; Create service only if it doesn't exist (fresh install) +Filename: "{sys}\sc.exe"; Parameters: "create ""{#MyServiceName}"" binPath= ""\""{app}\{#MyAppExeName}\"""" DisplayName= ""{#MyAppName}"" start= auto"; Tasks: installservice; Flags: runhidden; Check: not ServiceExists('{#MyServiceName}') +Filename: "{sys}\sc.exe"; Parameters: "description ""{#MyServiceName}"" ""Cleanuparr download management service"""; Tasks: installservice; Flags: runhidden; Check: not ServiceExists('{#MyServiceName}') + +; Start service (both fresh install and update) +Filename: "{sys}\sc.exe"; Parameters: "start ""{#MyServiceName}"""; Tasks: installservice; Flags: runhidden + +; Open web interface +Filename: "http://localhost:11011"; Description: "Open Cleanuparr Web Interface"; Flags: postinstall shellexec nowait; Check: IsTaskSelected('installservice') + +; Run directly (if not installed as service) +Filename: "{app}\{#MyAppExeName}"; Description: "Run {#MyAppName} Application"; Flags: nowait postinstall skipifsilent; Check: not IsTaskSelected('installservice') + +[UninstallRun] +Filename: "{sys}\sc.exe"; Parameters: "stop ""{#MyServiceName}"""; Flags: runhidden; Check: ServiceExists('{#MyServiceName}') +Filename: "{sys}\timeout.exe"; Parameters: "/t 5"; Flags: runhidden; Check: ServiceExists('{#MyServiceName}') +Filename: "{sys}\sc.exe"; Parameters: "delete ""{#MyServiceName}"""; Flags: runhidden; Check: ServiceExists('{#MyServiceName}') + +[Code] +function ServiceExists(ServiceName: string): Boolean; +var + ResultCode: Integer; +begin + Result := Exec(ExpandConstant('{sys}\sc.exe'), 'query "' + ServiceName + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0); +end; + +function IsServiceRunning(ServiceName: string): Boolean; +var + ResultCode: Integer; + TempFile: string; + StatusOutput: AnsiString; +begin + Result := False; + TempFile := ExpandConstant('{tmp}\service_status.txt'); + + // Use PowerShell to get service status + if Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), + '-Command "try { (Get-Service -Name ''' + ServiceName + ''' -ErrorAction Stop).Status } catch { ''NotFound'' }" > "' + TempFile + '"', + '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0) then + begin + if LoadStringFromFile(TempFile, StatusOutput) then + begin + Result := (Pos('Running', StatusOutput) > 0); + end; + DeleteFile(TempFile); + end; +end; + +function WaitForServiceStop(ServiceName: string; TimeoutSeconds: Integer): Boolean; +var + Counter: Integer; +begin + Result := True; + Counter := 0; + + while Counter < TimeoutSeconds do + begin + if not IsServiceRunning(ServiceName) then + Exit; + Sleep(1000); + Counter := Counter + 1; + end; + + Result := False; +end; + +function InitializeSetup(): Boolean; +var + ResultCode: Integer; +begin + Result := True; + + // If service exists and is running, stop it for the update + if ServiceExists('{#MyServiceName}') and IsServiceRunning('{#MyServiceName}') then + begin + if MsgBox('Cleanuparr service is currently running and needs to be stopped for the installation. Continue?', + mbConfirmation, MB_YESNO) = IDYES then + begin + Exec(ExpandConstant('{sys}\sc.exe'), 'stop "{#MyServiceName}"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + + if not WaitForServiceStop('{#MyServiceName}', 30) then + begin + MsgBox('Warning: Service took longer than expected to stop. Installation will continue but you may need to restart the service manually.', + mbInformation, MB_OK); + end; + end + else + begin + Result := False; + end; + end; +end; + +function InitializeUninstall(): Boolean; +var + ResultCode: Integer; +begin + Result := True; + + if ServiceExists('{#MyServiceName}') then + begin + if MsgBox('Cleanuparr service will be stopped and removed. Continue with uninstallation?', + mbConfirmation, MB_YESNO) = IDYES then + begin + if IsServiceRunning('{#MyServiceName}') then + begin + Exec(ExpandConstant('{sys}\sc.exe'), 'stop "{#MyServiceName}"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + WaitForServiceStop('{#MyServiceName}', 30); + end; + end + else + begin + Result := False; + end; + end; +end; \ No newline at end of file