diff --git a/.devcontainer/Prowlarr.code-workspace b/.devcontainer/Prowlarr.code-workspace new file mode 100644 index 000000000..a46158e44 --- /dev/null +++ b/.devcontainer/Prowlarr.code-workspace @@ -0,0 +1,13 @@ +// This file is used to open the backend and frontend in the same workspace, which is necessary as +// the frontend has vscode settings that are distinct from the backend +{ + "folders": [ + { + "path": ".." + }, + { + "path": "../frontend" + } + ], + "settings": {} +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..70473224d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet +{ + "name": "Prowlarr", + "image": "mcr.microsoft.com/devcontainers/dotnet:1-6.0", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "nodeGypDependencies": true, + "version": "16", + "nvmVersion": "latest" + } + }, + "forwardPorts": [9696], + "customizations": { + "vscode": { + "extensions": ["esbenp.prettier-vscode"] + } + } +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 30d988d53..f70e2c23e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug Report -description: 'Report a new bug, if you are not 100% certain this is a bug please go to our Reddit or Discord first' +description: 'Report a new bug, if you are not 100% certain this is a bug please go to our Discord first' labels: ['Type: Bug', 'Status: Needs Triage'] body: - type: checkboxes diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index ad1dd9115..eb800532e 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -6,6 +6,3 @@ contact_links: - name: Support via Discord url: https://prowlarr.com/discord about: Chat with users and devs on support and setup related topics. - - name: Support via Reddit - url: https://reddit.com/r/prowlarr - about: Discuss and search thru support topics. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..f33a02cd1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/.github/label-actions.yml b/.github/label-actions.yml index 24fc04567..ce6d46c73 100644 --- a/.github/label-actions.yml +++ b/.github/label-actions.yml @@ -4,8 +4,7 @@ comment: > :wave: @{issue-author}, we use the issue tracker exclusively for bug reports and feature requests. However, this issue appears - to be a support request. Please hop over onto our [Discord](https://prowlarr.com/discord) - or [Subreddit](https://reddit.com/r/prowlarr) + to be a support request. Please hop over onto our [Discord](https://prowlarr.com/discord). close: true close-reason: 'not planned' diff --git a/.github/labeler.yml b/.github/labeler.yml index 21aacef8c..74160b634 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,19 +1,31 @@ 'Area: API': - - src/Prowlarr.Api.V1/**/* + - changed-files: + - any-glob-to-any-file: + - src/Prowlarr.Api.V1/**/* 'Area: Db-migration': - - src/NzbDrone.Core/Datastore/Migration/* + - changed-files: + - any-glob-to-any-file: + - src/NzbDrone.Core/Datastore/Migration/* 'Area: Download Clients': - - src/NzbDrone.Core/Download/Clients/**/* + - changed-files: + - any-glob-to-any-file: + - src/NzbDrone.Core/Download/Clients/**/* 'Area: Indexer': - - src/NzbDrone.Core/Indexers/**/* + - changed-files: + - any-glob-to-any-file: + - src/NzbDrone.Core/Indexers/**/* 'Area: Notifications': - - src/NzbDrone.Core/Notifications/**/* + - changed-files: + - any-glob-to-any-file: + - src/NzbDrone.Core/Notifications/**/* 'Area: UI': - - frontend/**/* - - package.json - - yarn.lock + - changed-files: + - any-glob-to-any-file: + - frontend/**/* + - package.json + - yarn.lock \ No newline at end of file diff --git a/.github/workflows/label-actions.yml b/.github/workflows/label-actions.yml index 8f35f6bd6..77c35366c 100644 --- a/.github/workflows/label-actions.yml +++ b/.github/workflows/label-actions.yml @@ -18,6 +18,6 @@ jobs: action: runs-on: ubuntu-latest steps: - - uses: dessant/label-actions@v3 + - uses: dessant/label-actions@v4 with: process-only: 'issues, prs' diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 857cfb4a7..ab2292824 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -9,4 +9,4 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/labeler@v4 + - uses: actions/labeler@v5 diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index cf38066c5..1d50cb1f1 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -9,7 +9,7 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v4 + - uses: dessant/lock-threads@v5 with: github-token: ${{ github.token }} issue-inactive-days: '90' diff --git a/.gitignore b/.gitignore index d903078ef..689b44415 100644 --- a/.gitignore +++ b/.gitignore @@ -127,6 +127,7 @@ coverage*.xml coverage*.json setup/Output/ *.~is +.mono # VS outout folders bin diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..7a36fefe1 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "ms-dotnettools.csdevkit", + "ms-vscode-remote.remote-containers" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..d13f9426e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md + "name": "Run Prowlarr", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build dotnet", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/_output/net6.0/Prowlarr", + "args": [], + "cwd": "${workspaceFolder}", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "integratedTerminal", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..b3e22f6d1 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,44 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build dotnet", + "command": "dotnet", + "type": "process", + "args": [ + "msbuild", + "-restore", + "${workspaceFolder}/src/Prowlarr.sln", + "-p:GenerateFullPaths=true", + "-p:Configuration=Debug", + "-p:Platform=Posix", + "-consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/src/Prowlarr.sln", + "-property:GenerateFullPaths=true", + "-consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/src/Prowlarr.sln" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/Logo/dottrace.svg b/Logo/dottrace.svg deleted file mode 100644 index b879517cd..000000000 --- a/Logo/dottrace.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Logo/jetbrains.svg b/Logo/jetbrains.svg deleted file mode 100644 index 75d4d2177..000000000 --- a/Logo/jetbrains.svg +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Logo/resharper.svg b/Logo/resharper.svg deleted file mode 100644 index 24c987a78..000000000 --- a/Logo/resharper.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Logo/rider.svg b/Logo/rider.svg deleted file mode 100644 index 82da35b0b..000000000 --- a/Logo/rider.svg +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - rider - - - - - - - - - - - - - - diff --git a/Logo/webstorm.svg b/Logo/webstorm.svg deleted file mode 100644 index 39ab7eb97..000000000 --- a/Logo/webstorm.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/README.md b/README.md index e5759c632..e8c60546a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Prowlarr [![Build Status](https://dev.azure.com/Prowlarr/Prowlarr/_apis/build/status/Prowlarr.Prowlarr?branchName=develop)](https://dev.azure.com/Prowlarr/Prowlarr/_build/latest?definitionId=1&branchName=develop) -[![Translated](https://translate.servarr.com/widgets/servarr/-/prowlarr/svg-badge.svg)](https://translate.servarr.com/engage/prowlarr/?utm_source=widget) -[![Docker Pulls](https://img.shields.io/docker/pulls/hotio/prowlarr.svg)](https://wiki.servarr.com/prowlarr/installation#docker) +[![Translation status](https://translate.servarr.com/widget/servarr/prowlarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/?utm_source=widget) +[![Docker Pulls](https://img.shields.io/docker/pulls/hotio/prowlarr.svg)](https://wiki.servarr.com/prowlarr/installation/docker) ![Github Downloads](https://img.shields.io/github/downloads/Prowlarr/Prowlarr/total.svg) [![Backers on Open Collective](https://opencollective.com/Prowlarr/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/Prowlarr/sponsors/badge.svg)](#sponsors) @@ -29,7 +29,6 @@ Prowlarr is an indexer manager/proxy built on the popular \*arr .net/reactjs bas [![Wiki](https://img.shields.io/badge/servarr-wiki-181717.svg?maxAge=60)](https://wiki.servarr.com/prowlarr) [![Discord](https://img.shields.io/badge/discord-chat-7289DA.svg?maxAge=60)](https://prowlarr.com/discord) -[![Reddit](https://img.shields.io/badge/reddit-discussion-FF4500.svg?maxAge=60)](https://www.reddit.com/r/Prowlarr) Note: GitHub Issues are for Bugs and Feature Requests Only @@ -69,16 +68,16 @@ Support this project by becoming a sponsor. Your logo will show up here with a l ## JetBrains -Thank you to [JetBrains JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools. +Thank you to [JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools. -- [ReSharper ReSharper](http://www.jetbrains.com/resharper/) -- [WebStorm WebStorm](http://www.jetbrains.com/webstorm/) -- [Rider Rider](http://www.jetbrains.com/rider/) -- [dotTrace dotTrace](http://www.jetbrains.com/dottrace/) +* [ReSharper ReSharper](http://www.jetbrains.com/resharper/) +* [WebStorm WebStorm](http://www.jetbrains.com/webstorm/) +* [Rider Rider](http://www.jetbrains.com/rider/) +* [dotTrace dotTrace](http://www.jetbrains.com/dottrace/) ### License - [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) -- Copyright 2010-2022 +- Copyright 2010-2024 Icon Credit - [Box vector created by freepik - www.freepik.com](https://www.freepik.com/vectors/box) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 2383f7f05..dc667e803 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,24 +9,28 @@ variables: testsFolder: './_tests' yarnCacheFolder: $(Pipeline.Workspace)/.yarn nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages - majorVersion: '1.7.2' + majorVersion: '1.35.0' minorVersion: $[counter('minorVersion', 1)] prowlarrVersion: '$(majorVersion).$(minorVersion)' buildName: '$(Build.SourceBranchName).$(prowlarrVersion)' sentryOrg: 'servarr' sentryUrl: 'https://sentry.servarr.com' - dotnetVersion: '6.0.408' - innoVersion: '6.2.0' - nodeVersion: '16.x' + dotnetVersion: '6.0.427' + nodeVersion: '20.X' + innoVersion: '6.2.2' windowsImage: 'windows-2022' - linuxImage: 'ubuntu-20.04' - macImage: 'macOS-11' + linuxImage: 'ubuntu-22.04' + macImage: 'macOS-13' trigger: branches: include: - develop - master + paths: + exclude: + - .github + - src/Prowlarr.Api.*/openapi.json pr: branches: @@ -34,8 +38,9 @@ pr: - develop paths: exclude: + - .github - src/NzbDrone.Core/Localization/Core - - src/Prowlarr.API.*/openapi.json + - src/Prowlarr.Api.*/openapi.json stages: - stage: Setup @@ -161,10 +166,10 @@ stages: pool: vmImage: $(imageName) steps: - - task: NodeTool@0 + - task: UseNode@1 displayName: Set Node.js version inputs: - versionSpec: $(nodeVersion) + version: $(nodeVersion) - checkout: self submodules: true fetchDepth: 1 @@ -349,7 +354,7 @@ stages: includeRootFolder: false rootFolderOrFile: $(artifactsFolder)/linux-musl-arm64/net6.0 - task: ArchiveFiles@2 - displayName: Create FreeBSD Core Core tar + displayName: Create freebsd-x64 tar inputs: archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).freebsd-core-x64.tar.gz' archiveType: 'tar' @@ -528,8 +533,8 @@ stages: testRunTitle: '$(testName) Unit Tests' failTaskOnFailedTests: true - - job: Unit_LinuxCore_Postgres - displayName: Unit Native LinuxCore with Postgres Database + - job: Unit_LinuxCore_Postgres14 + displayName: Unit Native LinuxCore with Postgres14 Database dependsOn: Prepare condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0')) variables: @@ -565,6 +570,7 @@ stages: -e POSTGRES_PASSWORD=prowlarr \ -e POSTGRES_USER=prowlarr \ -p 5432:5432/tcp \ + -v /usr/share/zoneinfo/America/Chicago:/etc/localtime:ro \ postgres:14 displayName: Start postgres - bash: | @@ -577,7 +583,60 @@ stages: inputs: testResultsFormat: 'NUnit' testResultsFiles: '**/TestResult.xml' - testRunTitle: 'LinuxCore Postgres Unit Tests' + testRunTitle: 'LinuxCore Postgres14 Unit Tests' + failTaskOnFailedTests: true + + - job: Unit_LinuxCore_Postgres15 + displayName: Unit Native LinuxCore with Postgres15 Database + dependsOn: Prepare + condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0')) + variables: + pattern: 'Prowlarr.*.linux-core-x64.tar.gz' + artifactName: linux-x64-tests + Prowlarr__Postgres__Host: 'localhost' + Prowlarr__Postgres__Port: '5432' + Prowlarr__Postgres__User: 'prowlarr' + Prowlarr__Postgres__Password: 'prowlarr' + + pool: + vmImage: ${{ variables.linuxImage }} + + timeoutInMinutes: 10 + + steps: + - task: UseDotNet@2 + displayName: 'Install .net core' + inputs: + version: $(dotnetVersion) + - checkout: none + - task: DownloadPipelineArtifact@2 + displayName: Download Test Artifact + inputs: + buildType: 'current' + artifactName: $(artifactName) + targetPath: $(testsFolder) + - bash: find ${TESTSFOLDER} -name "Prowlarr.Test.Dummy" -exec chmod a+x {} \; + displayName: Make Test Dummy Executable + condition: and(succeeded(), ne(variables['osName'], 'Windows')) + - bash: | + docker run -d --name=postgres15 \ + -e POSTGRES_PASSWORD=prowlarr \ + -e POSTGRES_USER=prowlarr \ + -p 5432:5432/tcp \ + -v /usr/share/zoneinfo/America/Chicago:/etc/localtime:ro \ + postgres:15 + displayName: Start postgres + - bash: | + chmod a+x ${TESTSFOLDER}/test.sh + ls -lR ${TESTSFOLDER} + ${TESTSFOLDER}/test.sh Linux Unit Test + displayName: Run Tests + - task: PublishTestResults@2 + displayName: Publish Test Results + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '**/TestResult.xml' + testRunTitle: 'LinuxCore Postgres15 Unit Tests' failTaskOnFailedTests: true - stage: Integration @@ -663,8 +722,8 @@ stages: failTaskOnFailedTests: true displayName: Publish Test Results - - job: Integration_LinuxCore_Postgres - displayName: Integration Native LinuxCore with Postgres Database + - job: Integration_LinuxCore_Postgres14 + displayName: Integration Native LinuxCore with Postgres14 Database dependsOn: Prepare condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0')) variables: @@ -710,6 +769,7 @@ stages: -e POSTGRES_PASSWORD=prowlarr \ -e POSTGRES_USER=prowlarr \ -p 5432:5432/tcp \ + -v /usr/share/zoneinfo/America/Chicago:/etc/localtime:ro \ postgres:14 displayName: Start postgres - bash: | @@ -720,7 +780,70 @@ stages: inputs: testResultsFormat: 'NUnit' testResultsFiles: '**/TestResult.xml' - testRunTitle: 'Integration LinuxCore Postgres Database Integration Tests' + testRunTitle: 'Integration LinuxCore Postgres14 Database Integration Tests' + failTaskOnFailedTests: true + displayName: Publish Test Results + + + - job: Integration_LinuxCore_Postgres15 + displayName: Integration Native LinuxCore with Postgres Database + dependsOn: Prepare + condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0')) + variables: + pattern: 'Prowlarr.*.linux-core-x64.tar.gz' + Prowlarr__Postgres__Host: 'localhost' + Prowlarr__Postgres__Port: '5432' + Prowlarr__Postgres__User: 'prowlarr' + Prowlarr__Postgres__Password: 'prowlarr' + + pool: + vmImage: ${{ variables.linuxImage }} + + steps: + - task: UseDotNet@2 + displayName: 'Install .net core' + inputs: + version: $(dotnetVersion) + - checkout: none + - task: DownloadPipelineArtifact@2 + displayName: Download Test Artifact + inputs: + buildType: 'current' + artifactName: 'linux-x64-tests' + targetPath: $(testsFolder) + - task: DownloadPipelineArtifact@2 + displayName: Download Build Artifact + inputs: + buildType: 'current' + artifactName: Packages + itemPattern: '**/$(pattern)' + targetPath: $(Build.ArtifactStagingDirectory) + - task: ExtractFiles@1 + inputs: + archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' + destinationFolder: '$(Build.ArtifactStagingDirectory)/bin' + displayName: Extract Package + - bash: | + mkdir -p ./bin/ + cp -r -v ${BUILD_ARTIFACTSTAGINGDIRECTORY}/bin/Prowlarr/. ./bin/ + displayName: Move Package Contents + - bash: | + docker run -d --name=postgres15 \ + -e POSTGRES_PASSWORD=prowlarr \ + -e POSTGRES_USER=prowlarr \ + -p 5432:5432/tcp \ + -v /usr/share/zoneinfo/America/Chicago:/etc/localtime:ro \ + postgres:15 + displayName: Start postgres + - bash: | + chmod a+x ${TESTSFOLDER}/test.sh + ${TESTSFOLDER}/test.sh Linux Integration Test + displayName: Run Integration Tests + - task: PublishTestResults@2 + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '**/TestResult.xml' + testRunTitle: 'Integration LinuxCore Postgres15 Database Integration Tests' failTaskOnFailedTests: true displayName: Publish Test Results @@ -952,10 +1075,10 @@ stages: pool: vmImage: $(imageName) steps: - - task: NodeTool@0 + - task: UseNode@1 displayName: Set Node.js version inputs: - versionSpec: $(nodeVersion) + version: $(nodeVersion) - checkout: self submodules: true fetchDepth: 1 @@ -1003,7 +1126,7 @@ stages: git add . if git status | grep -q modified then - git commit -am 'Automated API Docs update [skip ci]' + git commit -am 'Automated API Docs update' git push -f --set-upstream origin api-docs curl -X POST -H "Authorization: token ${GITHUBTOKEN}" -H "Accept: application/vnd.github.v3+json" https://api.github.com/repos/prowlarr/prowlarr/pulls -d '{"head":"api-docs","base":"develop","title":"Update API docs"}' else @@ -1046,12 +1169,12 @@ stages: submodules: true - powershell: Set-Service SCardSvr -StartupType Manual displayName: Enable Windows Test Service - - task: SonarCloudPrepare@1 + - task: SonarCloudPrepare@3 condition: eq(variables['System.PullRequest.IsFork'], 'False') inputs: SonarCloud: 'SonarCloud' organization: 'prowlarr' - scannerMode: 'MSBuild' + scannerMode: 'dotnet' projectKey: 'Prowlarr_Prowlarr' projectName: 'Prowlarr' projectVersion: '$(prowlarrVersion)' @@ -1064,25 +1187,21 @@ stages: ./build.sh --backend -f net6.0 -r win-x64 TEST_DIR=_tests/net6.0/win-x64/publish/ ./test.sh Windows Unit Coverage displayName: Coverage Unit Tests - - task: SonarCloudAnalyze@1 + - task: SonarCloudAnalyze@3 condition: eq(variables['System.PullRequest.IsFork'], 'False') displayName: Publish SonarCloud Results - - task: reportgenerator@4 + - task: reportgenerator@5.3.11 displayName: Generate Coverage Report inputs: reports: '$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml' targetdir: '$(Build.SourcesDirectory)/CoverageResults/combined' reporttypes: 'HtmlInline_AzurePipelines;Cobertura;Badges' - - task: PublishCodeCoverageResults@1 - displayName: Publish Coverage Report - inputs: - codeCoverageTool: 'cobertura' - summaryFileLocation: './CoverageResults/combined/Cobertura.xml' - reportDirectory: './CoverageResults/combined/' + publishCodeCoverageResults: true - stage: Report_Out dependsOn: - Analyze + - Installer - Unit_Test - Integration - Automation diff --git a/build.sh b/build.sh index 9ce85b634..5139dba52 100755 --- a/build.sh +++ b/build.sh @@ -254,7 +254,7 @@ InstallInno() ProgressStart "Installing portable Inno Setup" rm -rf _inno - curl -s --output innosetup.exe "https://files.jrsoftware.org/is/6/innosetup-${INNOVERSION:-6.2.0}.exe" + curl -s --output innosetup.exe "https://files.jrsoftware.org/is/6/innosetup-${INNOVERSION:-6.2.2}.exe" mkdir _inno ./innosetup.exe //portable=1 //silent //currentuser //dir=.\\_inno rm innosetup.exe @@ -392,22 +392,21 @@ then fi fi -if [ "$FRONTEND" = "YES" ]; +if [[ "$LINT" = "YES" || "$FRONTEND" = "YES" ]]; then YarnInstall - RunWebpack fi if [ "$LINT" = "YES" ]; then - if [ -z "$FRONTEND" ]; - then - YarnInstall - fi - LintUI fi +if [ "$FRONTEND" = "YES" ]; +then + RunWebpack +fi + if [ "$PACKAGES" = "YES" ]; then UpdateVersionNumber diff --git a/docs.sh b/docs.sh old mode 100644 new mode 100755 index ae11bc83f..38b0e0fbc --- a/docs.sh +++ b/docs.sh @@ -1,13 +1,18 @@ +#!/bin/bash +set -e + +FRAMEWORK="net6.0" PLATFORM=$1 +ARCHITECTURE="${2:-x64}" if [ "$PLATFORM" = "Windows" ]; then - RUNTIME="win-x64" + RUNTIME="win-$ARCHITECTURE" elif [ "$PLATFORM" = "Linux" ]; then - RUNTIME="linux-x64" + RUNTIME="linux-$ARCHITECTURE" elif [ "$PLATFORM" = "Mac" ]; then - RUNTIME="osx-x64" + RUNTIME="osx-$ARCHITECTURE" else - echo "Platform must be provided as first arguement: Windows, Linux or Mac" + echo "Platform must be provided as first argument: Windows, Linux or Mac" exit 1 fi @@ -21,17 +26,23 @@ slnFile=src/Prowlarr.sln platform=Posix + if [ "$PLATFORM" = "Windows" ]; then + application=Prowlarr.Console.dll +else + application=Prowlarr.dll +fi + dotnet clean $slnFile -c Debug dotnet clean $slnFile -c Release dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids dotnet new tool-manifest -dotnet tool install --version 6.5.0 Swashbuckle.AspNetCore.Cli +dotnet tool install --version 7.3.2 Swashbuckle.AspNetCore.Cli -dotnet tool run swagger tofile --output ./src/Prowlarr.Api.V1/openapi.json "$outputFolder/net6.0/$RUNTIME/prowlarr.console.dll" v1 & +dotnet tool run swagger tofile --output ./src/Prowlarr.Api.V1/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v1 & -sleep 30 +sleep 45 kill %1 diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index c312414a2..56eaaeaab 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -26,7 +26,8 @@ module.exports = { globals: { expect: false, chai: false, - sinon: false + sinon: false, + JSX: true }, parserOptions: { @@ -356,11 +357,16 @@ module.exports = { ], rules: Object.assign(typescriptEslintRecommended.rules, { - 'no-shadow': 'off', - // These should be enabled after cleaning things up - '@typescript-eslint/no-unused-vars': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'after-used', + argsIgnorePattern: '^_', + ignoreRestSiblings: true + } + ], '@typescript-eslint/explicit-function-return-type': 'off', - 'react/prop-types': 'off', + 'no-shadow': 'off', 'prettier/prettier': 'error', 'simple-import-sort/imports': [ 'error', @@ -373,7 +379,41 @@ module.exports = { ['^@?\\w', `^(${dirs})(/.*|$)`, '^\\.', '^\\..*css$'] ] } - ] + ], + + // React Hooks + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'error', + + // React + 'react/function-component-definition': 'error', + 'react/hook-use-state': 'error', + 'react/jsx-boolean-value': ['error', 'always'], + 'react/jsx-curly-brace-presence': [ + 'error', + { props: 'never', children: 'never' } + ], + 'react/jsx-fragments': 'error', + 'react/jsx-handler-names': [ + 'error', + { + eventHandlerPrefix: 'on', + eventHandlerPropPrefix: 'on' + } + ], + 'react/jsx-no-bind': ['error', { ignoreRefs: true }], + 'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }], + 'react/jsx-pascal-case': ['error', { allowAllCaps: true }], + 'react/jsx-sort-props': [ + 'error', + { + callbacksLast: true, + noSortAlphabetically: true, + reservedFirst: true + } + ], + 'react/prop-types': 'off', + 'react/self-closing-comp': 'error' }) }, { diff --git a/frontend/babel.config.js b/frontend/babel.config.js index 4d60cc820..ade9f24a2 100644 --- a/frontend/babel.config.js +++ b/frontend/babel.config.js @@ -2,16 +2,18 @@ const loose = true; module.exports = { plugins: [ + '@babel/plugin-transform-logical-assignment-operators', + // Stage 1 '@babel/plugin-proposal-export-default-from', - ['@babel/plugin-proposal-optional-chaining', { loose }], - ['@babel/plugin-proposal-nullish-coalescing-operator', { loose }], + ['@babel/plugin-transform-optional-chaining', { loose }], + ['@babel/plugin-transform-nullish-coalescing-operator', { loose }], // Stage 2 - '@babel/plugin-proposal-export-namespace-from', + '@babel/plugin-transform-export-namespace-from', // Stage 3 - ['@babel/plugin-proposal-class-properties', { loose }], + ['@babel/plugin-transform-class-properties', { loose }], '@babel/plugin-syntax-dynamic-import' ], env: { diff --git a/frontend/build/webpack.config.js b/frontend/build/webpack.config.js index 7c1dcba83..ceacc4f04 100644 --- a/frontend/build/webpack.config.js +++ b/frontend/build/webpack.config.js @@ -25,6 +25,7 @@ module.exports = (env) => { const config = { mode: isProduction ? 'production' : 'development', devtool: isProduction ? 'source-map' : 'eval-source-map', + target: 'web', stats: { children: false @@ -35,7 +36,7 @@ module.exports = (env) => { }, entry: { - index: 'index.js' + index: 'index.ts' }, resolve: { @@ -65,23 +66,23 @@ module.exports = (env) => { output: { path: distFolder, publicPath: '/', - filename: '[name].js', + filename: isProduction ? '[name]-[contenthash].js' : '[name].js', sourceMapFilename: '[file].map' }, optimization: { moduleIds: 'deterministic', - chunkIds: 'named', - splitChunks: { - chunks: 'initial', - name: 'vendors' - } + chunkIds: isProduction ? 'deterministic' : 'named' }, performance: { hints: false }, + experiments: { + topLevelAwait: true + }, + plugins: [ new webpack.DefinePlugin({ __DEV__: !isProduction, @@ -89,13 +90,15 @@ module.exports = (env) => { }), new MiniCssExtractPlugin({ - filename: 'Content/styles.css' + filename: 'Content/styles.css', + chunkFilename: isProduction ? 'Content/[id]-[chunkhash].css' : 'Content/[id].css' }), new HtmlWebpackPlugin({ template: 'frontend/src/index.ejs', filename: 'index.html', - publicPath: '/' + publicPath: '/', + inject: false }), new FileManagerPlugin({ @@ -167,7 +170,7 @@ module.exports = (env) => { loose: true, debug: false, useBuiltIns: 'entry', - corejs: 3 + corejs: '3.39' } ] ] @@ -188,7 +191,7 @@ module.exports = (env) => { options: { importLoaders: 1, modules: { - localIdentName: '[name]/[local]/[hash:base64:5]' + localIdentName: isProduction ? '[name]/[local]/[hash:base64:5]' : '[name]/[local]' } } }, diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index f657adf28..89db00f8c 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -16,6 +16,7 @@ const mixinsFiles = [ module.exports = { plugins: [ + 'autoprefixer', ['postcss-mixins', { mixinsFiles }], diff --git a/frontend/src/App/App.js b/frontend/src/App/App.tsx similarity index 57% rename from frontend/src/App/App.js rename to frontend/src/App/App.tsx index 1eea6e082..dba90a697 100644 --- a/frontend/src/App/App.js +++ b/frontend/src/App/App.tsx @@ -1,31 +1,30 @@ -import { ConnectedRouter } from 'connected-react-router'; -import PropTypes from 'prop-types'; +import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router'; import React from 'react'; import DocumentTitle from 'react-document-title'; import { Provider } from 'react-redux'; +import { Store } from 'redux'; import PageConnector from 'Components/Page/PageConnector'; import ApplyTheme from './ApplyTheme'; import AppRoutes from './AppRoutes'; -function App({ store, history }) { +interface AppProps { + store: Store; + history: ConnectedRouterProps['history']; +} + +function App({ store, history }: AppProps) { return ( - - - - - + + + + ); } -App.propTypes = { - store: PropTypes.object.isRequired, - history: PropTypes.object.isRequired -}; - export default App; diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js deleted file mode 100644 index 0df7d2a49..000000000 --- a/frontend/src/App/AppRoutes.js +++ /dev/null @@ -1,184 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { Redirect, Route } from 'react-router-dom'; -import NotFound from 'Components/NotFound'; -import Switch from 'Components/Router/Switch'; -import HistoryConnector from 'History/HistoryConnector'; -import IndexerIndex from 'Indexer/Index/IndexerIndex'; -import StatsConnector from 'Indexer/Stats/StatsConnector'; -import SearchIndexConnector from 'Search/SearchIndexConnector'; -import ApplicationSettingsConnector from 'Settings/Applications/ApplicationSettingsConnector'; -import DevelopmentSettingsConnector from 'Settings/Development/DevelopmentSettingsConnector'; -import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; -import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; -import IndexerSettings from 'Settings/Indexers/IndexerSettings'; -import NotificationSettings from 'Settings/Notifications/NotificationSettings'; -import Settings from 'Settings/Settings'; -import TagSettings from 'Settings/Tags/TagSettings'; -import UISettingsConnector from 'Settings/UI/UISettingsConnector'; -import BackupsConnector from 'System/Backup/BackupsConnector'; -import LogsTableConnector from 'System/Events/LogsTableConnector'; -import Logs from 'System/Logs/Logs'; -import Status from 'System/Status/Status'; -import Tasks from 'System/Tasks/Tasks'; -import UpdatesConnector from 'System/Updates/UpdatesConnector'; -import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; - -function AppRoutes(props) { - const { - app - } = props; - - return ( - - {/* - Indexers - */} - - - - { - window.Prowlarr.urlBase && - { - return ( - - ); - }} - /> - } - - - - {/* - Search - */} - - - - {/* - Activity - */} - - - - {/* - Settings - */} - - - - - - - - - - - - - - - - - - - - {/* - System - */} - - - - - - - - - - - - - - {/* - Not Found - */} - - - - ); -} - -AppRoutes.propTypes = { - app: PropTypes.func.isRequired -}; - -export default AppRoutes; diff --git a/frontend/src/App/AppRoutes.tsx b/frontend/src/App/AppRoutes.tsx new file mode 100644 index 000000000..d451a12fb --- /dev/null +++ b/frontend/src/App/AppRoutes.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { Redirect, Route } from 'react-router-dom'; +import NotFound from 'Components/NotFound'; +import Switch from 'Components/Router/Switch'; +import HistoryConnector from 'History/HistoryConnector'; +import IndexerIndex from 'Indexer/Index/IndexerIndex'; +import IndexerStats from 'Indexer/Stats/IndexerStats'; +import SearchIndexConnector from 'Search/SearchIndexConnector'; +import ApplicationSettings from 'Settings/Applications/ApplicationSettings'; +import DevelopmentSettingsConnector from 'Settings/Development/DevelopmentSettingsConnector'; +import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; +import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; +import IndexerSettings from 'Settings/Indexers/IndexerSettings'; +import NotificationSettings from 'Settings/Notifications/NotificationSettings'; +import Settings from 'Settings/Settings'; +import TagSettings from 'Settings/Tags/TagSettings'; +import UISettingsConnector from 'Settings/UI/UISettingsConnector'; +import BackupsConnector from 'System/Backup/BackupsConnector'; +import LogsTableConnector from 'System/Events/LogsTableConnector'; +import Logs from 'System/Logs/Logs'; +import Status from 'System/Status/Status'; +import Tasks from 'System/Tasks/Tasks'; +import Updates from 'System/Updates/Updates'; +import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; + +function RedirectWithUrlBase() { + return ; +} + +function AppRoutes() { + return ( + + {/* + Indexers + */} + + + + {window.Prowlarr.urlBase && ( + + )} + + + + {/* + Search + */} + + + + {/* + Activity + */} + + + + {/* + Settings + */} + + + + + + + + + + + + + + + + + + + + {/* + System + */} + + + + + + + + + + + + + + {/* + Not Found + */} + + + + ); +} + +export default AppRoutes; diff --git a/frontend/src/App/AppUpdatedModal.js b/frontend/src/App/AppUpdatedModal.js deleted file mode 100644 index abc7f8832..000000000 --- a/frontend/src/App/AppUpdatedModal.js +++ /dev/null @@ -1,30 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector'; - -function AppUpdatedModal(props) { - const { - isOpen, - onModalClose - } = props; - - return ( - - - - ); -} - -AppUpdatedModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default AppUpdatedModal; diff --git a/frontend/src/App/AppUpdatedModal.tsx b/frontend/src/App/AppUpdatedModal.tsx new file mode 100644 index 000000000..696d36fb2 --- /dev/null +++ b/frontend/src/App/AppUpdatedModal.tsx @@ -0,0 +1,28 @@ +import React, { useCallback } from 'react'; +import Modal from 'Components/Modal/Modal'; +import AppUpdatedModalContent from './AppUpdatedModalContent'; + +interface AppUpdatedModalProps { + isOpen: boolean; + onModalClose: (...args: unknown[]) => unknown; +} + +function AppUpdatedModal(props: AppUpdatedModalProps) { + const { isOpen, onModalClose } = props; + + const handleModalClose = useCallback(() => { + location.reload(); + }, []); + + return ( + + + + ); +} + +export default AppUpdatedModal; diff --git a/frontend/src/App/AppUpdatedModalConnector.js b/frontend/src/App/AppUpdatedModalConnector.js deleted file mode 100644 index a21afbc5a..000000000 --- a/frontend/src/App/AppUpdatedModalConnector.js +++ /dev/null @@ -1,12 +0,0 @@ -import { connect } from 'react-redux'; -import AppUpdatedModal from './AppUpdatedModal'; - -function createMapDispatchToProps(dispatch, props) { - return { - onModalClose() { - location.reload(); - } - }; -} - -export default connect(null, createMapDispatchToProps)(AppUpdatedModal); diff --git a/frontend/src/App/AppUpdatedModalContent.css b/frontend/src/App/AppUpdatedModalContent.css index 37b89c9be..0df4183a6 100644 --- a/frontend/src/App/AppUpdatedModalContent.css +++ b/frontend/src/App/AppUpdatedModalContent.css @@ -1,6 +1,7 @@ .version { margin: 0 3px; font-weight: bold; + font-family: var(--defaultFontFamily); } .maintenance { diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js deleted file mode 100644 index d03609a69..000000000 --- a/frontend/src/App/AppUpdatedModalContent.js +++ /dev/null @@ -1,140 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Button from 'Components/Link/Button'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { kinds } from 'Helpers/Props'; -import UpdateChanges from 'System/Updates/UpdateChanges'; -import translate from 'Utilities/String/translate'; -import styles from './AppUpdatedModalContent.css'; - -function mergeUpdates(items, version, prevVersion) { - let installedIndex = items.findIndex((u) => u.version === version); - let installedPreviouslyIndex = items.findIndex((u) => u.version === prevVersion); - - if (installedIndex === -1) { - installedIndex = 0; - } - - if (installedPreviouslyIndex === -1) { - installedPreviouslyIndex = items.length; - } else if (installedPreviouslyIndex === installedIndex && items.length) { - installedPreviouslyIndex += 1; - } - - const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex); - - if (!appliedUpdates.length) { - return null; - } - - const appliedChanges = { new: [], fixed: [] }; - appliedUpdates.forEach((u) => { - if (u.changes) { - appliedChanges.new.push(... u.changes.new); - appliedChanges.fixed.push(... u.changes.fixed); - } - }); - - const mergedUpdate = Object.assign({}, appliedUpdates[0], { changes: appliedChanges }); - - if (!appliedChanges.new.length && !appliedChanges.fixed.length) { - mergedUpdate.changes = null; - } - - return mergedUpdate; -} - -function AppUpdatedModalContent(props) { - const { - version, - prevVersion, - isPopulated, - error, - items, - onSeeChangesPress, - onModalClose - } = props; - - const update = mergeUpdates(items, version, prevVersion); - - return ( - - - Prowlarr Updated - - - -
- Version {version} of Prowlarr has been installed, in order to get the latest changes you'll need to reload Prowlarr. -
- - { - isPopulated && !error && !!update && -
- { - !update.changes && -
- {translate('MaintenanceRelease')} -
- } - - { - !!update.changes && -
-
- What's new? -
- - - - -
- } -
- } - - { - !isPopulated && !error && - - } -
- - - - - - -
- ); -} - -AppUpdatedModalContent.propTypes = { - version: PropTypes.string.isRequired, - prevVersion: PropTypes.string, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - onSeeChangesPress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default AppUpdatedModalContent; diff --git a/frontend/src/App/AppUpdatedModalContent.tsx b/frontend/src/App/AppUpdatedModalContent.tsx new file mode 100644 index 000000000..0bd5df6d3 --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContent.tsx @@ -0,0 +1,145 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { kinds } from 'Helpers/Props'; +import { fetchUpdates } from 'Store/Actions/systemActions'; +import UpdateChanges from 'System/Updates/UpdateChanges'; +import Update from 'typings/Update'; +import translate from 'Utilities/String/translate'; +import AppState from './State/AppState'; +import styles from './AppUpdatedModalContent.css'; + +function mergeUpdates(items: Update[], version: string, prevVersion?: string) { + let installedIndex = items.findIndex((u) => u.version === version); + let installedPreviouslyIndex = items.findIndex( + (u) => u.version === prevVersion + ); + + if (installedIndex === -1) { + installedIndex = 0; + } + + if (installedPreviouslyIndex === -1) { + installedPreviouslyIndex = items.length; + } else if (installedPreviouslyIndex === installedIndex && items.length) { + installedPreviouslyIndex += 1; + } + + const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex); + + if (!appliedUpdates.length) { + return null; + } + + const appliedChanges: Update['changes'] = { new: [], fixed: [] }; + + appliedUpdates.forEach((u: Update) => { + if (u.changes) { + appliedChanges.new.push(...u.changes.new); + appliedChanges.fixed.push(...u.changes.fixed); + } + }); + + const mergedUpdate: Update = Object.assign({}, appliedUpdates[0], { + changes: appliedChanges, + }); + + if (!appliedChanges.new.length && !appliedChanges.fixed.length) { + mergedUpdate.changes = null; + } + + return mergedUpdate; +} + +interface AppUpdatedModalContentProps { + onModalClose: () => void; +} + +function AppUpdatedModalContent(props: AppUpdatedModalContentProps) { + const dispatch = useDispatch(); + const { version, prevVersion } = useSelector((state: AppState) => state.app); + const { isPopulated, error, items } = useSelector( + (state: AppState) => state.system.updates + ); + const previousVersion = usePrevious(version); + + const { onModalClose } = props; + + const update = mergeUpdates(items, version, prevVersion); + + const handleSeeChangesPress = useCallback(() => { + window.location.href = `${window.Prowlarr.urlBase}/system/updates`; + }, []); + + useEffect(() => { + dispatch(fetchUpdates()); + }, [dispatch]); + + useEffect(() => { + if (version !== previousVersion) { + dispatch(fetchUpdates()); + } + }, [version, previousVersion, dispatch]); + + return ( + + {translate('AppUpdated')} + + +
+ +
+ + {isPopulated && !error && !!update ? ( +
+ {update.changes ? ( +
+ {translate('MaintenanceRelease')} +
+ ) : null} + + {update.changes ? ( +
+
{translate('WhatsNew')}
+ + + + +
+ ) : null} +
+ ) : null} + + {!isPopulated && !error ? : null} +
+ + + + + + +
+ ); +} + +export default AppUpdatedModalContent; diff --git a/frontend/src/App/AppUpdatedModalContentConnector.js b/frontend/src/App/AppUpdatedModalContentConnector.js deleted file mode 100644 index 97dd0aeb9..000000000 --- a/frontend/src/App/AppUpdatedModalContentConnector.js +++ /dev/null @@ -1,78 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchUpdates } from 'Store/Actions/systemActions'; -import AppUpdatedModalContent from './AppUpdatedModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.app.version, - (state) => state.app.prevVersion, - (state) => state.system.updates, - (version, prevVersion, updates) => { - const { - isPopulated, - error, - items - } = updates; - - return { - version, - prevVersion, - isPopulated, - error, - items - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - dispatchFetchUpdates() { - dispatch(fetchUpdates()); - }, - - onSeeChangesPress() { - window.location = `${window.Prowlarr.urlBase}/system/updates`; - } - }; -} - -class AppUpdatedModalContentConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.dispatchFetchUpdates(); - } - - componentDidUpdate(prevProps) { - if (prevProps.version !== this.props.version) { - this.props.dispatchFetchUpdates(); - } - } - - // - // Render - - render() { - const { - dispatchFetchUpdates, - ...otherProps - } = this.props; - - return ( - - ); - } -} - -AppUpdatedModalContentConnector.propTypes = { - version: PropTypes.string.isRequired, - dispatchFetchUpdates: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModalContentConnector); diff --git a/frontend/src/App/ApplyTheme.js b/frontend/src/App/ApplyTheme.js deleted file mode 100644 index bd4d6a6c8..000000000 --- a/frontend/src/App/ApplyTheme.js +++ /dev/null @@ -1,50 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Fragment, useCallback, useEffect } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import themes from 'Styles/Themes'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.ui.item.theme || window.Prowlarr.theme, - ( - theme - ) => { - return { - theme - }; - } - ); -} - -function ApplyTheme({ theme, children }) { - // Update the CSS Variables - - const updateCSSVariables = useCallback(() => { - const arrayOfVariableKeys = Object.keys(themes[theme]); - const arrayOfVariableValues = Object.values(themes[theme]); - - // Loop through each array key and set the CSS Variables - arrayOfVariableKeys.forEach((cssVariableKey, index) => { - // Based on our snippet from MDN - document.documentElement.style.setProperty( - `--${cssVariableKey}`, - arrayOfVariableValues[index] - ); - }); - }, [theme]); - - // On Component Mount and Component Update - useEffect(() => { - updateCSSVariables(theme); - }, [updateCSSVariables, theme]); - - return {children}; -} - -ApplyTheme.propTypes = { - theme: PropTypes.string.isRequired, - children: PropTypes.object.isRequired -}; - -export default connect(createMapStateToProps)(ApplyTheme); diff --git a/frontend/src/App/ApplyTheme.tsx b/frontend/src/App/ApplyTheme.tsx new file mode 100644 index 000000000..ec9cd037f --- /dev/null +++ b/frontend/src/App/ApplyTheme.tsx @@ -0,0 +1,33 @@ +import { useCallback, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import themes from 'Styles/Themes'; +import AppState from './State/AppState'; + +function createThemeSelector() { + return createSelector( + (state: AppState) => state.settings.ui.item.theme || window.Prowlarr.theme, + (theme) => { + return theme; + } + ); +} + +function ApplyTheme() { + const theme = useSelector(createThemeSelector()); + + const updateCSSVariables = useCallback(() => { + Object.entries(themes[theme]).forEach(([key, value]) => { + document.documentElement.style.setProperty(`--${key}`, value); + }); + }, [theme]); + + // On Component Mount and Component Update + useEffect(() => { + updateCSSVariables(); + }, [updateCSSVariables, theme]); + + return null; +} + +export default ApplyTheme; diff --git a/frontend/src/App/ColorImpairedContext.js b/frontend/src/App/ColorImpairedContext.ts similarity index 100% rename from frontend/src/App/ColorImpairedContext.js rename to frontend/src/App/ColorImpairedContext.ts diff --git a/frontend/src/App/ConnectionLostModal.js b/frontend/src/App/ConnectionLostModal.tsx similarity index 51% rename from frontend/src/App/ConnectionLostModal.js rename to frontend/src/App/ConnectionLostModal.tsx index 16adf78f5..f08f2c0e2 100644 --- a/frontend/src/App/ConnectionLostModal.js +++ b/frontend/src/App/ConnectionLostModal.tsx @@ -1,5 +1,4 @@ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useCallback } from 'react'; import Button from 'Components/Link/Button'; import Modal from 'Components/Modal/Modal'; import ModalBody from 'Components/Modal/ModalBody'; @@ -10,36 +9,31 @@ import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import styles from './ConnectionLostModal.css'; -function ConnectionLostModal(props) { - const { - isOpen, - onModalClose - } = props; +interface ConnectionLostModalProps { + isOpen: boolean; +} + +function ConnectionLostModal(props: ConnectionLostModalProps) { + const { isOpen } = props; + + const handleModalClose = useCallback(() => { + location.reload(); + }, []); return ( - - - - {translate('ConnectionLost')} - + + + {translate('ConnectionLost')} -
- {translate('ConnectionLostMessage')} -
+
{translate('ConnectionLostToBackend')}
- {translate('ConnectionLostAutomaticMessage')} + {translate('ConnectionLostReconnect')}
- @@ -48,9 +42,4 @@ function ConnectionLostModal(props) { ); } -ConnectionLostModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - export default ConnectionLostModal; diff --git a/frontend/src/App/ConnectionLostModalConnector.js b/frontend/src/App/ConnectionLostModalConnector.js deleted file mode 100644 index 8ab8e3cd0..000000000 --- a/frontend/src/App/ConnectionLostModalConnector.js +++ /dev/null @@ -1,12 +0,0 @@ -import { connect } from 'react-redux'; -import ConnectionLostModal from './ConnectionLostModal'; - -function createMapDispatchToProps(dispatch, props) { - return { - onModalClose() { - location.reload(); - } - }; -} - -export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal); diff --git a/frontend/src/App/State/AppSectionState.ts b/frontend/src/App/State/AppSectionState.ts index d511963fc..f89eb25f7 100644 --- a/frontend/src/App/State/AppSectionState.ts +++ b/frontend/src/App/State/AppSectionState.ts @@ -1,4 +1,6 @@ +import Column from 'Components/Table/Column'; import SortDirection from 'Helpers/Props/SortDirection'; +import { FilterBuilderProp, PropertyFilter } from './AppState'; export interface Error { responseJSON: { @@ -17,7 +19,19 @@ export interface AppSectionSaveState { } export interface PagedAppSectionState { + page: number; pageSize: number; + totalPages: number; + totalRecords?: number; +} +export interface TableAppSectionState { + columns: Column[]; +} + +export interface AppSectionFilterState { + selectedFilterKey: string; + filters: PropertyFilter[]; + filterBuilderProps: FilterBuilderProp[]; } export interface AppSectionSchemaState { @@ -33,6 +47,7 @@ export interface AppSectionItemState { isFetching: boolean; isPopulated: boolean; error: Error; + pendingChanges: Partial; item: T; } diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 6e868cce4..0f0e82c0d 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,5 +1,13 @@ -import IndexerAppState from './IndexerAppState'; +import CommandAppState from './CommandAppState'; +import HistoryAppState from './HistoryAppState'; +import IndexerAppState, { + IndexerHistoryAppState, + IndexerIndexAppState, + IndexerStatusAppState, +} from './IndexerAppState'; +import IndexerStatsAppState from './IndexerStatsAppState'; import SettingsAppState from './SettingsAppState'; +import SystemAppState from './SystemAppState'; import TagsAppState from './TagsAppState'; interface FilterBuilderPropOption { @@ -34,9 +42,29 @@ export interface CustomFilter { filers: PropertyFilter[]; } +export interface AppSectionState { + isConnected: boolean; + isReconnecting: boolean; + version: string; + prevVersion?: string; + dimensions: { + isSmallScreen: boolean; + width: number; + height: number; + }; +} + interface AppState { + app: AppSectionState; + commands: CommandAppState; + history: HistoryAppState; + indexerHistory: IndexerHistoryAppState; + indexerIndex: IndexerIndexAppState; + indexerStats: IndexerStatsAppState; + indexerStatus: IndexerStatusAppState; indexers: IndexerAppState; settings: SettingsAppState; + system: SystemAppState; tags: TagsAppState; } diff --git a/frontend/src/App/State/ClientSideCollectionAppState.ts b/frontend/src/App/State/ClientSideCollectionAppState.ts new file mode 100644 index 000000000..f4110ef73 --- /dev/null +++ b/frontend/src/App/State/ClientSideCollectionAppState.ts @@ -0,0 +1,8 @@ +import { CustomFilter } from './AppState'; + +interface ClientSideCollectionAppState { + totalItems: number; + customFilters: CustomFilter[]; +} + +export default ClientSideCollectionAppState; diff --git a/frontend/src/App/State/CommandAppState.ts b/frontend/src/App/State/CommandAppState.ts new file mode 100644 index 000000000..1bde37371 --- /dev/null +++ b/frontend/src/App/State/CommandAppState.ts @@ -0,0 +1,6 @@ +import AppSectionState from 'App/State/AppSectionState'; +import Command from 'Commands/Command'; + +export type CommandAppState = AppSectionState; + +export default CommandAppState; diff --git a/frontend/src/App/State/HistoryAppState.ts b/frontend/src/App/State/HistoryAppState.ts new file mode 100644 index 000000000..3bb0e85f5 --- /dev/null +++ b/frontend/src/App/State/HistoryAppState.ts @@ -0,0 +1,14 @@ +import AppSectionState, { + AppSectionFilterState, +} from 'App/State/AppSectionState'; +import Column from 'Components/Table/Column'; +import History from 'typings/History'; + +interface HistoryAppState + extends AppSectionState, + AppSectionFilterState { + pageSize: number; + columns: Column[]; +} + +export default HistoryAppState; diff --git a/frontend/src/App/State/IndexerAppState.ts b/frontend/src/App/State/IndexerAppState.ts index ad7e778e6..4c0145d0d 100644 --- a/frontend/src/App/State/IndexerAppState.ts +++ b/frontend/src/App/State/IndexerAppState.ts @@ -1,12 +1,42 @@ -import Indexer from 'typings/Indexer'; +import Column from 'Components/Table/Column'; +import SortDirection from 'Helpers/Props/SortDirection'; +import Indexer, { IndexerStatus } from 'Indexer/Indexer'; +import History from 'typings/History'; import AppSectionState, { AppSectionDeleteState, AppSectionSaveState, } from './AppSectionState'; +import { Filter, FilterBuilderProp } from './AppState'; + +export interface IndexerIndexAppState { + isTestingAll: boolean; + sortKey: string; + sortDirection: SortDirection; + secondarySortKey: string; + secondarySortDirection: SortDirection; + view: string; + + tableOptions: { + showSearchAction: boolean; + }; + + selectedFilterKey: string; + filterBuilderProps: FilterBuilderProp[]; + filters: Filter[]; + columns: Column[]; +} interface IndexerAppState extends AppSectionState, AppSectionDeleteState, - AppSectionSaveState {} + AppSectionSaveState { + itemMap: Record; + + isTestingAll: boolean; +} + +export type IndexerStatusAppState = AppSectionState; + +export type IndexerHistoryAppState = AppSectionState; export default IndexerAppState; diff --git a/frontend/src/App/State/IndexerStatsAppState.ts b/frontend/src/App/State/IndexerStatsAppState.ts new file mode 100644 index 000000000..8d3ae660a --- /dev/null +++ b/frontend/src/App/State/IndexerStatsAppState.ts @@ -0,0 +1,13 @@ +import { AppSectionItemState } from 'App/State/AppSectionState'; +import { Filter, FilterBuilderProp } from 'App/State/AppState'; +import Indexer from 'Indexer/Indexer'; +import { IndexerStats } from 'typings/IndexerStats'; + +export interface IndexerStatsAppState + extends AppSectionItemState { + filterBuilderProps: FilterBuilderProp[]; + selectedFilterKey: string; + filters: Filter[]; +} + +export default IndexerStatsAppState; diff --git a/frontend/src/App/State/ReleaseAppState.ts b/frontend/src/App/State/ReleaseAppState.ts new file mode 100644 index 000000000..325a429fa --- /dev/null +++ b/frontend/src/App/State/ReleaseAppState.ts @@ -0,0 +1,10 @@ +import AppSectionState, { + AppSectionDeleteState, +} from 'App/State/AppSectionState'; +import Release from 'typings/Release'; + +interface ReleaseAppState + extends AppSectionState, + AppSectionDeleteState {} + +export default ReleaseAppState; diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index bded59754..33c6c936d 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -1,19 +1,40 @@ import AppSectionState, { AppSectionDeleteState, + AppSectionItemState, AppSectionSaveState, } from 'App/State/AppSectionState'; +import { IndexerCategory } from 'Indexer/Indexer'; import Application from 'typings/Application'; import DownloadClient from 'typings/DownloadClient'; import Notification from 'typings/Notification'; -import { UiSettings } from 'typings/UiSettings'; +import General from 'typings/Settings/General'; +import UiSettings from 'typings/Settings/UiSettings'; -export interface ApplicationAppState +export interface AppProfileAppState extends AppSectionState, AppSectionDeleteState, AppSectionSaveState {} +export interface ApplicationAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState { + isTestingAll: boolean; +} + export interface DownloadClientAppState extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState { + isTestingAll: boolean; +} + +export interface GeneralAppState + extends AppSectionItemState, + AppSectionSaveState {} + +export interface IndexerCategoryAppState + extends AppSectionState, AppSectionDeleteState, AppSectionSaveState {} @@ -21,13 +42,16 @@ export interface NotificationAppState extends AppSectionState, AppSectionDeleteState {} -export type UiSettingsAppState = AppSectionState; +export type UiSettingsAppState = AppSectionItemState; interface SettingsAppState { + appProfiles: AppProfileAppState; applications: ApplicationAppState; downloadClients: DownloadClientAppState; + general: GeneralAppState; + indexerCategories: IndexerCategoryAppState; notifications: NotificationAppState; - uiSettings: UiSettingsAppState; + ui: UiSettingsAppState; } export default SettingsAppState; diff --git a/frontend/src/App/State/SystemAppState.ts b/frontend/src/App/State/SystemAppState.ts new file mode 100644 index 000000000..8bc1b03e2 --- /dev/null +++ b/frontend/src/App/State/SystemAppState.ts @@ -0,0 +1,19 @@ +import Health from 'typings/Health'; +import SystemStatus from 'typings/SystemStatus'; +import Task from 'typings/Task'; +import Update from 'typings/Update'; +import AppSectionState, { AppSectionItemState } from './AppSectionState'; + +export type HealthAppState = AppSectionState; +export type SystemStatusAppState = AppSectionItemState; +export type TaskAppState = AppSectionState; +export type UpdateAppState = AppSectionState; + +interface SystemAppState { + health: HealthAppState; + status: SystemStatusAppState; + tasks: TaskAppState; + updates: UpdateAppState; +} + +export default SystemAppState; diff --git a/frontend/src/App/State/TagsAppState.ts b/frontend/src/App/State/TagsAppState.ts index d1f1d5a2f..53a0d847f 100644 --- a/frontend/src/App/State/TagsAppState.ts +++ b/frontend/src/App/State/TagsAppState.ts @@ -1,12 +1,28 @@ import ModelBase from 'App/ModelBase'; import AppSectionState, { AppSectionDeleteState, + AppSectionSaveState, } from 'App/State/AppSectionState'; export interface Tag extends ModelBase { label: string; } -interface TagsAppState extends AppSectionState, AppSectionDeleteState {} +export interface TagDetail extends ModelBase { + label: string; + applicationIds: number[]; + indexerIds: number[]; + indexerProxyIds: number[]; + notificationIds: number[]; +} + +export interface TagDetailAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +interface TagsAppState extends AppSectionState, AppSectionDeleteState { + details: TagDetailAppState; +} export default TagsAppState; diff --git a/frontend/src/Commands/Command.ts b/frontend/src/Commands/Command.ts new file mode 100644 index 000000000..b9b31bf63 --- /dev/null +++ b/frontend/src/Commands/Command.ts @@ -0,0 +1,36 @@ +import ModelBase from 'App/ModelBase'; + +export interface CommandBody { + sendUpdatesToClient: boolean; + updateScheduledTask: boolean; + completionMessage: string; + requiresDiskAccess: boolean; + isExclusive: boolean; + isLongRunning: boolean; + name: string; + lastExecutionTime: string; + lastStartTime: string; + trigger: string; + suppressMessages: boolean; +} + +interface Command extends ModelBase { + name: string; + commandName: string; + message: string; + body: CommandBody; + priority: string; + status: string; + result: string; + queued: string; + started: string; + ended: string; + duration: string; + trigger: string; + stateChangeTime: string; + sendUpdatesToClient: boolean; + updateScheduledTask: boolean; + lastExecutionTime: string; +} + +export default Command; diff --git a/frontend/src/Components/Chart/BarChart.js b/frontend/src/Components/Chart/BarChart.js index b9d7f0acc..83176c989 100644 --- a/frontend/src/Components/Chart/BarChart.js +++ b/frontend/src/Components/Chart/BarChart.js @@ -2,6 +2,7 @@ import Chart from 'chart.js/auto'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { kinds } from 'Helpers/Props'; +import { defaultFontFamily } from 'Styles/Variables/fonts'; function getColors(kind) { @@ -39,7 +40,15 @@ class BarChart extends Component { plugins: { title: { display: true, - text: this.props.title + align: 'start', + text: this.props.title, + padding: { + bottom: 30 + }, + font: { + size: 14, + family: defaultFontFamily + } }, legend: { display: this.props.legend diff --git a/frontend/src/Components/Chart/DoughnutChart.js b/frontend/src/Components/Chart/DoughnutChart.js index dd5052e23..d10979aa1 100644 --- a/frontend/src/Components/Chart/DoughnutChart.js +++ b/frontend/src/Components/Chart/DoughnutChart.js @@ -1,6 +1,7 @@ import Chart from 'chart.js/auto'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import { defaultFontFamily } from 'Styles/Variables/fonts'; function getColors(kind) { @@ -22,7 +23,15 @@ class DoughnutChart extends Component { plugins: { title: { display: true, - text: this.props.title + align: 'start', + text: this.props.title, + padding: { + bottom: 30 + }, + font: { + size: 14, + family: defaultFontFamily + } }, legend: { position: 'bottom' diff --git a/frontend/src/Components/Chart/StackedBarChart.js b/frontend/src/Components/Chart/StackedBarChart.js index d6e4879d2..b69fd8e03 100644 --- a/frontend/src/Components/Chart/StackedBarChart.js +++ b/frontend/src/Components/Chart/StackedBarChart.js @@ -1,6 +1,7 @@ import Chart from 'chart.js/auto'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import { defaultFontFamily } from 'Styles/Variables/fonts'; function getColors(index) { @@ -36,7 +37,19 @@ class StackedBarChart extends Component { plugins: { title: { display: true, - text: this.props.title + align: 'start', + text: this.props.title, + padding: { + bottom: 30 + }, + font: { + size: 14, + family: defaultFontFamily + } + }, + tooltip: { + mode: 'index', + position: 'average' } } }, diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.js b/frontend/src/Components/DescriptionList/DescriptionListItem.js index aac01a6b5..931557045 100644 --- a/frontend/src/Components/DescriptionList/DescriptionListItem.js +++ b/frontend/src/Components/DescriptionList/DescriptionListItem.js @@ -10,6 +10,7 @@ class DescriptionListItem extends Component { render() { const { + className, titleClassName, descriptionClassName, title, @@ -17,7 +18,7 @@ class DescriptionListItem extends Component { } = this.props; return ( -
+
@@ -35,6 +36,7 @@ class DescriptionListItem extends Component { } DescriptionListItem.propTypes = { + className: PropTypes.string, titleClassName: PropTypes.string, descriptionClassName: PropTypes.string, title: PropTypes.string, diff --git a/frontend/src/Components/Error/ErrorBoundaryError.tsx b/frontend/src/Components/Error/ErrorBoundaryError.tsx index b3db237b1..51d286311 100644 --- a/frontend/src/Components/Error/ErrorBoundaryError.tsx +++ b/frontend/src/Components/Error/ErrorBoundaryError.tsx @@ -23,7 +23,9 @@ function ErrorBoundaryError(props: ErrorBoundaryErrorProps) { info, } = props; - const [detailedError, setDetailedError] = useState(null); + const [detailedError, setDetailedError] = useState< + StackTrace.StackFrame[] | null + >(null); useEffect(() => { if (error) { @@ -61,11 +63,7 @@ function ErrorBoundaryError(props: ErrorBoundaryErrorProps) {
{info.componentStack}
)} - { -
- Version: {window.Prowlarr.version} -
- } +
Version: {window.Prowlarr.version}
); diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js index fd2ff8afa..dfb720003 100644 --- a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js @@ -20,12 +20,12 @@ import styles from './FileBrowserModalContent.css'; const columns = [ { name: 'type', - label: translate('Type'), + label: () => translate('Type'), isVisible: true }, { name: 'name', - label: translate('Name'), + label: () => translate('Name'), isVisible: true } ]; diff --git a/frontend/src/Components/Filter/Builder/CategoryFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/CategoryFilterBuilderRowValue.tsx new file mode 100644 index 000000000..6a7dddcfc --- /dev/null +++ b/frontend/src/Components/Filter/Builder/CategoryFilterBuilderRowValue.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import { IndexerCategory } from 'Indexer/Indexer'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; +import FilterBuilderRowValueProps from './FilterBuilderRowValueProps'; + +const indexerCategoriesSelector = createSelector( + (state: AppState) => state.settings.indexerCategories, + (categories) => categories.items +); + +function CategoryFilterBuilderRowValue(props: FilterBuilderRowValueProps) { + const categories: IndexerCategory[] = useSelector(indexerCategoriesSelector); + + const tagList = categories.reduce( + (acc: { id: number; name: string }[], element) => { + acc.push({ + id: element.id, + name: `${element.name} (${element.id})`, + }); + + if (element.subCategories && element.subCategories.length > 0) { + element.subCategories.forEach((subCat) => { + acc.push({ + id: subCat.id, + name: `${subCat.name} (${subCat.id})`, + }); + }); + } + + return acc; + }, + [] + ); + + return ; +} + +export default CategoryFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js index 033b9a69a..0c4a31657 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js @@ -1,3 +1,4 @@ +import { maxBy } from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import FormInputGroup from 'Components/Form/FormInputGroup'; @@ -50,7 +51,7 @@ class FilterBuilderModalContent extends Component { if (id) { dispatchSetFilter({ selectedFilterKey: id }); } else { - const last = customFilters[customFilters.length -1]; + const last = maxBy(customFilters, 'id'); dispatchSetFilter({ selectedFilterKey: last.id }); } @@ -108,7 +109,7 @@ class FilterBuilderModalContent extends Component { this.setState({ labelErrors: [ { - message: 'Label is required' + message: translate('LabelIsRequired') } ] }); @@ -146,13 +147,13 @@ class FilterBuilderModalContent extends Component { return ( - Custom Filter + {translate('CustomFilter')}
- Label + {translate('Label')}
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js index ed375b745..b02844c61 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -3,10 +3,13 @@ import React, { Component } from 'react'; import SelectInput from 'Components/Form/SelectInput'; import IconButton from 'Components/Link/IconButton'; import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props'; +import sortByProp from 'Utilities/Array/sortByProp'; import AppProfileFilterBuilderRowValueConnector from './AppProfileFilterBuilderRowValueConnector'; import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue'; +import CategoryFilterBuilderRowValue from './CategoryFilterBuilderRowValue'; import DateFilterBuilderRowValue from './DateFilterBuilderRowValue'; import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector'; +import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue'; import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector'; import PrivacyFilterBuilderRowValue from './PrivacyFilterBuilderRowValue'; import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; @@ -55,9 +58,15 @@ function getRowValueConnector(selectedFilterBuilderProp) { case filterBuilderValueTypes.BOOL: return BoolFilterBuilderRowValue; + case filterBuilderValueTypes.CATEGORY: + return CategoryFilterBuilderRowValue; + case filterBuilderValueTypes.DATE: return DateFilterBuilderRowValue; + case filterBuilderValueTypes.HISTORY_EVENT_TYPE: + return HistoryEventTypeFilterBuilderRowValue; + case filterBuilderValueTypes.INDEXER: return IndexerFilterBuilderRowValueConnector; @@ -198,11 +207,13 @@ class FilterBuilderRow extends Component { const selectedFilterBuilderProp = this.selectedFilterBuilderProp; const keyOptions = filterBuilderProps.map((availablePropFilter) => { + const { name, label } = availablePropFilter; + return { - key: availablePropFilter.name, - value: availablePropFilter.label + key: name, + value: typeof label === 'function' ? label() : label }; - }); + }).sort(sortByProp('value')); const ValueComponent = getRowValueConnector(selectedFilterBuilderProp); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js index a7aed80b6..d1419327a 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { filterBuilderTypes } from 'Helpers/Props'; import * as filterTypes from 'Helpers/Props/filterTypes'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import FilterBuilderRowValue from './FilterBuilderRowValue'; function createTagListSelector() { @@ -38,7 +38,7 @@ function createTagListSelector() { } return acc; - }, []).sort(sortByName); + }, []).sort(sortByProp('name')); } return _.uniqBy(items, 'id'); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueProps.ts b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueProps.ts new file mode 100644 index 000000000..5bf9e5785 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueProps.ts @@ -0,0 +1,16 @@ +import { FilterBuilderProp } from 'App/State/AppState'; + +interface FilterBuilderRowOnChangeProps { + name: string; + value: unknown[]; +} + +interface FilterBuilderRowValueProps { + filterType?: string; + filterValue: string | number | object | string[] | number[] | object[]; + selectedFilterBuilderProp: FilterBuilderProp; + sectionItem: unknown[]; + onChange: (payload: FilterBuilderRowOnChangeProps) => void; +} + +export default FilterBuilderRowValueProps; diff --git a/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx new file mode 100644 index 000000000..03c5f7227 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import translate from 'Utilities/String/translate'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; +import FilterBuilderRowValueProps from './FilterBuilderRowValueProps'; + +const EVENT_TYPE_OPTIONS = [ + { + id: 1, + get name() { + return translate('Grabbed'); + }, + }, + { + id: 3, + get name() { + return translate('IndexerRss'); + }, + }, + { + id: 2, + get name() { + return translate('IndexerQuery'); + }, + }, + { + id: 4, + get name() { + return translate('IndexerAuth'); + }, + }, +]; + +function HistoryEventTypeFilterBuilderRowValue( + props: FilterBuilderRowValueProps +) { + return ; +} + +export default HistoryEventTypeFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/PrivacyFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/PrivacyFilterBuilderRowValue.js index 4004f0ced..4f6250151 100644 --- a/frontend/src/Components/Filter/Builder/PrivacyFilterBuilderRowValue.js +++ b/frontend/src/Components/Filter/Builder/PrivacyFilterBuilderRowValue.js @@ -3,9 +3,24 @@ import translate from 'Utilities/String/translate'; import FilterBuilderRowValue from './FilterBuilderRowValue'; const privacyTypes = [ - { id: 'public', name: translate('Public') }, - { id: 'private', name: translate('Private') }, - { id: 'semiPrivate', name: translate('SemiPrivate') } + { + id: 'public', + get name() { + return translate('Public'); + } + }, + { + id: 'private', + get name() { + return translate('Private'); + } + }, + { + id: 'semiPrivate', + get name() { + return translate('SemiPrivate'); + } + } ]; function PrivacyFilterBuilderRowValue(props) { diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js index 7407f729a..9f378d5a2 100644 --- a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js @@ -37,8 +37,8 @@ class CustomFilter extends Component { dispatchSetFilter } = this.props; - // Assume that delete and then unmounting means the delete was successful. - // Moving this check to a ancestor would be more accurate, but would have + // Assume that delete and then unmounting means the deletion was successful. + // Moving this check to an ancestor would be more accurate, but would have // more boilerplate. if (this.state.isDeleting && id === selectedFilterKey) { dispatchSetFilter({ selectedFilterKey: 'all' }); diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js index 07660426e..99cb6ec5c 100644 --- a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js @@ -5,6 +5,7 @@ import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; +import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import CustomFilter from './CustomFilter'; import styles from './CustomFiltersModalContent.css'; @@ -30,22 +31,24 @@ function CustomFiltersModalContent(props) { { - customFilters.map((customFilter) => { - return ( - - ); - }) + customFilters + .sort((a, b) => sortByProp(a, b, 'label')) + .map((customFilter) => { + return ( + + ); + }) }
diff --git a/frontend/src/Components/Form/AppProfileSelectInputConnector.js b/frontend/src/Components/Form/AppProfileSelectInputConnector.js index 1aef10c30..0ab181e2f 100644 --- a/frontend/src/Components/Form/AppProfileSelectInputConnector.js +++ b/frontend/src/Components/Form/AppProfileSelectInputConnector.js @@ -4,13 +4,13 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import SelectInput from './SelectInput'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.appProfiles', sortByName), + createSortedSectionSelector('settings.appProfiles', sortByProp('name')), (state, { includeNoChange }) => includeNoChange, (state, { includeMixed }) => includeMixed, (appProfiles, includeNoChange, includeMixed) => { @@ -24,16 +24,20 @@ function createMapStateToProps() { if (includeNoChange) { values.unshift({ key: 'noChange', - value: translate('NoChange'), - disabled: true + get value() { + return translate('NoChange'); + }, + isDisabled: true }); } if (includeMixed) { values.unshift({ key: 'mixed', - value: '(Mixed)', - disabled: true + get value() { + return `(${translate('Mixed')})`; + }, + isDisabled: true }); } diff --git a/frontend/src/Components/Form/AvailabilitySelectInput.js b/frontend/src/Components/Form/AvailabilitySelectInput.js deleted file mode 100644 index af9bdb2d6..000000000 --- a/frontend/src/Components/Form/AvailabilitySelectInput.js +++ /dev/null @@ -1,54 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import SelectInput from './SelectInput'; - -const availabilityOptions = [ - { key: 'announced', value: 'Announced' }, - { key: 'inCinemas', value: 'In Cinemas' }, - { key: 'released', value: 'Released' }, - { key: 'preDB', value: 'PreDB' } -]; - -function AvailabilitySelectInput(props) { - const values = [...availabilityOptions]; - - const { - includeNoChange, - includeMixed - } = props; - - if (includeNoChange) { - values.unshift({ - key: 'noChange', - value: 'No Change', - disabled: true - }); - } - - if (includeMixed) { - values.unshift({ - key: 'mixed', - value: '(Mixed)', - disabled: true - }); - } - - return ( - - ); -} - -AvailabilitySelectInput.propTypes = { - includeNoChange: PropTypes.bool.isRequired, - includeMixed: PropTypes.bool.isRequired -}; - -AvailabilitySelectInput.defaultProps = { - includeNoChange: false, - includeMixed: false -}; - -export default AvailabilitySelectInput; diff --git a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js index 162c79885..9cf7a429a 100644 --- a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js +++ b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js @@ -3,7 +3,8 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { fetchDownloadClients } from 'Store/Actions/settingsActions'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; +import translate from 'Utilities/String/translate'; import EnhancedSelectInput from './EnhancedSelectInput'; function createMapStateToProps() { @@ -21,16 +22,17 @@ function createMapStateToProps() { const values = items .filter((downloadClient) => downloadClient.protocol === protocolFilter) - .sort(sortByName) + .sort(sortByProp('name')) .map((downloadClient) => ({ key: downloadClient.id, - value: downloadClient.name + value: downloadClient.name, + hint: `(${downloadClient.id})` })); if (includeAny) { values.unshift({ key: 0, - value: '(Any)' + value: `(${translate('Any')})` }); } diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js index cc4215025..79b1c999c 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.js +++ b/frontend/src/Components/Form/EnhancedSelectInput.js @@ -20,6 +20,8 @@ import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue'; import TextInput from './TextInput'; import styles from './EnhancedSelectInput.css'; +const MINIMUM_DISTANCE_FROM_EDGE = 10; + function isArrowKey(keyCode) { return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW; } @@ -137,18 +139,9 @@ class EnhancedSelectInput extends Component { // Listeners onComputeMaxHeight = (data) => { - const { - top, - bottom - } = data.offsets.reference; - const windowHeight = window.innerHeight; - if ((/^botton/).test(data.placement)) { - data.styles.maxHeight = windowHeight - bottom; - } else { - data.styles.maxHeight = top; - } + data.styles.maxHeight = windowHeight - MINIMUM_DISTANCE_FROM_EDGE; return data; }; @@ -271,26 +264,29 @@ class EnhancedSelectInput extends Component { this.setState({ isOpen: !this.state.isOpen }); }; - onSelect = (value) => { - if (Array.isArray(this.props.value)) { - let newValue = null; - const index = this.props.value.indexOf(value); + onSelect = (newValue) => { + const { name, value, values, onChange } = this.props; + + if (Array.isArray(value)) { + let arrayValue = null; + const index = value.indexOf(newValue); + if (index === -1) { - newValue = this.props.values.map((v) => v.key).filter((v) => (v === value) || this.props.value.includes(v)); + arrayValue = values.map((v) => v.key).filter((v) => (v === newValue) || value.includes(v)); } else { - newValue = [...this.props.value]; - newValue.splice(index, 1); + arrayValue = [...value]; + arrayValue.splice(index, 1); } - this.props.onChange({ - name: this.props.name, - value: newValue + onChange({ + name, + value: arrayValue }); } else { this.setState({ isOpen: false }); - this.props.onChange({ - name: this.props.name, - value + onChange({ + name, + value: newValue }); } }; @@ -457,6 +453,10 @@ class EnhancedSelectInput extends Component { order: 851, enabled: true, fn: this.onComputeMaxHeight + }, + preventOverflow: { + enabled: true, + boundariesElement: 'viewport' } }} > @@ -485,7 +485,7 @@ class EnhancedSelectInput extends Component { values.map((v, index) => { const hasParent = v.parentKey !== undefined; const depth = hasParent ? 1 : 0; - const parentSelected = hasParent && value.includes(v.parentKey); + const parentSelected = hasParent && Array.isArray(value) && value.includes(v.parentKey); return ( {error.errorMessage} + + { + error.detailedDescription ? + } + tooltip={error.detailedDescription} + kind={kinds.INVERSE} + position={tooltipPositions.TOP} + /> : + null + } ); }) @@ -39,6 +53,18 @@ function Form(props) { kind={kinds.WARNING} > {warning.errorMessage} + + { + warning.detailedDescription ? + } + tooltip={warning.detailedDescription} + kind={kinds.INVERSE} + position={tooltipPositions.TOP} + /> : + null + } ); }) diff --git a/frontend/src/Components/Form/FormInputButton.js b/frontend/src/Components/Form/FormInputButton.js deleted file mode 100644 index a7145363a..000000000 --- a/frontend/src/Components/Form/FormInputButton.js +++ /dev/null @@ -1,54 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import Button from 'Components/Link/Button'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import { kinds } from 'Helpers/Props'; -import styles from './FormInputButton.css'; - -function FormInputButton(props) { - const { - className, - canSpin, - isLastButton, - ...otherProps - } = props; - - if (canSpin) { - return ( - - ); - } - - return ( -
- - diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js index 5ac032c0f..5c1f6f42e 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -3,7 +3,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { createSelector } from 'reselect'; -import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; +import { fetchTranslations, saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; import { fetchIndexers } from 'Store/Actions/indexerActions'; import { fetchIndexerStatus } from 'Store/Actions/indexerStatusActions'; @@ -54,6 +54,7 @@ const selectIsPopulated = createSelector( (state) => state.indexerStatus.isPopulated, (state) => state.settings.indexerCategories.isPopulated, (state) => state.system.status.isPopulated, + (state) => state.app.translations.isPopulated, ( customFiltersIsPopulated, tagsIsPopulated, @@ -63,7 +64,8 @@ const selectIsPopulated = createSelector( indexersIsPopulated, indexerStatusIsPopulated, indexerCategoriesIsPopulated, - systemStatusIsPopulated + systemStatusIsPopulated, + translationsIsPopulated ) => { return ( customFiltersIsPopulated && @@ -74,7 +76,8 @@ const selectIsPopulated = createSelector( indexersIsPopulated && indexerStatusIsPopulated && indexerCategoriesIsPopulated && - systemStatusIsPopulated + systemStatusIsPopulated && + translationsIsPopulated ); } ); @@ -89,6 +92,7 @@ const selectErrors = createSelector( (state) => state.indexerStatus.error, (state) => state.settings.indexerCategories.error, (state) => state.system.status.error, + (state) => state.app.translations.error, ( customFiltersError, tagsError, @@ -98,7 +102,8 @@ const selectErrors = createSelector( indexersError, indexerStatusError, indexerCategoriesError, - systemStatusError + systemStatusError, + translationsError ) => { const hasError = !!( customFiltersError || @@ -109,7 +114,8 @@ const selectErrors = createSelector( indexersError || indexerStatusError || indexerCategoriesError || - systemStatusError + systemStatusError || + translationsError ); return { @@ -122,7 +128,8 @@ const selectErrors = createSelector( indexersError, indexerStatusError, indexerCategoriesError, - systemStatusError + systemStatusError, + translationsError }; } ); @@ -184,6 +191,9 @@ function createMapDispatchToProps(dispatch, props) { dispatchFetchStatus() { dispatch(fetchStatus()); }, + dispatchFetchTranslations() { + dispatch(fetchTranslations()); + }, onResize(dimensions) { dispatch(saveDimensions(dimensions)); }, @@ -217,6 +227,7 @@ class PageConnector extends Component { this.props.dispatchFetchUISettings(); this.props.dispatchFetchGeneralSettings(); this.props.dispatchFetchStatus(); + this.props.dispatchFetchTranslations(); } } @@ -242,6 +253,7 @@ class PageConnector extends Component { dispatchFetchUISettings, dispatchFetchGeneralSettings, dispatchFetchStatus, + dispatchFetchTranslations, ...otherProps } = this.props; @@ -282,6 +294,7 @@ PageConnector.propTypes = { dispatchFetchUISettings: PropTypes.func.isRequired, dispatchFetchGeneralSettings: PropTypes.func.isRequired, dispatchFetchStatus: PropTypes.func.isRequired, + dispatchFetchTranslations: PropTypes.func.isRequired, onSidebarVisibleChange: PropTypes.func.isRequired }; diff --git a/frontend/src/Components/Page/PageContentBody.tsx b/frontend/src/Components/Page/PageContentBody.tsx index 75317f113..ce9b0e7e4 100644 --- a/frontend/src/Components/Page/PageContentBody.tsx +++ b/frontend/src/Components/Page/PageContentBody.tsx @@ -1,22 +1,19 @@ -import React, { forwardRef, ReactNode, useCallback } from 'react'; -import Scroller from 'Components/Scroller/Scroller'; +import React, { ForwardedRef, forwardRef, ReactNode, useCallback } from 'react'; +import Scroller, { OnScroll } from 'Components/Scroller/Scroller'; import ScrollDirection from 'Helpers/Props/ScrollDirection'; import { isLocked } from 'Utilities/scrollLock'; import styles from './PageContentBody.css'; interface PageContentBodyProps { - className: string; - innerClassName: string; + className?: string; + innerClassName?: string; children: ReactNode; initialScrollTop?: number; - onScroll?: (payload) => void; + onScroll?: (payload: OnScroll) => void; } const PageContentBody = forwardRef( - ( - props: PageContentBodyProps, - ref: React.MutableRefObject - ) => { + (props: PageContentBodyProps, ref: ForwardedRef) => { const { className = styles.contentBody, innerClassName = styles.innerContentBody, @@ -26,7 +23,7 @@ const PageContentBody = forwardRef( } = props; const onScrollWrapper = useCallback( - (payload) => { + (payload: OnScroll) => { if (onScroll && !isLocked()) { onScroll(payload); } diff --git a/frontend/src/Components/Page/PageJumpBar.css b/frontend/src/Components/Page/PageJumpBar.css index 9a116fb54..f5ae7a729 100644 --- a/frontend/src/Components/Page/PageJumpBar.css +++ b/frontend/src/Components/Page/PageJumpBar.css @@ -1,4 +1,5 @@ .jumpBar { + z-index: $pageJumpBarZIndex; display: flex; align-content: stretch; align-items: stretch; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js index 045789075..6eef54eab 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebar.js +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -8,7 +8,7 @@ import Scroller from 'Components/Scroller/Scroller'; import { icons } from 'Helpers/Props'; import locationShape from 'Helpers/Props/Shapes/locationShape'; import dimensions from 'Styles/Variables/dimensions'; -import HealthStatusConnector from 'System/Status/Health/HealthStatusConnector'; +import HealthStatus from 'System/Status/Health/HealthStatus'; import translate from 'Utilities/String/translate'; import MessagesConnector from './Messages/MessagesConnector'; import PageSidebarItem from './PageSidebarItem'; @@ -20,12 +20,12 @@ const SIDEBAR_WIDTH = parseInt(dimensions.sidebarWidth); const links = [ { iconName: icons.MOVIE_CONTINUING, - title: translate('Indexers'), + title: () => translate('Indexers'), to: '/', alias: '/indexers', children: [ { - title: translate('Stats'), + title: () => translate('Stats'), to: '/indexers/stats' } ] @@ -33,47 +33,47 @@ const links = [ { iconName: icons.SEARCH, - title: translate('Search'), + title: () => translate('Search'), to: '/search' }, { iconName: icons.ACTIVITY, - title: translate('History'), + title: () => translate('History'), to: '/history' }, { iconName: icons.SETTINGS, - title: translate('Settings'), + title: () => translate('Settings'), to: '/settings', children: [ { - title: translate('Indexers'), + title: () => translate('Indexers'), to: '/settings/indexers' }, { - title: translate('Apps'), + title: () => translate('Apps'), to: '/settings/applications' }, { - title: translate('DownloadClients'), + title: () => translate('DownloadClients'), to: '/settings/downloadclients' }, { - title: translate('Connect'), + title: () => translate('Connect'), to: '/settings/connect' }, { - title: translate('Tags'), + title: () => translate('Tags'), to: '/settings/tags' }, { - title: translate('General'), + title: () => translate('General'), to: '/settings/general' }, { - title: translate('UI'), + title: () => translate('UI'), to: '/settings/ui' } ] @@ -81,32 +81,32 @@ const links = [ { iconName: icons.SYSTEM, - title: translate('System'), + title: () => translate('System'), to: '/system/status', children: [ { - title: translate('Status'), + title: () => translate('Status'), to: '/system/status', - statusComponent: HealthStatusConnector + statusComponent: HealthStatus }, { - title: translate('Tasks'), + title: () => translate('Tasks'), to: '/system/tasks' }, { - title: translate('Backup'), + title: () => translate('Backup'), to: '/system/backup' }, { - title: translate('Updates'), + title: () => translate('Updates'), to: '/system/updates' }, { - title: translate('Events'), + title: () => translate('Events'), to: '/system/events' }, { - title: translate('LogFiles'), + title: () => translate('LogFiles'), to: '/system/logs/files' } ] diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.css b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css index 5e3e3b52c..409062f97 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebarItem.css +++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css @@ -24,6 +24,7 @@ composes: link; padding: 10px 24px; + padding-left: 35px; } .isActiveLink { @@ -41,10 +42,6 @@ text-align: center; } -.noIcon { - margin-left: 25px; -} - .status { float: right; } diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.css.d.ts b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css.d.ts index 77e23c767..5bf0eb815 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebarItem.css.d.ts +++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css.d.ts @@ -8,7 +8,6 @@ interface CssExports { 'isActiveParentLink': string; 'item': string; 'link': string; - 'noIcon': string; 'status': string; } export const cssExports: CssExports; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.js b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js index 9ad78db6b..8d0e4e790 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebarItem.js +++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js @@ -63,9 +63,7 @@ class PageSidebarItem extends Component { } - - {title} - + {typeof title === 'function' ? title() : title} { !!StatusComponent && @@ -88,7 +86,7 @@ class PageSidebarItem extends Component { PageSidebarItem.propTypes = { iconName: PropTypes.object, - title: PropTypes.string.isRequired, + title: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, to: PropTypes.string.isRequired, isActive: PropTypes.bool, isActiveParent: PropTypes.bool, diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.css b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css index 0b6918296..e9a1b666d 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarButton.css +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css @@ -22,11 +22,14 @@ display: flex; align-items: center; justify-content: center; + overflow: hidden; height: 24px; } .label { padding: 0 3px; + max-width: 100%; + max-height: 100%; color: var(--toolbarLabelColor); font-size: $extraSmallFontSize; line-height: calc($extraSmallFontSize + 1px); diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js index c93603aa9..675bdfd02 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js @@ -23,6 +23,7 @@ function PageToolbarButton(props) { isDisabled && styles.isDisabled )} isDisabled={isDisabled || isSpinning} + title={label} {...otherProps} > void; + onScroll?: (payload: OnScroll) => void; } const Scroller = forwardRef( - (props: ScrollerProps, ref: React.MutableRefObject) => { + (props: ScrollerProps, ref: ForwardedRef) => { const { className, autoFocus = false, @@ -30,7 +42,7 @@ const Scroller = forwardRef( } = props; const internalRef = useRef(); - const currentRef = ref ?? internalRef; + const currentRef = (ref as MutableRefObject) ?? internalRef; useEffect( () => { diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js index 28c12df12..d39c05e10 100644 --- a/frontend/src/Components/SignalRConnector.js +++ b/frontend/src/Components/SignalRConnector.js @@ -141,6 +141,16 @@ class SignalRConnector extends Component { console.error(`signalR: Unable to find handler for ${name}`); }; + handleApplications = ({ action, resource }) => { + const section = 'settings.applications'; + + if (action === 'created' || action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: resource.id }); + } + }; + handleCommand = (body) => { if (body.action === 'sync') { this.props.dispatchFetchCommands(); @@ -150,8 +160,8 @@ class SignalRConnector extends Component { const resource = body.resource; const status = resource.status; - // Both sucessful and failed commands need to be - // completed, otherwise they spin until they timeout. + // Both successful and failed commands need to be + // completed, otherwise they spin until they time out. if (status === 'completed' || status === 'failed') { this.props.dispatchFinishCommand(resource); @@ -160,6 +170,16 @@ class SignalRConnector extends Component { } }; + handleDownloadclient = ({ action, resource }) => { + const section = 'settings.downloadClients'; + + if (action === 'created' || action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: resource.id }); + } + }; + handleHealth = () => { this.props.dispatchFetchHealth(); }; @@ -168,14 +188,33 @@ class SignalRConnector extends Component { this.props.dispatchFetchIndexerStatus(); }; - handleIndexer = (body) => { - const action = body.action; + handleIndexer = ({ action, resource }) => { const section = 'indexers'; - if (action === 'updated') { - this.props.dispatchUpdateItem({ section, ...body.resource }); + if (action === 'created' || action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); } else if (action === 'deleted') { - this.props.dispatchRemoveItem({ section, id: body.resource.id }); + this.props.dispatchRemoveItem({ section, id: resource.id }); + } + }; + + handleIndexerproxy = ({ action, resource }) => { + const section = 'settings.indexerProxies'; + + if (action === 'created' || action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: resource.id }); + } + }; + + handleNotification = ({ action, resource }) => { + const section = 'settings.notifications'; + + if (action === 'created' || action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: resource.id }); } }; diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.js b/frontend/src/Components/Table/Cells/RelativeDateCell.js index 207b97752..4bf94cf57 100644 --- a/frontend/src/Components/Table/Cells/RelativeDateCell.js +++ b/frontend/src/Components/Table/Cells/RelativeDateCell.js @@ -1,58 +1,66 @@ import PropTypes from 'prop-types'; -import React, { PureComponent } from 'react'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import formatDateTime from 'Utilities/Date/formatDateTime'; import getRelativeDate from 'Utilities/Date/getRelativeDate'; import TableRowCell from './TableRowCell'; import styles from './RelativeDateCell.css'; -class RelativeDateCell extends PureComponent { +function createRelativeDateCellSelector() { + return createSelector(createUISettingsSelector(), (uiSettings) => { + return { + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat + }; + }); +} +function RelativeDateCell(props) { // // Render - render() { - const { - className, - date, - includeSeconds, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat, - component: Component, - dispatch, - ...otherProps - } = this.props; + const { + className, + date, + includeSeconds, + component: Component, + dispatch, + ...otherProps + } = props; - if (!date) { - return ( - - ); - } + const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } = + useSelector(createRelativeDateCellSelector()); - return ( - - {getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds, timeForToday: true })} - - ); + if (!date) { + return ; } + + return ( + + {getRelativeDate(date, shortDateFormat, showRelativeDates, { + timeFormat, + includeSeconds, + timeForToday: true + })} + + ); } RelativeDateCell.propTypes = { className: PropTypes.string.isRequired, date: PropTypes.string, includeSeconds: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, component: PropTypes.elementType, dispatch: PropTypes.func }; diff --git a/frontend/src/Components/Form/PasswordInput.css.d.ts b/frontend/src/Components/Table/Cells/TableRowCellButton.css.d.ts similarity index 89% rename from frontend/src/Components/Form/PasswordInput.css.d.ts rename to frontend/src/Components/Table/Cells/TableRowCellButton.css.d.ts index 774807ef4..c748f6f97 100644 --- a/frontend/src/Components/Form/PasswordInput.css.d.ts +++ b/frontend/src/Components/Table/Cells/TableRowCellButton.css.d.ts @@ -1,7 +1,7 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'input': string; + 'cell': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.js b/frontend/src/Components/Table/Cells/TableRowCellButton.js deleted file mode 100644 index ff50d3bc9..000000000 --- a/frontend/src/Components/Table/Cells/TableRowCellButton.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Link from 'Components/Link/Link'; -import TableRowCell from './TableRowCell'; -import styles from './TableRowCellButton.css'; - -function TableRowCellButton({ className, ...otherProps }) { - return ( - - ); -} - -TableRowCellButton.propTypes = { - className: PropTypes.string.isRequired -}; - -TableRowCellButton.defaultProps = { - className: styles.cell -}; - -export default TableRowCellButton; diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.tsx b/frontend/src/Components/Table/Cells/TableRowCellButton.tsx new file mode 100644 index 000000000..c80a3d626 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCellButton.tsx @@ -0,0 +1,19 @@ +import React, { ReactNode } from 'react'; +import Link, { LinkProps } from 'Components/Link/Link'; +import TableRowCell from './TableRowCell'; +import styles from './TableRowCellButton.css'; + +interface TableRowCellButtonProps extends LinkProps { + className?: string; + children: ReactNode; +} + +function TableRowCellButton(props: TableRowCellButtonProps) { + const { className = styles.cell, ...otherProps } = props; + + return ( + + ); +} + +export default TableRowCellButton; diff --git a/frontend/src/Components/Table/Column.ts b/frontend/src/Components/Table/Column.ts index 8c2122c65..24674c3fc 100644 --- a/frontend/src/Components/Table/Column.ts +++ b/frontend/src/Components/Table/Column.ts @@ -1,8 +1,12 @@ import React from 'react'; +type PropertyFunction = () => T; + +// TODO: Convert to generic so `name` can be a type interface Column { name: string; - label: string | React.ReactNode; + label: string | PropertyFunction | React.ReactNode; + className?: string; columnLabel?: string; isSortable?: boolean; isVisible: boolean; diff --git a/frontend/src/Components/Table/Table.js b/frontend/src/Components/Table/Table.js index befc8219a..8afbf9ea0 100644 --- a/frontend/src/Components/Table/Table.js +++ b/frontend/src/Components/Table/Table.js @@ -107,7 +107,7 @@ function Table(props) { {...getTableHeaderCellProps(otherProps)} {...column} > - {column.label} + {typeof column.label === 'function' ? column.label() : column.label} ); }) diff --git a/frontend/src/Components/Table/TableHeaderCell.js b/frontend/src/Components/Table/TableHeaderCell.js index 21766978b..b0ed5c571 100644 --- a/frontend/src/Components/Table/TableHeaderCell.js +++ b/frontend/src/Components/Table/TableHeaderCell.js @@ -30,6 +30,7 @@ class TableHeaderCell extends Component { const { className, name, + label, columnLabel, isSortable, isVisible, @@ -53,7 +54,8 @@ class TableHeaderCell extends Component { {...otherProps} component="th" className={className} - title={columnLabel} + label={typeof label === 'function' ? label() : label} + title={typeof columnLabel === 'function' ? columnLabel() : columnLabel} onPress={this.onPress} > {children} @@ -77,7 +79,8 @@ class TableHeaderCell extends Component { TableHeaderCell.propTypes = { className: PropTypes.string, name: PropTypes.string.isRequired, - columnLabel: PropTypes.string, + label: PropTypes.oneOfType([PropTypes.string, PropTypes.func, PropTypes.node]), + columnLabel: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), isSortable: PropTypes.bool, isVisible: PropTypes.bool, isModifiable: PropTypes.bool, diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js index 2d91c7c63..402ef5ae1 100644 --- a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js @@ -35,7 +35,7 @@ function TableOptionsColumn(props) { isDisabled={isModifiable === false} onChange={onVisibleChange} /> - {label} + {typeof label === 'function' ? label() : label} { @@ -56,7 +56,7 @@ function TableOptionsColumn(props) { TableOptionsColumn.propTypes = { name: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, + label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, isVisible: PropTypes.bool.isRequired, isModifiable: PropTypes.bool.isRequired, index: PropTypes.number.isRequired, diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js index 100559660..77d18463f 100644 --- a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js @@ -112,7 +112,7 @@ class TableOptionsColumnDragSource extends Component { void; +} + +function usePaging(options: PagingOptions) { + const { page, totalPages, gotoPage } = options; + const dispatch = useDispatch(); + + const handleFirstPagePress = useCallback(() => { + dispatch(gotoPage({ page: 1 })); + }, [dispatch, gotoPage]); + + const handlePreviousPagePress = useCallback(() => { + dispatch(gotoPage({ page: Math.max(page - 1, 1) })); + }, [page, dispatch, gotoPage]); + + const handleNextPagePress = useCallback(() => { + dispatch(gotoPage({ page: Math.min(page + 1, totalPages) })); + }, [page, totalPages, dispatch, gotoPage]); + + const handleLastPagePress = useCallback(() => { + dispatch(gotoPage({ page: totalPages })); + }, [totalPages, dispatch, gotoPage]); + + const handlePageSelect = useCallback( + (page: number) => { + dispatch(gotoPage({ page })); + }, + [dispatch, gotoPage] + ); + + return useMemo(() => { + return { + handleFirstPagePress, + handlePreviousPagePress, + handleNextPagePress, + handleLastPagePress, + handlePageSelect, + }; + }, [ + handleFirstPagePress, + handlePreviousPagePress, + handleNextPagePress, + handleLastPagePress, + handlePageSelect, + ]); +} + +export default usePaging; diff --git a/frontend/src/Components/TagList.js b/frontend/src/Components/TagList.js index f4d4e2af4..fe700b8fe 100644 --- a/frontend/src/Components/TagList.js +++ b/frontend/src/Components/TagList.js @@ -1,14 +1,15 @@ import PropTypes from 'prop-types'; import React from 'react'; import { kinds } from 'Helpers/Props'; +import sortByProp from 'Utilities/Array/sortByProp'; import Label from './Label'; import styles from './TagList.css'; function TagList({ tags, tagList }) { const sortedTags = tags .map((tagId) => tagList.find((tag) => tag.id === tagId)) - .filter((t) => t !== undefined) - .sort((a, b) => a.label.localeCompare(b.label)); + .filter((tag) => !!tag) + .sort(sortByProp('label')); return (
diff --git a/frontend/src/Components/keyboardShortcuts.js b/frontend/src/Components/keyboardShortcuts.js index b576a988c..8513a65eb 100644 --- a/frontend/src/Components/keyboardShortcuts.js +++ b/frontend/src/Components/keyboardShortcuts.js @@ -6,37 +6,51 @@ import translate from 'Utilities/String/translate'; export const shortcuts = { OPEN_KEYBOARD_SHORTCUTS_MODAL: { key: '?', - name: translate('OpenThisModal') + get name() { + return translate('OpenThisModal'); + } }, CLOSE_MODAL: { key: 'Esc', - name: translate('CloseCurrentModal') + get name() { + return translate('CloseCurrentModal'); + } }, ACCEPT_CONFIRM_MODAL: { key: 'Enter', - name: translate('AcceptConfirmationModal') + get name() { + return translate('AcceptConfirmationModal'); + } }, MOVIE_SEARCH_INPUT: { key: 's', - name: translate('FocusSearchBox') + get name() { + return translate('FocusSearchBox'); + } }, SAVE_SETTINGS: { key: 'mod+s', - name: translate('SaveSettings') + get name() { + return translate('SaveSettings'); + } }, SCROLL_TOP: { key: 'mod+home', - name: translate('MovieIndexScrollTop') + get name() { + return translate('MovieIndexScrollTop'); + } }, SCROLL_BOTTOM: { key: 'mod+end', - name: translate('MovieIndexScrollBottom') + get name() { + return translate('MovieIndexScrollBottom'); + } } }; diff --git a/frontend/src/Components/withScrollPosition.tsx b/frontend/src/Components/withScrollPosition.tsx index ec13c6ab8..f688a6253 100644 --- a/frontend/src/Components/withScrollPosition.tsx +++ b/frontend/src/Components/withScrollPosition.tsx @@ -1,24 +1,30 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; import scrollPositions from 'Store/scrollPositions'; -function withScrollPosition(WrappedComponent, scrollPositionKey) { - function ScrollPosition(props) { +interface WrappedComponentProps { + initialScrollTop: number; +} + +interface ScrollPositionProps { + history: RouteComponentProps['history']; + location: RouteComponentProps['location']; + match: RouteComponentProps['match']; +} + +function withScrollPosition( + WrappedComponent: React.FC, + scrollPositionKey: string +) { + function ScrollPosition(props: ScrollPositionProps) { const { history } = props; const initialScrollTop = - history.action === 'POP' || - (history.location.state && history.location.state.restoreScrollPosition) - ? scrollPositions[scrollPositionKey] - : 0; + history.action === 'POP' ? scrollPositions[scrollPositionKey] : 0; return ; } - ScrollPosition.propTypes = { - history: PropTypes.object.isRequired, - }; - return ScrollPosition; } diff --git a/frontend/src/Content/Fonts/fonts.css b/frontend/src/Content/Fonts/fonts.css index bf31501dd..e0f1bf5dc 100644 --- a/frontend/src/Content/Fonts/fonts.css +++ b/frontend/src/Content/Fonts/fonts.css @@ -25,14 +25,3 @@ font-family: 'Ubuntu Mono'; src: url('UbuntuMono-Regular.eot?#iefix&v=1.3.0') format('embedded-opentype'), url('UbuntuMono-Regular.woff?v=1.3.0') format('woff'), url('UbuntuMono-Regular.ttf?v=1.3.0') format('truetype'); } - -/* - * text-security-disc - */ - -@font-face { - font-weight: normal; - font-style: normal; - font-family: 'text-security-disc'; - src: url('text-security-disc.woff?v=1.3.0') format('woff'), url('text-security-disc.ttf?v=1.3.0') format('truetype'); -} diff --git a/frontend/src/Content/Fonts/text-security-disc.ttf b/frontend/src/Content/Fonts/text-security-disc.ttf deleted file mode 100644 index 86038dba8..000000000 Binary files a/frontend/src/Content/Fonts/text-security-disc.ttf and /dev/null differ diff --git a/frontend/src/Content/Fonts/text-security-disc.woff b/frontend/src/Content/Fonts/text-security-disc.woff deleted file mode 100644 index bc4cc324b..000000000 Binary files a/frontend/src/Content/Fonts/text-security-disc.woff and /dev/null differ diff --git a/frontend/src/DownloadClient/DownloadProtocol.ts b/frontend/src/DownloadClient/DownloadProtocol.ts new file mode 100644 index 000000000..417db8178 --- /dev/null +++ b/frontend/src/DownloadClient/DownloadProtocol.ts @@ -0,0 +1,3 @@ +type DownloadProtocol = 'usenet' | 'torrent' | 'unknown'; + +export default DownloadProtocol; diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContent.js b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js index 920c59a31..17a04e403 100644 --- a/frontend/src/FirstRun/AuthenticationRequiredModalContent.js +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js @@ -11,7 +11,7 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes, kinds } from 'Helpers/Props'; -import { authenticationMethodOptions, authenticationRequiredOptions, authenticationRequiredWarning } from 'Settings/General/SecuritySettings'; +import { authenticationMethodOptions, authenticationRequiredOptions } from 'Settings/General/SecuritySettings'; import translate from 'Utilities/String/translate'; import styles from './AuthenticationRequiredModalContent.css'; @@ -34,7 +34,8 @@ function AuthenticationRequiredModalContent(props) { authenticationMethod, authenticationRequired, username, - password + password, + passwordConfirmation } = settings; const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none'; @@ -63,71 +64,75 @@ function AuthenticationRequiredModalContent(props) { className={styles.authRequiredAlert} kind={kinds.WARNING} > - {authenticationRequiredWarning} + {translate('AuthenticationRequiredWarning')} { isPopulated && !error ?
- {translate('Authentication')} + {translate('AuthenticationMethod')} - { - authenticationEnabled ? - - {translate('AuthenticationRequired')} + + {translate('AuthenticationRequired')} - - : - null - } + + - { - authenticationEnabled ? - - {translate('Username')} + + {translate('Username')} - - : - null - } + + - { - authenticationEnabled ? - - {translate('Password')} + + {translate('Password')} - - : - null - } + + + + + {translate('PasswordConfirmation')} + + +
: null } diff --git a/frontend/src/Helpers/Hooks/useCurrentPage.ts b/frontend/src/Helpers/Hooks/useCurrentPage.ts new file mode 100644 index 000000000..3caf66df2 --- /dev/null +++ b/frontend/src/Helpers/Hooks/useCurrentPage.ts @@ -0,0 +1,9 @@ +import { useHistory } from 'react-router-dom'; + +function useCurrentPage() { + const history = useHistory(); + + return history.action === 'POP'; +} + +export default useCurrentPage; diff --git a/frontend/src/Helpers/Hooks/useModalOpenState.ts b/frontend/src/Helpers/Hooks/useModalOpenState.ts new file mode 100644 index 000000000..24cffb2f1 --- /dev/null +++ b/frontend/src/Helpers/Hooks/useModalOpenState.ts @@ -0,0 +1,17 @@ +import { useCallback, useState } from 'react'; + +export default function useModalOpenState( + initialState: boolean +): [boolean, () => void, () => void] { + const [isOpen, setIsOpen] = useState(initialState); + + const setModalOpen = useCallback(() => { + setIsOpen(true); + }, [setIsOpen]); + + const setModalClosed = useCallback(() => { + setIsOpen(false); + }, [setIsOpen]); + + return [isOpen, setModalOpen, setModalClosed]; +} diff --git a/frontend/src/Helpers/Props/TooltipPosition.ts b/frontend/src/Helpers/Props/TooltipPosition.ts new file mode 100644 index 000000000..885c73470 --- /dev/null +++ b/frontend/src/Helpers/Props/TooltipPosition.ts @@ -0,0 +1,3 @@ +type TooltipPosition = 'top' | 'right' | 'bottom' | 'left'; + +export default TooltipPosition; diff --git a/frontend/src/Helpers/Props/align.js b/frontend/src/Helpers/Props/align.ts similarity index 100% rename from frontend/src/Helpers/Props/align.js rename to frontend/src/Helpers/Props/align.ts diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.js b/frontend/src/Helpers/Props/filterBuilderValueTypes.js index 7fed535f2..73ef41956 100644 --- a/frontend/src/Helpers/Props/filterBuilderValueTypes.js +++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js @@ -2,9 +2,10 @@ export const BOOL = 'bool'; export const BYTES = 'bytes'; export const DATE = 'date'; export const DEFAULT = 'default'; +export const HISTORY_EVENT_TYPE = 'historyEventType'; export const INDEXER = 'indexer'; export const PROTOCOL = 'protocol'; export const PRIVACY = 'privacy'; export const APP_PROFILE = 'appProfile'; -export const MOVIE_STATUS = 'movieStatus'; +export const CATEGORY = 'category'; export const TAG = 'tag'; diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 589add5a8..773748996 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -43,6 +43,7 @@ import { faChevronCircleRight as fasChevronCircleRight, faChevronCircleUp as fasChevronCircleUp, faCircle as fasCircle, + faCircleDown as fasCircleDown, faCloud as fasCloud, faCloudDownloadAlt as fasCloudDownloadAlt, faCog as fasCog, @@ -75,6 +76,7 @@ import { faListCheck as fasListCheck, faLocationArrow as fasLocationArrow, faLock as fasLock, + faMagnet as fasMagnet, faMedkit as fasMedkit, faMinus as fasMinus, faMusic as fasMusic, @@ -140,6 +142,7 @@ export const CHECK_INDETERMINATE = fasMinus; export const CHECK_CIRCLE = fasCheckCircle; export const CHECK_SQUARE = fasSquareCheck; export const CIRCLE = fasCircle; +export const CIRCLE_DOWN = fasCircleDown; export const CIRCLE_OUTLINE = farCircle; export const CLEAR = fasTrashAlt; export const CLIPBOARD = fasCopy; @@ -181,6 +184,7 @@ export const INTERACTIVE = fasUser; export const KEYBOARD = farKeyboard; export const LOCK = fasLock; export const LOGOUT = fasSignOutAlt; +export const MAGNET = fasMagnet; export const MANAGE = fasListCheck; export const MEDIA_INFO = farFileInvoice; export const MISSING = fasExclamationTriangle; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index d26d08616..f9cd58e6d 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -1,6 +1,5 @@ export const AUTO_COMPLETE = 'autoComplete'; export const APP_PROFILE_SELECT = 'appProfileSelect'; -export const AVAILABILITY_SELECT = 'availabilitySelect'; export const CAPTCHA = 'captcha'; export const CARDIGANNCAPTCHA = 'cardigannCaptcha'; export const CHECK = 'check'; @@ -10,6 +9,7 @@ export const INFO = 'info'; export const MOVIE_MONITORED_SELECT = 'movieMonitoredSelect'; export const CATEGORY_SELECT = 'newznabCategorySelect'; export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect'; +export const FLOAT = 'float'; export const NUMBER = 'number'; export const OAUTH = 'oauth'; export const PASSWORD = 'password'; @@ -26,7 +26,6 @@ export const TAG_SELECT = 'tagSelect'; export const all = [ AUTO_COMPLETE, APP_PROFILE_SELECT, - AVAILABILITY_SELECT, CAPTCHA, CARDIGANNCAPTCHA, CHECK, @@ -35,6 +34,7 @@ export const all = [ INFO, MOVIE_MONITORED_SELECT, CATEGORY_SELECT, + FLOAT, NUMBER, OAUTH, PASSWORD, diff --git a/frontend/src/Helpers/Props/kinds.js b/frontend/src/Helpers/Props/kinds.ts similarity index 72% rename from frontend/src/Helpers/Props/kinds.js rename to frontend/src/Helpers/Props/kinds.ts index b0f5ac87f..7ce606716 100644 --- a/frontend/src/Helpers/Props/kinds.js +++ b/frontend/src/Helpers/Props/kinds.ts @@ -7,7 +7,6 @@ export const PRIMARY = 'primary'; export const PURPLE = 'purple'; export const SUCCESS = 'success'; export const WARNING = 'warning'; -export const QUEUE = 'queue'; export const all = [ DANGER, @@ -19,5 +18,15 @@ export const all = [ PURPLE, SUCCESS, WARNING, - QUEUE -]; +] as const; + +export type Kind = + | 'danger' + | 'default' + | 'disabled' + | 'info' + | 'inverse' + | 'primary' + | 'purple' + | 'success' + | 'warning'; diff --git a/frontend/src/Helpers/Props/sizes.js b/frontend/src/Helpers/Props/sizes.ts similarity index 71% rename from frontend/src/Helpers/Props/sizes.js rename to frontend/src/Helpers/Props/sizes.ts index d7f85df5e..ca7a50fbf 100644 --- a/frontend/src/Helpers/Props/sizes.js +++ b/frontend/src/Helpers/Props/sizes.ts @@ -4,4 +4,6 @@ export const MEDIUM = 'medium'; export const LARGE = 'large'; export const EXTRA_LARGE = 'extraLarge'; -export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE]; +export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE] as const; + +export type Size = 'extraSmall' | 'small' | 'medium' | 'large' | 'extraLarge'; diff --git a/frontend/src/History/Details/HistoryDetails.js b/frontend/src/History/Details/HistoryDetails.js index e0ae06eb1..6d5ab260e 100644 --- a/frontend/src/History/Details/HistoryDetails.js +++ b/frontend/src/History/Details/HistoryDetails.js @@ -3,6 +3,7 @@ import React from 'react'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; import Link from 'Components/Link/Link'; +import formatDateTime from 'Utilities/Date/formatDateTime'; import translate from 'Utilities/String/translate'; import styles from './HistoryDetails.css'; @@ -10,7 +11,10 @@ function HistoryDetails(props) { const { indexer, eventType, - data + date, + data, + shortDateFormat, + timeFormat } = props; if (eventType === 'indexerQuery' || eventType === 'indexerRss') { @@ -21,7 +25,10 @@ function HistoryDetails(props) { limit, offset, source, - url + host, + url, + elapsedTime, + cached } = data; return ( @@ -86,6 +93,15 @@ function HistoryDetails(props) { null } + { + data ? + : + null + } + { data ? : null } + + { + elapsedTime ? + : + null + } + + { + date ? + : + null + } ); } @@ -101,10 +135,19 @@ function HistoryDetails(props) { if (eventType === 'releaseGrabbed') { const { source, + host, grabTitle, - url + url, + publishedDate, + infoUrl, + downloadClient, + downloadClientName, + elapsedTime, + grabMethod } = data; + const downloadClientNameInfo = downloadClientName ?? downloadClient; + return ( { @@ -125,6 +168,15 @@ function HistoryDetails(props) { null } + { + data ? + : + null + } + { data ? {infoUrl}} + /> : + null + } + + { + publishedDate ? + : + null + } + + { + downloadClientNameInfo ? + : + null + } + { data ? : null } + + { + elapsedTime ? + : + null + } + + { + grabMethod ? + : + null + } + + { + date ? + : + null + } ); } if (eventType === 'indexerAuth') { + const { elapsedTime } = data; + return ( : null } + + { + elapsedTime ? + : + null + } + + { + date ? + : + null + } ); } @@ -171,6 +297,15 @@ function HistoryDetails(props) { title={translate('Name')} data={data.query} /> + + { + date ? + : + null + } ); } @@ -178,6 +313,7 @@ function HistoryDetails(props) { HistoryDetails.propTypes = { indexer: PropTypes.object.isRequired, eventType: PropTypes.string.isRequired, + date: PropTypes.string.isRequired, data: PropTypes.object.isRequired, shortDateFormat: PropTypes.string.isRequired, timeFormat: PropTypes.string.isRequired diff --git a/frontend/src/History/Details/HistoryDetailsModal.css b/frontend/src/History/Details/HistoryDetailsModal.css deleted file mode 100644 index 271d422ff..000000000 --- a/frontend/src/History/Details/HistoryDetailsModal.css +++ /dev/null @@ -1,5 +0,0 @@ -.markAsFailedButton { - composes: button from '~Components/Link/Button.css'; - - margin-right: auto; -} diff --git a/frontend/src/History/Details/HistoryDetailsModal.js b/frontend/src/History/Details/HistoryDetailsModal.js index e6f960c48..560955de3 100644 --- a/frontend/src/History/Details/HistoryDetailsModal.js +++ b/frontend/src/History/Details/HistoryDetailsModal.js @@ -1,16 +1,13 @@ import PropTypes from 'prop-types'; import React from 'react'; import Button from 'Components/Link/Button'; -import SpinnerButton from 'Components/Link/SpinnerButton'; import Modal from 'Components/Modal/Modal'; import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; -import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import HistoryDetails from './HistoryDetails'; -import styles from './HistoryDetailsModal.css'; function getHeaderTitle(eventType) { switch (eventType) { @@ -32,11 +29,10 @@ function HistoryDetailsModal(props) { isOpen, eventType, indexer, + date, data, - isMarkingAsFailed, shortDateFormat, timeFormat, - onMarkAsFailedPress, onModalClose } = props; @@ -54,6 +50,7 @@ function HistoryDetailsModal(props) { - { - eventType === 'grabbed' && - - Mark as Failed - - } -
- ); - } -} - -HistoryRowParameter.propTypes = { - title: PropTypes.string.isRequired, - value: PropTypes.string.isRequired -}; - -export default HistoryRowParameter; diff --git a/frontend/src/History/HistoryRowParameter.tsx b/frontend/src/History/HistoryRowParameter.tsx new file mode 100644 index 000000000..ad83d5d77 --- /dev/null +++ b/frontend/src/History/HistoryRowParameter.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import Link from 'Components/Link/Link'; +import { HistoryQueryType } from 'typings/History'; +import styles from './HistoryRowParameter.css'; + +interface HistoryRowParameterProps { + title: string; + value: string; + queryType: HistoryQueryType; +} + +function HistoryRowParameter(props: HistoryRowParameterProps) { + const { title, value, queryType } = props; + + const type = title.toLowerCase(); + + let link = null; + + if (type === 'imdb') { + link = {value}; + } else if (type === 'tmdb') { + link = ( + + {value} + + ); + } else if (type === 'tvdb') { + link = ( + + {value} + + ); + } else if (type === 'tvmaze') { + link = {value}; + } + + return ( +
+
+ {title} +
+ +
{link ? link : value}
+
+ ); +} + +export default HistoryRowParameter; diff --git a/frontend/src/Indexer/Add/AddIndexerModal.js b/frontend/src/Indexer/Add/AddIndexerModal.js deleted file mode 100644 index 4c4db24b9..000000000 --- a/frontend/src/Indexer/Add/AddIndexerModal.js +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import AddIndexerModalContentConnector from './AddIndexerModalContentConnector'; -import styles from './AddIndexerModal.css'; - -function AddIndexerModal({ isOpen, onModalClose, onSelectIndexer, ...otherProps }) { - return ( - - - - ); -} - -AddIndexerModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired, - onSelectIndexer: PropTypes.func.isRequired -}; - -export default AddIndexerModal; diff --git a/frontend/src/Indexer/Add/AddIndexerModal.tsx b/frontend/src/Indexer/Add/AddIndexerModal.tsx new file mode 100644 index 000000000..be22eec57 --- /dev/null +++ b/frontend/src/Indexer/Add/AddIndexerModal.tsx @@ -0,0 +1,44 @@ +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import { clearIndexerSchema } from 'Store/Actions/indexerActions'; +import AddIndexerModalContent from './AddIndexerModalContent'; +import styles from './AddIndexerModal.css'; + +interface AddIndexerModalProps { + isOpen: boolean; + onSelectIndexer(): void; + onModalClose(): void; +} + +function AddIndexerModal({ + isOpen, + onSelectIndexer, + onModalClose, + ...otherProps +}: AddIndexerModalProps) { + const dispatch = useDispatch(); + + const onModalClosePress = useCallback(() => { + dispatch(clearIndexerSchema()); + onModalClose(); + }, [dispatch, onModalClose]); + + return ( + + + + ); +} + +export default AddIndexerModal; diff --git a/frontend/src/Indexer/Add/AddIndexerModalContent.css b/frontend/src/Indexer/Add/AddIndexerModalContent.css index 5a92b40cb..e824c5475 100644 --- a/frontend/src/Indexer/Add/AddIndexerModalContent.css +++ b/frontend/src/Indexer/Add/AddIndexerModalContent.css @@ -19,12 +19,18 @@ margin-bottom: 16px; } -.alert { +.notice { composes: alert from '~Components/Alert.css'; margin-bottom: 20px; } +.alert { + composes: alert from '~Components/Alert.css'; + + text-align: center; +} + .scroller { flex: 1 1 auto; } @@ -40,7 +46,6 @@ flex: 1; flex-direction: column; margin-right: 12px; - max-width: 50%; } .filterContainer:last-child { @@ -53,17 +58,22 @@ } @media only screen and (max-width: $breakpointSmall) { + .filterInput { + margin-bottom: 5px; + } + .alert { display: none; } .filterRow { - flex-direction: column; + display: block; + margin-bottom: 10px; } .filterContainer { margin-right: 0; - margin-bottom: 12px; + margin-bottom: 5px; } .scroller { @@ -73,6 +83,12 @@ } } +@media only screen and (min-width: $breakpointSmall) { + .filterContainer { + max-width: 50%; + } +} + .modalFooter { composes: modalFooter from '~Components/Modal/ModalFooter.css'; @@ -88,4 +104,8 @@ flex-direction: column; gap: 10px; } + + .available { + display: none; + } } diff --git a/frontend/src/Indexer/Add/AddIndexerModalContent.css.d.ts b/frontend/src/Indexer/Add/AddIndexerModalContent.css.d.ts index cbedc72a4..5978832e4 100644 --- a/frontend/src/Indexer/Add/AddIndexerModalContent.css.d.ts +++ b/frontend/src/Indexer/Add/AddIndexerModalContent.css.d.ts @@ -10,6 +10,7 @@ interface CssExports { 'indexers': string; 'modalBody': string; 'modalFooter': string; + 'notice': string; 'scroller': string; } export const cssExports: CssExports; diff --git a/frontend/src/Indexer/Add/AddIndexerModalContent.js b/frontend/src/Indexer/Add/AddIndexerModalContent.js deleted file mode 100644 index 4617664ad..000000000 --- a/frontend/src/Indexer/Add/AddIndexerModalContent.js +++ /dev/null @@ -1,311 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import EnhancedSelectInput from 'Components/Form/EnhancedSelectInput'; -import NewznabCategorySelectInputConnector from 'Components/Form/NewznabCategorySelectInputConnector'; -import TextInput from 'Components/Form/TextInput'; -import Button from 'Components/Link/Button'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import Scroller from 'Components/Scroller/Scroller'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import { kinds, scrollDirections } from 'Helpers/Props'; -import getErrorMessage from 'Utilities/Object/getErrorMessage'; -import translate from 'Utilities/String/translate'; -import SelectIndexerRowConnector from './SelectIndexerRowConnector'; -import styles from './AddIndexerModalContent.css'; - -const columns = [ - { - name: 'protocol', - label: translate('Protocol'), - isSortable: true, - isVisible: true - }, - { - name: 'sortName', - label: translate('Name'), - isSortable: true, - isVisible: true - }, - { - name: 'language', - label: translate('Language'), - isSortable: true, - isVisible: true - }, - { - name: 'description', - label: translate('Description'), - isSortable: false, - isVisible: true - }, - { - name: 'privacy', - label: translate('Privacy'), - isSortable: true, - isVisible: true - } -]; - -const protocols = [ - { - key: 'torrent', - value: 'torrent' - }, - { - key: 'usenet', - value: 'nzb' - } -]; - -const privacyLevels = [ - { - key: 'private', - value: translate('Private') - }, - { - key: 'semiPrivate', - value: translate('SemiPrivate') - }, - { - key: 'public', - value: translate('Public') - } -]; - -class AddIndexerModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - filter: '', - filterProtocols: [], - filterLanguages: [], - filterPrivacyLevels: [], - filterCategories: [] - }; - } - - // - // Listeners - - onFilterChange = ({ value }) => { - this.setState({ filter: value }); - }; - - // - // Render - - render() { - const { - indexers, - onIndexerSelect, - sortKey, - sortDirection, - isFetching, - isPopulated, - error, - onSortPress, - onModalClose - } = this.props; - - const languages = Array.from(new Set(indexers.map(({ language }) => language))) - .sort((a, b) => a.localeCompare(b)) - .map((language) => ({ key: language, value: language })); - - const filteredIndexers = indexers.filter((indexer) => { - const { - filter, - filterProtocols, - filterLanguages, - filterPrivacyLevels, - filterCategories - } = this.state; - - if (!indexer.name.toLowerCase().includes(filter.toLocaleLowerCase()) && !indexer.description.toLowerCase().includes(filter.toLocaleLowerCase())) { - return false; - } - - if (filterProtocols.length && !filterProtocols.includes(indexer.protocol)) { - return false; - } - - if (filterLanguages.length && !filterLanguages.includes(indexer.language)) { - return false; - } - - if (filterPrivacyLevels.length && !filterPrivacyLevels.includes(indexer.privacy)) { - return false; - } - - if (filterCategories.length) { - const { categories = [] } = indexer.capabilities || {}; - const flat = ({ id, subCategories = [] }) => [id, ...subCategories.flatMap(flat)]; - const flatCategories = categories - .filter((item) => item.id < 100000) - .flatMap(flat); - - if (!filterCategories.every((item) => flatCategories.includes(item))) { - return false; - } - } - - return true; - }); - - const errorMessage = getErrorMessage(error, translate('UnableToLoadIndexers')); - - return ( - - - {translate('AddIndexer')} - - - - - -
-
- - this.setState({ filterProtocols: value })} - /> -
- -
- - this.setState({ filterLanguages: value })} - /> -
- -
- - this.setState({ filterPrivacyLevels: value })} - /> -
- -
- - this.setState({ filterCategories: value })} - /> -
-
- - -
- {translate('ProwlarrSupportsAnyIndexer')} -
-
- - - { - isFetching ? : null - } - { - error ? {errorMessage} : null - } - { - isPopulated && !!indexers.length ? - - - { - filteredIndexers.map((indexer) => ( - - )) - } - -
: - null - } - { - isPopulated && !!indexers.length && !filteredIndexers.length ? - - {translate('NoIndexersFound')} - : - null - } -
-
- - -
- { - isPopulated ? - translate('CountIndexersAvailable', [filteredIndexers.length]) : - null - } -
- -
- -
-
-
- ); - } -} - -AddIndexerModalContent.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - sortKey: PropTypes.string, - sortDirection: PropTypes.string, - onSortPress: PropTypes.func.isRequired, - indexers: PropTypes.arrayOf(PropTypes.object).isRequired, - onIndexerSelect: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default AddIndexerModalContent; diff --git a/frontend/src/Indexer/Add/AddIndexerModalContent.tsx b/frontend/src/Indexer/Add/AddIndexerModalContent.tsx new file mode 100644 index 000000000..be1413769 --- /dev/null +++ b/frontend/src/Indexer/Add/AddIndexerModalContent.tsx @@ -0,0 +1,434 @@ +import { some } from 'lodash'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import IndexerAppState from 'App/State/IndexerAppState'; +import Alert from 'Components/Alert'; +import EnhancedSelectInput from 'Components/Form/EnhancedSelectInput'; +import NewznabCategorySelectInputConnector from 'Components/Form/NewznabCategorySelectInputConnector'; +import TextInput from 'Components/Form/TextInput'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import Scroller from 'Components/Scroller/Scroller'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import { kinds, scrollDirections } from 'Helpers/Props'; +import Indexer, { IndexerCategory } from 'Indexer/Indexer'; +import { + fetchIndexerSchema, + selectIndexerSchema, + setIndexerSchemaSort, +} from 'Store/Actions/indexerActions'; +import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { SortCallback } from 'typings/callbacks'; +import sortByProp from 'Utilities/Array/sortByProp'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import SelectIndexerRow from './SelectIndexerRow'; +import styles from './AddIndexerModalContent.css'; + +const COLUMNS = [ + { + name: 'protocol', + label: () => translate('Protocol'), + isSortable: true, + isVisible: true, + }, + { + name: 'sortName', + label: () => translate('Name'), + isSortable: true, + isVisible: true, + }, + { + name: 'language', + label: () => translate('Language'), + isSortable: true, + isVisible: true, + }, + { + name: 'description', + label: () => translate('Description'), + isSortable: false, + isVisible: true, + }, + { + name: 'privacy', + label: () => translate('Privacy'), + isSortable: true, + isVisible: true, + }, + { + name: 'categories', + label: () => translate('Categories'), + isSortable: false, + isVisible: true, + }, +]; + +const PROTOCOLS = [ + { + key: 'torrent', + value: 'torrent', + }, + { + key: 'usenet', + value: 'nzb', + }, +]; + +const PRIVACY_LEVELS = [ + { + key: 'private', + get value() { + return translate('Private'); + }, + }, + { + key: 'semiPrivate', + get value() { + return translate('SemiPrivate'); + }, + }, + { + key: 'public', + get value() { + return translate('Public'); + }, + }, +]; + +interface IndexerSchema extends Indexer { + isExistingIndexer: boolean; +} + +function createAddIndexersSelector() { + return createSelector( + createClientSideCollectionSelector('indexers.schema'), + createAllIndexersSelector(), + (indexers: IndexerAppState, allIndexers) => { + const { isFetching, isPopulated, error, items, sortDirection, sortKey } = + indexers; + + const indexerList: IndexerSchema[] = items.map((item) => { + const { definitionName } = item; + return { + ...item, + isExistingIndexer: some(allIndexers, { definitionName }), + }; + }); + + return { + isFetching, + isPopulated, + error, + indexers: indexerList, + sortKey, + sortDirection, + }; + } + ); +} + +interface AddIndexerModalContentProps { + onSelectIndexer(): void; + onModalClose(): void; +} + +function AddIndexerModalContent(props: AddIndexerModalContentProps) { + const { onSelectIndexer, onModalClose } = props; + + const { isFetching, isPopulated, error, indexers, sortKey, sortDirection } = + useSelector(createAddIndexersSelector()); + const dispatch = useDispatch(); + + const [filter, setFilter] = useState(''); + const [filterProtocols, setFilterProtocols] = useState([]); + const [filterLanguages, setFilterLanguages] = useState([]); + const [filterPrivacyLevels, setFilterPrivacyLevels] = useState([]); + const [filterCategories, setFilterCategories] = useState([]); + + useEffect( + () => { + dispatch(fetchIndexerSchema()); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const onFilterChange = useCallback( + ({ value }: { value: string }) => { + setFilter(value); + }, + [setFilter] + ); + + const onFilterProtocolsChange = useCallback( + ({ value }: { value: string[] }) => { + setFilterProtocols(value); + }, + [setFilterProtocols] + ); + + const onFilterLanguagesChange = useCallback( + ({ value }: { value: string[] }) => { + setFilterLanguages(value); + }, + [setFilterLanguages] + ); + + const onFilterPrivacyLevelsChange = useCallback( + ({ value }: { value: string[] }) => { + setFilterPrivacyLevels(value); + }, + [setFilterPrivacyLevels] + ); + + const onFilterCategoriesChange = useCallback( + ({ value }: { value: number[] }) => { + setFilterCategories(value); + }, + [setFilterCategories] + ); + + const onIndexerSelect = useCallback( + ({ + implementation, + implementationName, + name, + }: { + implementation: string; + implementationName: string; + name: string; + }) => { + dispatch( + selectIndexerSchema({ + implementation, + implementationName, + name, + }) + ); + + onSelectIndexer(); + }, + [dispatch, onSelectIndexer] + ); + + const onSortPress = useCallback( + (sortKey, sortDirection) => { + dispatch(setIndexerSchemaSort({ sortKey, sortDirection })); + }, + [dispatch] + ); + + const languages = useMemo( + () => + Array.from(new Set(indexers.map(({ language }) => language))) + .map((language) => ({ key: language, value: language })) + .sort(sortByProp('value')), + [indexers] + ); + + const filteredIndexers = useMemo(() => { + const flat = ({ + id, + subCategories = [], + }: { + id: number; + subCategories: IndexerCategory[]; + }): number[] => [id, ...subCategories.flatMap(flat)]; + + return indexers.filter((indexer) => { + if ( + filter.length && + !indexer.name.toLowerCase().includes(filter.toLocaleLowerCase()) && + !indexer.description.toLowerCase().includes(filter.toLocaleLowerCase()) + ) { + return false; + } + + if ( + filterProtocols.length && + !filterProtocols.includes(indexer.protocol) + ) { + return false; + } + + if ( + filterLanguages.length && + !filterLanguages.includes(indexer.language) + ) { + return false; + } + + if ( + filterPrivacyLevels.length && + !filterPrivacyLevels.includes(indexer.privacy) + ) { + return false; + } + + if (filterCategories.length) { + const { categories = [] } = indexer.capabilities || {}; + + const flatCategories = categories + .filter((item) => item.id < 100000) + .flatMap(flat); + + if ( + !filterCategories.every((categoryId) => + flatCategories.includes(categoryId) + ) + ) { + return false; + } + } + + return true; + }); + }, [ + indexers, + filter, + filterProtocols, + filterLanguages, + filterPrivacyLevels, + filterCategories, + ]); + + const errorMessage = getErrorMessage( + error, + translate('UnableToLoadIndexers') + ); + + return ( + + {translate('AddIndexer')} + + + + +
+
+ + + +
+ +
+ + + +
+ +
+ + +
+ +
+ + + +
+
+ + +
{translate('ProwlarrSupportsAnyIndexer')}
+
+ + + {isFetching ? : null} + + {error ? ( + + {errorMessage} + + ) : null} + + {isPopulated && !!indexers.length ? ( + + + {filteredIndexers.map((indexer) => ( + + ))} + +
+ ) : null} + + {isPopulated && !!indexers.length && !filteredIndexers.length ? ( + + {translate('NoIndexersFound')} + + ) : null} +
+
+ + +
+ {isPopulated + ? translate('CountIndexersAvailable', { + count: filteredIndexers.length, + }) + : null} +
+ +
+ +
+
+
+ ); +} + +export default AddIndexerModalContent; diff --git a/frontend/src/Indexer/Add/AddIndexerModalContentConnector.js b/frontend/src/Indexer/Add/AddIndexerModalContentConnector.js deleted file mode 100644 index 0dc810608..000000000 --- a/frontend/src/Indexer/Add/AddIndexerModalContentConnector.js +++ /dev/null @@ -1,83 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchIndexerSchema, selectIndexerSchema, setIndexerSchemaSort } from 'Store/Actions/indexerActions'; -import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; -import AddIndexerModalContent from './AddIndexerModalContent'; - -function createMapStateToProps() { - return createSelector( - createClientSideCollectionSelector('indexers.schema'), - (indexers) => { - const { - isFetching, - isPopulated, - error, - items, - sortDirection, - sortKey - } = indexers; - - return { - isFetching, - isPopulated, - error, - indexers: items, - sortKey, - sortDirection - }; - } - ); -} - -const mapDispatchToProps = { - fetchIndexerSchema, - selectIndexerSchema, - setIndexerSchemaSort -}; - -class AddIndexerModalContentConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.fetchIndexerSchema(); - } - - // - // Listeners - - onIndexerSelect = ({ implementation, name }) => { - this.props.selectIndexerSchema({ implementation, name }); - this.props.onSelectIndexer(); - }; - - onSortPress = (sortKey, sortDirection) => { - this.props.setIndexerSchemaSort({ sortKey, sortDirection }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -AddIndexerModalContentConnector.propTypes = { - fetchIndexerSchema: PropTypes.func.isRequired, - selectIndexerSchema: PropTypes.func.isRequired, - setIndexerSchemaSort: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired, - onSelectIndexer: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(AddIndexerModalContentConnector); diff --git a/frontend/src/Indexer/Add/AddIndexerPresetMenuItem.js b/frontend/src/Indexer/Add/AddIndexerPresetMenuItem.js index 03196e526..8f98d0e12 100644 --- a/frontend/src/Indexer/Add/AddIndexerPresetMenuItem.js +++ b/frontend/src/Indexer/Add/AddIndexerPresetMenuItem.js @@ -10,12 +10,14 @@ class AddIndexerPresetMenuItem extends Component { onPress = () => { const { name, - implementation + implementation, + implementationName } = this.props; this.props.onPress({ name, - implementation + implementation, + implementationName }); }; @@ -26,6 +28,7 @@ class AddIndexerPresetMenuItem extends Component { const { name, implementation, + implementationName, ...otherProps } = this.props; @@ -43,6 +46,7 @@ class AddIndexerPresetMenuItem extends Component { AddIndexerPresetMenuItem.propTypes = { name: PropTypes.string.isRequired, implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, onPress: PropTypes.func.isRequired }; diff --git a/frontend/src/Indexer/Add/SelectIndexerRow.js b/frontend/src/Indexer/Add/SelectIndexerRow.js deleted file mode 100644 index c3f33220d..000000000 --- a/frontend/src/Indexer/Add/SelectIndexerRow.js +++ /dev/null @@ -1,88 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableRowButton from 'Components/Table/TableRowButton'; -import { icons } from 'Helpers/Props'; -import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel'; -import firstCharToUpper from 'Utilities/String/firstCharToUpper'; -import translate from 'Utilities/String/translate'; -import styles from './SelectIndexerRow.css'; - -class SelectIndexerRow extends Component { - - // - // Listeners - - onPress = () => { - const { - implementation, - name - } = this.props; - - this.props.onIndexerSelect({ implementation, name }); - }; - - // - // Render - - render() { - const { - protocol, - privacy, - name, - language, - description, - isExistingIndexer - } = this.props; - - return ( - - - - - - - {name} - { - isExistingIndexer ? - : - null - } - - - - {language} - - - - {description} - - - - {translate(firstCharToUpper(privacy))} - - - ); - } -} - -SelectIndexerRow.propTypes = { - name: PropTypes.string.isRequired, - protocol: PropTypes.string.isRequired, - privacy: PropTypes.string.isRequired, - language: PropTypes.string.isRequired, - description: PropTypes.string.isRequired, - implementation: PropTypes.string.isRequired, - onIndexerSelect: PropTypes.func.isRequired, - isExistingIndexer: PropTypes.bool.isRequired -}; - -export default SelectIndexerRow; diff --git a/frontend/src/Indexer/Add/SelectIndexerRow.tsx b/frontend/src/Indexer/Add/SelectIndexerRow.tsx new file mode 100644 index 000000000..157050e41 --- /dev/null +++ b/frontend/src/Indexer/Add/SelectIndexerRow.tsx @@ -0,0 +1,78 @@ +import React, { useCallback } from 'react'; +import Icon from 'Components/Icon'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRowButton from 'Components/Table/TableRowButton'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import { icons } from 'Helpers/Props'; +import CapabilitiesLabel from 'Indexer/Index/Table/CapabilitiesLabel'; +import PrivacyLabel from 'Indexer/Index/Table/PrivacyLabel'; +import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel'; +import { IndexerCapabilities, IndexerPrivacy } from 'Indexer/Indexer'; +import translate from 'Utilities/String/translate'; +import styles from './SelectIndexerRow.css'; + +interface SelectIndexerRowProps { + name: string; + protocol: DownloadProtocol; + privacy: IndexerPrivacy; + language: string; + description: string; + capabilities: IndexerCapabilities; + implementation: string; + implementationName: string; + isExistingIndexer: boolean; + onIndexerSelect(...args: unknown[]): void; +} + +function SelectIndexerRow(props: SelectIndexerRowProps) { + const { + name, + protocol, + privacy, + language, + description, + capabilities, + implementation, + implementationName, + isExistingIndexer, + onIndexerSelect, + } = props; + + const onPress = useCallback(() => { + onIndexerSelect({ implementation, implementationName, name }); + }, [implementation, implementationName, name, onIndexerSelect]); + + return ( + + + + + + + {name} + {isExistingIndexer ? ( + + ) : null} + + + {language} + + {description} + + + + + + + + + + ); +} + +export default SelectIndexerRow; diff --git a/frontend/src/Indexer/Add/SelectIndexerRowConnector.js b/frontend/src/Indexer/Add/SelectIndexerRowConnector.js deleted file mode 100644 index f507689c8..000000000 --- a/frontend/src/Indexer/Add/SelectIndexerRowConnector.js +++ /dev/null @@ -1,18 +0,0 @@ - -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createExistingIndexerSelector from 'Store/Selectors/createExistingIndexerSelector'; -import SelectIndexerRow from './SelectIndexerRow'; - -function createMapStateToProps() { - return createSelector( - createExistingIndexerSelector(), - (isExistingIndexer, dimensions) => { - return { - isExistingIndexer - }; - } - ); -} - -export default connect(createMapStateToProps)(SelectIndexerRow); diff --git a/frontend/src/Indexer/Delete/DeleteIndexerModal.js b/frontend/src/Indexer/Delete/DeleteIndexerModal.js deleted file mode 100644 index aed954829..000000000 --- a/frontend/src/Indexer/Delete/DeleteIndexerModal.js +++ /dev/null @@ -1,34 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import { sizes } from 'Helpers/Props'; -import DeleteIndexerModalContentConnector from './DeleteIndexerModalContentConnector'; - -function DeleteIndexerModal(props) { - const { - isOpen, - onModalClose, - ...otherProps - } = props; - - return ( - - - - ); -} - -DeleteIndexerModal.propTypes = { - ...DeleteIndexerModalContentConnector.propTypes, - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default DeleteIndexerModal; diff --git a/frontend/src/Indexer/Delete/DeleteIndexerModal.tsx b/frontend/src/Indexer/Delete/DeleteIndexerModal.tsx new file mode 100644 index 000000000..13850aa77 --- /dev/null +++ b/frontend/src/Indexer/Delete/DeleteIndexerModal.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import DeleteIndexerModalContent from './DeleteIndexerModalContent'; + +interface DeleteIndexerModalProps { + isOpen: boolean; + indexerId: number; + onModalClose(): void; +} + +function DeleteIndexerModal(props: DeleteIndexerModalProps) { + const { isOpen, indexerId, onModalClose } = props; + + return ( + + + + ); +} + +export default DeleteIndexerModal; diff --git a/frontend/src/Indexer/Delete/DeleteIndexerModalContent.js b/frontend/src/Indexer/Delete/DeleteIndexerModalContent.js deleted file mode 100644 index e3d46e108..000000000 --- a/frontend/src/Indexer/Delete/DeleteIndexerModalContent.js +++ /dev/null @@ -1,88 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Button from 'Components/Link/Button'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -class DeleteIndexerModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - deleteFiles: false, - addImportExclusion: false - }; - } - - // - // Listeners - - onDeleteFilesChange = ({ value }) => { - this.setState({ deleteFiles: value }); - }; - - onAddImportExclusionChange = ({ value }) => { - this.setState({ addImportExclusion: value }); - }; - - onDeleteMovieConfirmed = () => { - const deleteFiles = this.state.deleteFiles; - const addImportExclusion = this.state.addImportExclusion; - - this.setState({ deleteFiles: false, addImportExclusion: false }); - this.props.onDeletePress(deleteFiles, addImportExclusion); - }; - - // - // Render - - render() { - const { - name, - onModalClose - } = this.props; - - return ( - - - Delete - {name} - - - - {`Are you sure you want to delete ${name} from Prowlarr`} - - - - - - - - - ); - } -} - -DeleteIndexerModalContent.propTypes = { - name: PropTypes.string.isRequired, - onDeletePress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default DeleteIndexerModalContent; diff --git a/frontend/src/Indexer/Delete/DeleteIndexerModalContent.tsx b/frontend/src/Indexer/Delete/DeleteIndexerModalContent.tsx new file mode 100644 index 000000000..aeae273a9 --- /dev/null +++ b/frontend/src/Indexer/Delete/DeleteIndexerModalContent.tsx @@ -0,0 +1,54 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { kinds } from 'Helpers/Props'; +import Indexer from 'Indexer/Indexer'; +import { deleteIndexer } from 'Store/Actions/indexerActions'; +import { createIndexerSelectorForHook } from 'Store/Selectors/createIndexerSelector'; +import translate from 'Utilities/String/translate'; + +interface DeleteIndexerModalContentProps { + indexerId: number; + onModalClose(): void; +} + +function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) { + const { indexerId, onModalClose } = props; + + const { name } = useSelector( + createIndexerSelectorForHook(indexerId) + ) as Indexer; + const dispatch = useDispatch(); + + const onConfirmDelete = useCallback(() => { + dispatch(deleteIndexer({ id: indexerId })); + + onModalClose(); + }, [indexerId, dispatch, onModalClose]); + + return ( + + + {translate('Delete')} - {name} + + + + {translate('AreYouSureYouWantToDeleteIndexer', { name })} + + + + + + + + + ); +} + +export default DeleteIndexerModalContent; diff --git a/frontend/src/Indexer/Delete/DeleteIndexerModalContentConnector.js b/frontend/src/Indexer/Delete/DeleteIndexerModalContentConnector.js deleted file mode 100644 index 1e92eb845..000000000 --- a/frontend/src/Indexer/Delete/DeleteIndexerModalContentConnector.js +++ /dev/null @@ -1,57 +0,0 @@ -import { push } from 'connected-react-router'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { deleteIndexer } from 'Store/Actions/indexerActions'; -import createIndexerSelector from 'Store/Selectors/createIndexerSelector'; -import DeleteIndexerModalContent from './DeleteIndexerModalContent'; - -function createMapStateToProps() { - return createSelector( - createIndexerSelector(), - (indexer) => { - return indexer; - } - ); -} - -const mapDispatchToProps = { - deleteIndexer, - push -}; - -class DeleteIndexerModalContentConnector extends Component { - - // - // Listeners - - onDeletePress = () => { - this.props.deleteIndexer({ - id: this.props.indexerId - }); - - this.props.onModalClose(true); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -DeleteIndexerModalContentConnector.propTypes = { - indexerId: PropTypes.number.isRequired, - onModalClose: PropTypes.func.isRequired, - deleteIndexer: PropTypes.func.isRequired, - push: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(DeleteIndexerModalContentConnector); diff --git a/frontend/src/Indexer/Edit/EditIndexerModalContent.js b/frontend/src/Indexer/Edit/EditIndexerModalContent.js index 4a2ec4c0e..7dabc50d9 100644 --- a/frontend/src/Indexer/Edit/EditIndexerModalContent.js +++ b/frontend/src/Indexer/Edit/EditIndexerModalContent.js @@ -61,7 +61,7 @@ function EditIndexerModalContent(props) { return ( - {`${id ? translate('EditIndexer') : translate('AddIndexer')} - ${indexerDisplayName}`} + {id ? translate('EditIndexerImplementation', { implementationName: indexerDisplayName }) : translate('AddIndexerImplementation', { implementationName: indexerDisplayName })} @@ -97,7 +97,7 @@ function EditIndexerModalContent(props) { @@ -144,6 +144,7 @@ function EditIndexerModalContent(props) { }) : null } + diff --git a/frontend/src/Indexer/Index/IndexerIndex.tsx b/frontend/src/Indexer/Index/IndexerIndex.tsx index dcb7b8c9e..e20e269f8 100644 --- a/frontend/src/Indexer/Index/IndexerIndex.tsx +++ b/frontend/src/Indexer/Index/IndexerIndex.tsx @@ -1,6 +1,16 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { SelectProvider } from 'App/SelectContext'; +import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState'; +import IndexerAppState, { + IndexerIndexAppState, +} from 'App/State/IndexerAppState'; import { APP_INDEXER_SYNC } from 'Commands/commandNames'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; @@ -18,12 +28,17 @@ import AddIndexerModal from 'Indexer/Add/AddIndexerModal'; import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector'; import NoIndexer from 'Indexer/NoIndexer'; import { executeCommand } from 'Store/Actions/commandActions'; -import { testAllIndexers } from 'Store/Actions/indexerActions'; +import { + cloneIndexer, + fetchIndexers, + testAllIndexers, +} from 'Store/Actions/indexerActions'; import { setIndexerFilter, setIndexerSort, setIndexerTableOption, } from 'Store/Actions/indexerIndexActions'; +import { fetchIndexerStatus } from 'Store/Actions/indexerStatusActions'; import scrollPositions from 'Store/scrollPositions'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; @@ -41,9 +56,7 @@ import IndexerIndexTable from './Table/IndexerIndexTable'; import IndexerIndexTableOptions from './Table/IndexerIndexTableOptions'; import styles from './IndexerIndex.css'; -function getViewComponent() { - return IndexerIndexTable; -} +const getViewComponent = () => IndexerIndexTable; interface IndexerIndexProps { initialScrollTop?: number; @@ -64,27 +77,25 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => { sortKey, sortDirection, view, - } = useSelector( - createIndexerClientSideCollectionItemsSelector('indexerIndex') - ); + }: IndexerAppState & IndexerIndexAppState & ClientSideCollectionAppState = + useSelector(createIndexerClientSideCollectionItemsSelector('indexerIndex')); const isSyncingIndexers = useSelector( createCommandExecutingSelector(APP_INDEXER_SYNC) ); const { isSmallScreen } = useSelector(createDimensionsSelector()); const dispatch = useDispatch(); - const scrollerRef = useRef(); + const scrollerRef = useRef(null); const [isAddIndexerModalOpen, setIsAddIndexerModalOpen] = useState(false); const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false); - const [jumpToCharacter, setJumpToCharacter] = useState(null); + const [jumpToCharacter, setJumpToCharacter] = useState( + undefined + ); const [isSelectMode, setIsSelectMode] = useState(false); - const onAppIndexerSyncPress = useCallback(() => { - dispatch( - executeCommand({ - name: APP_INDEXER_SYNC, - }) - ); + useEffect(() => { + dispatch(fetchIndexers()); + dispatch(fetchIndexerStatus()); }, [dispatch]); const onAddIndexerPress = useCallback(() => { @@ -103,6 +114,24 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => { setIsEditIndexerModalOpen(false); }, [setIsEditIndexerModalOpen]); + const onCloneIndexerPress = useCallback( + (id: number) => { + dispatch(cloneIndexer({ id })); + + setIsEditIndexerModalOpen(true); + }, + [dispatch, setIsEditIndexerModalOpen] + ); + + const onAppIndexerSyncPress = useCallback(() => { + dispatch( + executeCommand({ + name: APP_INDEXER_SYNC, + forceSync: true, + }) + ); + }, [dispatch]); + const onTestAllPress = useCallback(() => { dispatch(testAllIndexers()); }, [dispatch]); @@ -112,37 +141,37 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => { }, [isSelectMode, setIsSelectMode]); const onTableOptionChange = useCallback( - (payload) => { + (payload: unknown) => { dispatch(setIndexerTableOption(payload)); }, [dispatch] ); const onSortSelect = useCallback( - (value) => { + (value: string) => { dispatch(setIndexerSort({ sortKey: value })); }, [dispatch] ); const onFilterSelect = useCallback( - (value) => { + (value: string) => { dispatch(setIndexerFilter({ selectedFilterKey: value })); }, [dispatch] ); const onJumpBarItemPress = useCallback( - (character) => { + (character: string) => { setJumpToCharacter(character); }, [setJumpToCharacter] ); const onScroll = useCallback( - ({ scrollTop }) => { - setJumpToCharacter(null); - scrollPositions.seriesIndex = scrollTop; + ({ scrollTop }: { scrollTop: number }) => { + setJumpToCharacter(undefined); + scrollPositions.indexerIndex = scrollTop; }, [setJumpToCharacter] ); @@ -155,7 +184,7 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => { }; } - const characters = items.reduce((acc, item) => { + const characters = items.reduce((acc: Record, item) => { let char = item.sortName.charAt(0); if (!isNaN(Number(char))) { @@ -277,6 +306,8 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => { { jumpToCharacter={jumpToCharacter} isSelectMode={isSelectMode} isSmallScreen={isSmallScreen} + onCloneIndexerPress={onCloneIndexerPress} /> diff --git a/frontend/src/Indexer/Index/IndexerIndexFilterModal.tsx b/frontend/src/Indexer/Index/IndexerIndexFilterModal.tsx index 8a151907a..1b4bfb6de 100644 --- a/frontend/src/Indexer/Index/IndexerIndexFilterModal.tsx +++ b/frontend/src/Indexer/Index/IndexerIndexFilterModal.tsx @@ -1,12 +1,13 @@ import React, { useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; import FilterModal from 'Components/Filter/FilterModal'; import { setIndexerFilter } from 'Store/Actions/indexerIndexActions'; function createIndexerSelector() { return createSelector( - (state) => state.indexers.items, + (state: AppState) => state.indexers.items, (indexers) => { return indexers; } @@ -15,14 +16,20 @@ function createIndexerSelector() { function createFilterBuilderPropsSelector() { return createSelector( - (state) => state.indexerIndex.filterBuilderProps, + (state: AppState) => state.indexerIndex.filterBuilderProps, (filterBuilderProps) => { return filterBuilderProps; } ); } -export default function IndexerIndexFilterModal(props) { +interface IndexerIndexFilterModalProps { + isOpen: boolean; +} + +export default function IndexerIndexFilterModal( + props: IndexerIndexFilterModalProps +) { const sectionItems = useSelector(createIndexerSelector()); const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); const customFilterType = 'indexerIndex'; @@ -30,7 +37,7 @@ export default function IndexerIndexFilterModal(props) { const dispatch = useDispatch(); const dispatchSetFilter = useCallback( - (payload) => { + (payload: unknown) => { dispatch(setIndexerFilter(payload)); }, [dispatch] @@ -38,6 +45,7 @@ export default function IndexerIndexFilterModal(props) { return ( { + (indexers: IndexerAppState) => { return indexers.items.map((s) => { const { protocol, privacy, enable } = s; diff --git a/frontend/src/Indexer/Index/Menus/IndexerIndexFilterMenu.tsx b/frontend/src/Indexer/Index/Menus/IndexerIndexFilterMenu.tsx index 0b6021bad..57ebf7b2f 100644 --- a/frontend/src/Indexer/Index/Menus/IndexerIndexFilterMenu.tsx +++ b/frontend/src/Indexer/Index/Menus/IndexerIndexFilterMenu.tsx @@ -1,10 +1,18 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import { CustomFilter } from 'App/State/AppState'; import FilterMenu from 'Components/Menu/FilterMenu'; import { align } from 'Helpers/Props'; import IndexerIndexFilterModal from 'Indexer/Index/IndexerIndexFilterModal'; -function IndexerIndexFilterMenu(props) { +interface IndexerIndexFilterMenuProps { + selectedFilterKey: string | number; + filters: object[]; + customFilters: CustomFilter[]; + isDisabled: boolean; + onFilterSelect(filterName: string): unknown; +} + +function IndexerIndexFilterMenu(props: IndexerIndexFilterMenuProps) { const { selectedFilterKey, filters, @@ -26,15 +34,6 @@ function IndexerIndexFilterMenu(props) { ); } -IndexerIndexFilterMenu.propTypes = { - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) - .isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - isDisabled: PropTypes.bool.isRequired, - onFilterSelect: PropTypes.func.isRequired, -}; - IndexerIndexFilterMenu.defaultProps = { showCustomFilters: false, }; diff --git a/frontend/src/Indexer/Index/Menus/IndexerIndexSortMenu.tsx b/frontend/src/Indexer/Index/Menus/IndexerIndexSortMenu.tsx index 723db799f..088cbca90 100644 --- a/frontend/src/Indexer/Index/Menus/IndexerIndexSortMenu.tsx +++ b/frontend/src/Indexer/Index/Menus/IndexerIndexSortMenu.tsx @@ -1,12 +1,19 @@ -import PropTypes from 'prop-types'; import React from 'react'; import MenuContent from 'Components/Menu/MenuContent'; import SortMenu from 'Components/Menu/SortMenu'; import SortMenuItem from 'Components/Menu/SortMenuItem'; -import { align, sortDirections } from 'Helpers/Props'; +import { align } from 'Helpers/Props'; +import SortDirection from 'Helpers/Props/SortDirection'; import translate from 'Utilities/String/translate'; -function IndexerIndexSortMenu(props) { +interface IndexerIndexSortMenuProps { + sortKey?: string; + sortDirection?: SortDirection; + isDisabled: boolean; + onSortSelect(sortKey: string): unknown; +} + +function IndexerIndexSortMenu(props: IndexerIndexSortMenuProps) { const { sortKey, sortDirection, isDisabled, onSortSelect } = props; return ( @@ -79,11 +86,4 @@ function IndexerIndexSortMenu(props) { ); } -IndexerIndexSortMenu.propTypes = { - sortKey: PropTypes.string, - sortDirection: PropTypes.oneOf(sortDirections.all), - isDisabled: PropTypes.bool.isRequired, - onSortSelect: PropTypes.func.isRequired, -}; - export default IndexerIndexSortMenu; diff --git a/frontend/src/Indexer/Index/Select/Delete/DeleteIndexerModalContent.tsx b/frontend/src/Indexer/Index/Select/Delete/DeleteIndexerModalContent.tsx index 0e27902fe..0793af82d 100644 --- a/frontend/src/Indexer/Index/Select/Delete/DeleteIndexerModalContent.tsx +++ b/frontend/src/Indexer/Index/Select/Delete/DeleteIndexerModalContent.tsx @@ -7,6 +7,7 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { kinds } from 'Helpers/Props'; +import Indexer from 'Indexer/Indexer'; import { bulkDeleteIndexers } from 'Store/Actions/indexerActions'; import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector'; import translate from 'Utilities/String/translate'; @@ -20,15 +21,15 @@ interface DeleteIndexerModalContentProps { function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) { const { indexerIds, onModalClose } = props; - const allIndexers = useSelector(createAllIndexersSelector()); + const allIndexers: Indexer[] = useSelector(createAllIndexersSelector()); const dispatch = useDispatch(); - const selectedIndexers = useMemo(() => { - const indexers = indexerIds.map((id) => { + const indexers = useMemo((): Indexer[] => { + const indexerList = indexerIds.map((id) => { return allIndexers.find((s) => s.id === id); - }); + }) as Indexer[]; - return orderBy(indexers, ['sortName']); + return orderBy(indexerList, ['sortName']); }, [indexerIds, allIndexers]); const onDeleteIndexerConfirmed = useCallback(() => { @@ -47,13 +48,13 @@ function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) {
- {translate('DeleteSelectedIndexersMessageText', [ - selectedIndexers.length, - ])} + {translate('DeleteSelectedIndexersMessageText', { + count: indexers.length, + })}
    - {selectedIndexers.map((s) => { + {indexers.map((s) => { return (
  • {s.name} diff --git a/frontend/src/Indexer/Index/Select/Edit/EditIndexerModalContent.tsx b/frontend/src/Indexer/Index/Select/Edit/EditIndexerModalContent.tsx index f3bb9cca7..9d42aa389 100644 --- a/frontend/src/Indexer/Index/Select/Edit/EditIndexerModalContent.tsx +++ b/frontend/src/Indexer/Index/Select/Edit/EditIndexerModalContent.tsx @@ -19,6 +19,7 @@ interface SavePayload { seedRatio?: number; seedTime?: number; packSeedTime?: number; + preferMagnetUrl?: boolean; } interface EditIndexerModalContentProps { @@ -30,9 +31,25 @@ interface EditIndexerModalContentProps { const NO_CHANGE = 'noChange'; const enableOptions = [ - { key: NO_CHANGE, value: translate('NoChange'), disabled: true }, - { key: 'true', value: translate('Enabled') }, - { key: 'false', value: translate('Disabled') }, + { + key: NO_CHANGE, + get value() { + return translate('NoChange'); + }, + isDisabled: true, + }, + { + key: 'true', + get value() { + return translate('Enabled'); + }, + }, + { + key: 'false', + get value() { + return translate('Disabled'); + }, + }, ]; function EditIndexerModalContent(props: EditIndexerModalContentProps) { @@ -49,6 +66,9 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { const [packSeedTime, setPackSeedTime] = useState( null ); + const [preferMagnetUrl, setPreferMagnetUrl] = useState< + null | string | boolean + >(null); const save = useCallback(() => { let hasChanges = false; @@ -89,6 +109,11 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { payload.packSeedTime = packSeedTime as number; } + if (preferMagnetUrl !== null) { + hasChanges = true; + payload.preferMagnetUrl = preferMagnetUrl === 'true'; + } + if (hasChanges) { onSavePress(payload); } @@ -102,12 +127,13 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { seedRatio, seedTime, packSeedTime, + preferMagnetUrl, onSavePress, onModalClose, ]); const onInputChange = useCallback( - ({ name, value }) => { + ({ name, value }: { name: string; value: string }) => { switch (name) { case 'enable': setEnable(value); @@ -130,6 +156,9 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { case 'packSeedTime': setPackSeedTime(value); break; + case 'preferMagnetUrl': + setPreferMagnetUrl(value); + break; default: console.warn(`EditIndexersModalContent Unknown Input: '${name}'`); } @@ -208,6 +237,7 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { name="seedRatio" value={seedRatio} helpText={translate('SeedRatioHelpText')} + isFloat={true} onChange={onInputChange} /> @@ -237,11 +267,23 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { onChange={onInputChange} /> + + + {translate('PreferMagnetUrl')} + + +
    - {translate('CountIndexersSelected', [selectedCount])} + {translate('CountIndexersSelected', { count: selectedCount })}
    diff --git a/frontend/src/Indexer/Index/Select/IndexerIndexSelectAllButton.tsx b/frontend/src/Indexer/Index/Select/IndexerIndexSelectAllButton.tsx index bd7682018..d6fc776d6 100644 --- a/frontend/src/Indexer/Index/Select/IndexerIndexSelectAllButton.tsx +++ b/frontend/src/Indexer/Index/Select/IndexerIndexSelectAllButton.tsx @@ -7,7 +7,7 @@ import translate from 'Utilities/String/translate'; interface IndexerIndexSelectAllButtonProps { label: string; isSelectMode: boolean; - overflowComponent: React.FunctionComponent; + overflowComponent: React.FunctionComponent; } function IndexerIndexSelectAllButton(props: IndexerIndexSelectAllButtonProps) { diff --git a/frontend/src/Indexer/Index/Select/IndexerIndexSelectFooter.tsx b/frontend/src/Indexer/Index/Select/IndexerIndexSelectFooter.tsx index 953d0daf9..64fe8c1cb 100644 --- a/frontend/src/Indexer/Index/Select/IndexerIndexSelectFooter.tsx +++ b/frontend/src/Indexer/Index/Select/IndexerIndexSelectFooter.tsx @@ -15,6 +15,16 @@ import EditIndexerModal from './Edit/EditIndexerModal'; import TagsModal from './Tags/TagsModal'; import styles from './IndexerIndexSelectFooter.css'; +interface SavePayload { + enable?: boolean; + appProfileId?: number; + priority?: number; + minimumSeeders?: number; + seedRatio?: number; + seedTime?: number; + packSeedTime?: number; +} + const indexersEditorSelector = createSelector( (state: AppState) => state.indexers, (indexers) => { @@ -60,7 +70,7 @@ function IndexerIndexSelectFooter() { }, [setIsEditModalOpen]); const onSavePress = useCallback( - (payload) => { + (payload: SavePayload) => { setIsSavingIndexer(true); setIsEditModalOpen(false); @@ -83,7 +93,7 @@ function IndexerIndexSelectFooter() { }, [setIsTagsModalOpen]); const onApplyTagsPress = useCallback( - (tags, applyTags) => { + (tags: number[], applyTags: string) => { setIsSavingTags(true); setIsTagsModalOpen(false); @@ -155,7 +165,7 @@ function IndexerIndexSelectFooter() {
    - {translate('CountIndexersSelected', [selectedCount])} + {translate('CountIndexersSelected', { count: selectedCount })}
    ; onPress: () => void; } diff --git a/frontend/src/Indexer/Index/Select/Tags/TagsModalContent.tsx b/frontend/src/Indexer/Index/Select/Tags/TagsModalContent.tsx index 964d9ad57..1964d271c 100644 --- a/frontend/src/Indexer/Index/Select/Tags/TagsModalContent.tsx +++ b/frontend/src/Indexer/Index/Select/Tags/TagsModalContent.tsx @@ -1,6 +1,7 @@ -import { concat, uniq } from 'lodash'; +import { uniq } from 'lodash'; import React, { useCallback, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; +import { Tag } from 'App/State/TagsAppState'; import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; import FormInputGroup from 'Components/Form/FormInputGroup'; @@ -12,6 +13,7 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import Indexer from 'Indexer/Indexer'; import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector'; import createTagsSelector from 'Store/Selectors/createTagsSelector'; import translate from 'Utilities/String/translate'; @@ -26,29 +28,35 @@ interface TagsModalContentProps { function TagsModalContent(props: TagsModalContentProps) { const { indexerIds, onModalClose, onApplyTagsPress } = props; - const allIndexers = useSelector(createAllIndexersSelector()); - const tagList = useSelector(createTagsSelector()); + const allIndexers: Indexer[] = useSelector(createAllIndexersSelector()); + const tagList: Tag[] = useSelector(createTagsSelector()); const [tags, setTags] = useState([]); const [applyTags, setApplyTags] = useState('add'); const indexerTags = useMemo(() => { - const indexers = indexerIds.map((id) => { - return allIndexers.find((s) => s.id === id); - }); + const tags = indexerIds.reduce((acc: number[], id) => { + const s = allIndexers.find((s) => s.id === id); - return uniq(concat(...indexers.map((s) => s.tags))); + if (s) { + acc.push(...s.tags); + } + + return acc; + }, []); + + return uniq(tags); }, [indexerIds, allIndexers]); const onTagsChange = useCallback( - ({ value }) => { + ({ value }: { value: number[] }) => { setTags(value); }, [setTags] ); const onApplyTagsChange = useCallback( - ({ value }) => { + ({ value }: { value: string }) => { setApplyTags(value); }, [setApplyTags] diff --git a/frontend/src/Indexer/Index/Table/CapabilitiesLabel.tsx b/frontend/src/Indexer/Index/Table/CapabilitiesLabel.tsx index 5f742d902..8e30532cc 100644 --- a/frontend/src/Indexer/Index/Table/CapabilitiesLabel.tsx +++ b/frontend/src/Indexer/Index/Table/CapabilitiesLabel.tsx @@ -1,6 +1,8 @@ +import { uniqBy } from 'lodash'; import React from 'react'; import Label from 'Components/Label'; import { IndexerCapabilities } from 'Indexer/Indexer'; +import translate from 'Utilities/String/translate'; interface CapabilitiesLabelProps { capabilities: IndexerCapabilities; @@ -23,17 +25,21 @@ function CapabilitiesLabel(props: CapabilitiesLabelProps) { ); } - const nameList = Array.from( - new Set(filteredList.map((item) => item.name).sort()) + const indexerCategories = uniqBy(filteredList, 'id').sort( + (a, b) => a.id - b.id ); return ( - {nameList.map((category) => { - return ; + {indexerCategories.map((category) => { + return ( + + ); })} - {filteredList.length === 0 ? : null} + {filteredList.length === 0 ? : null} ); } diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexRow.css b/frontend/src/Indexer/Index/Table/IndexerIndexRow.css index a0a0daee4..a20efded3 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexRow.css +++ b/frontend/src/Indexer/Index/Table/IndexerIndexRow.css @@ -11,6 +11,12 @@ flex: 0 0 60px; } +.id { + composes: cell; + + flex: 0 0 60px; +} + .sortName { composes: cell; @@ -23,7 +29,8 @@ .minimumSeeders, .seedRatio, .seedTime, -.packSeedTime { +.packSeedTime, +.preferMagnetUrl { composes: cell; flex: 0 0 90px; diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexRow.css.d.ts b/frontend/src/Indexer/Index/Table/IndexerIndexRow.css.d.ts index c5d22cf6d..42821bd74 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexRow.css.d.ts +++ b/frontend/src/Indexer/Index/Table/IndexerIndexRow.css.d.ts @@ -8,8 +8,10 @@ interface CssExports { 'cell': string; 'checkInput': string; 'externalLink': string; + 'id': string; 'minimumSeeders': string; 'packSeedTime': string; + 'preferMagnetUrl': string; 'priority': string; 'privacy': string; 'protocol': string; diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexRow.tsx b/frontend/src/Indexer/Index/Table/IndexerIndexRow.tsx index 5325028e9..e4c3cd32e 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexRow.tsx +++ b/frontend/src/Indexer/Index/Table/IndexerIndexRow.tsx @@ -1,9 +1,9 @@ import React, { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; import { useSelect } from 'App/SelectContext'; -import Label from 'Components/Label'; +import CheckInput from 'Components/Form/CheckInput'; import IconButton from 'Components/Link/IconButton'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; import Column from 'Components/Table/Column'; @@ -12,11 +12,13 @@ import { icons } from 'Helpers/Props'; import DeleteIndexerModal from 'Indexer/Delete/DeleteIndexerModal'; import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector'; import createIndexerIndexItemSelector from 'Indexer/Index/createIndexerIndexItemSelector'; +import Indexer from 'Indexer/Indexer'; import IndexerTitleLink from 'Indexer/IndexerTitleLink'; -import firstCharToUpper from 'Utilities/String/firstCharToUpper'; +import { SelectStateInputProps } from 'typings/props'; import translate from 'Utilities/String/translate'; import CapabilitiesLabel from './CapabilitiesLabel'; import IndexerStatusCell from './IndexerStatusCell'; +import PrivacyLabel from './PrivacyLabel'; import ProtocolLabel from './ProtocolLabel'; import styles from './IndexerIndexRow.css'; @@ -25,13 +27,14 @@ interface IndexerIndexRowProps { sortKey: string; columns: Column[]; isSelectMode: boolean; + onCloneIndexerPress(id: number): void; } function IndexerIndexRow(props: IndexerIndexRowProps) { - const { indexerId, columns, isSelectMode } = props; + const { indexerId, columns, isSelectMode, onCloneIndexerPress } = props; const { indexer, appProfile, status, longDateFormat, timeFormat } = - useSelector(createIndexerIndexItemSelector(props.indexerId)); + useSelector(createIndexerIndexItemSelector(indexerId)); const { id, @@ -46,7 +49,7 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { fields, added, capabilities, - } = indexer; + } = indexer as Indexer; const baseUrl = fields.find((field) => field.name === 'baseUrl')?.value ?? @@ -72,6 +75,10 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { fields.find((field) => field.name === 'torrentBaseSettings.packSeedTime') ?.value ?? undefined; + const preferMagnetUrl = + fields.find((field) => field.name === 'torrentBaseSettings.preferMagnetUrl') + ?.value ?? undefined; + const rssUrl = `${window.location.origin}${ window.Prowlarr.urlBase }/${id}/api?apikey=${encodeURIComponent( @@ -105,7 +112,7 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { }, []); const onSelectedChange = useCallback( - ({ id, value, shiftKey }) => { + ({ id, value, shiftKey }: SelectStateInputProps) => { selectDispatch({ type: 'toggleSelected', id, @@ -149,12 +156,25 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { ); } + if (name === 'id') { + return ( + + + + ); + } + if (name === 'sortName') { return ( ); @@ -163,7 +183,7 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { if (name === 'privacy') { return ( - + ); } @@ -202,7 +222,9 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { if (name === 'added') { return ( - + {preferMagnetUrl === undefined ? null : ( + + )} + + ); + } + if (name === 'actions') { return ( ; + scrollerRef: RefObject; isSelectMode: boolean; isSmallScreen: boolean; + onCloneIndexerPress(id: number): void; } const columnsSelector = createSelector( - (state) => state.indexerIndex.columns, + (state: AppState) => state.indexerIndex.columns, (columns) => columns ); -const Row: React.FC> = ({ - index, - style, - data, -}) => { - const { items, sortKey, columns, isSelectMode } = data; +function Row({ index, style, data }: ListChildComponentProps) { + const { items, sortKey, columns, isSelectMode, onCloneIndexerPress } = data; if (index >= items.length) { return null; @@ -64,16 +62,18 @@ const Row: React.FC> = ({ justifyContent: 'space-between', ...style, }} + className={styles.row} >
); -}; +} function getWindowScrollTopPosition() { return document.documentElement.scrollTop || document.body.scrollTop || 0; @@ -88,25 +88,25 @@ function IndexerIndexTable(props: IndexerIndexTableProps) { isSelectMode, isSmallScreen, scrollerRef, + onCloneIndexerPress, } = props; const columns = useSelector(columnsSelector); - const { showBanners } = useSelector(selectTableOptions); - const listRef = useRef(null); + const listRef = useRef>(null); const [measureRef, bounds] = useMeasure(); const [size, setSize] = useState({ width: 0, height: 0 }); + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; - const rowHeight = useMemo(() => { - return showBanners ? 70 : 38; - }, [showBanners]); + const rowHeight = 38; useEffect(() => { - const current = scrollerRef.current as HTMLElement; + const current = scrollerRef?.current as HTMLElement; if (isSmallScreen) { setSize({ - width: window.innerWidth, - height: window.innerHeight, + width: windowWidth, + height: windowHeight, }); return; @@ -119,10 +119,10 @@ function IndexerIndexTable(props: IndexerIndexTableProps) { setSize({ width: width - padding * 2, - height: window.innerHeight, + height: windowHeight, }); } - }, [isSmallScreen, scrollerRef, bounds]); + }, [isSmallScreen, windowWidth, windowHeight, scrollerRef, bounds]); useEffect(() => { const currentScrollerRef = scrollerRef.current as HTMLElement; @@ -165,7 +165,7 @@ function IndexerIndexTable(props: IndexerIndexTableProps) { } listRef.current?.scrollTo(scrollTop); - scrollerRef.current?.scrollTo(0, scrollTop); + scrollerRef?.current?.scrollTo(0, scrollTop); } } }, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]); @@ -177,7 +177,6 @@ function IndexerIndexTable(props: IndexerIndexTableProps) { scrollDirection={ScrollDirection.Horizontal} > {Row} diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css b/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css index 90ad3b0e9..839cd49ff 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css +++ b/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css @@ -4,6 +4,12 @@ flex: 0 0 60px; } +.id { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 60px; +} + .sortName { composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; @@ -16,7 +22,8 @@ .minimumSeeders, .seedRatio, .seedTime, -.packSeedTime { +.packSeedTime, +.preferMagnetUrl { composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; flex: 0 0 90px; diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css.d.ts b/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css.d.ts index 94d39a9bb..020d61f27 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css.d.ts +++ b/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css.d.ts @@ -5,8 +5,10 @@ interface CssExports { 'added': string; 'appProfileId': string; 'capabilities': string; + 'id': string; 'minimumSeeders': string; 'packSeedTime': string; + 'preferMagnetUrl': string; 'priority': string; 'privacy': string; 'protocol': string; diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.tsx b/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.tsx index 965451fcf..908be76b5 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.tsx +++ b/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.tsx @@ -14,12 +14,11 @@ import { setIndexerSort, setIndexerTableOption, } from 'Store/Actions/indexerIndexActions'; -import { SelectStateInputProps } from 'typings/props'; +import { CheckInputChanged } from 'typings/inputs'; import IndexerIndexTableOptions from './IndexerIndexTableOptions'; import styles from './IndexerIndexTableHeader.css'; interface IndexerIndexTableHeaderProps { - showBanners: boolean; columns: Column[]; sortKey?: string; sortDirection?: SortDirection; @@ -32,21 +31,21 @@ function IndexerIndexTableHeader(props: IndexerIndexTableHeaderProps) { const [selectState, selectDispatch] = useSelect(); const onSortPress = useCallback( - (value) => { + (value: string) => { dispatch(setIndexerSort({ sortKey: value })); }, [dispatch] ); const onTableOptionChange = useCallback( - (payload) => { + (payload: unknown) => { dispatch(setIndexerTableOption(payload)); }, [dispatch] ); const onSelectAllChange = useCallback( - ({ value }: SelectStateInputProps) => { + ({ value }: CheckInputChanged) => { selectDispatch({ type: value ? 'selectAll' : 'unselectAll', }); @@ -93,14 +92,18 @@ function IndexerIndexTableHeader(props: IndexerIndexTableHeaderProps) { return ( - {label} + {typeof label === 'function' ? label() : label} ); })} diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexTableOptions.tsx b/frontend/src/Indexer/Index/Table/IndexerIndexTableOptions.tsx index aa4611b97..3aa087790 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexTableOptions.tsx +++ b/frontend/src/Indexer/Index/Table/IndexerIndexTableOptions.tsx @@ -1,9 +1,10 @@ -import React, { Fragment, useCallback } from 'react'; +import React, { useCallback } from 'react'; import { useSelector } from 'react-redux'; import FormGroup from 'Components/Form/FormGroup'; import FormInputGroup from 'Components/Form/FormInputGroup'; import FormLabel from 'Components/Form/FormLabel'; import { inputTypes } from 'Helpers/Props'; +import { CheckInputChanged } from 'typings/inputs'; import translate from 'Utilities/String/translate'; import selectTableOptions from './selectTableOptions'; @@ -19,7 +20,7 @@ function IndexerIndexTableOptions(props: IndexerIndexTableOptionsProps) { const { showSearchAction } = tableOptions; const onTableOptionChangeWrapper = useCallback( - ({ name, value }) => { + ({ name, value }: CheckInputChanged) => { onTableOptionChange({ tableOptions: { ...tableOptions, @@ -31,19 +32,17 @@ function IndexerIndexTableOptions(props: IndexerIndexTableOptionsProps) { ); return ( - - - {translate('ShowSearch')} + + {translate('ShowSearch')} - - - + + ); } diff --git a/frontend/src/Indexer/Index/Table/IndexerStatusCell.tsx b/frontend/src/Indexer/Index/Table/IndexerStatusCell.tsx index 7a5f5cc10..1a2350302 100644 --- a/frontend/src/Indexer/Index/Table/IndexerStatusCell.tsx +++ b/frontend/src/Indexer/Index/Table/IndexerStatusCell.tsx @@ -8,11 +8,35 @@ import translate from 'Utilities/String/translate'; import DisabledIndexerInfo from './DisabledIndexerInfo'; import styles from './IndexerStatusCell.css'; +function getIconKind(enabled: boolean, redirect: boolean) { + if (enabled) { + return redirect ? kinds.INFO : kinds.SUCCESS; + } + + return kinds.DEFAULT; +} + +function getIconName(enabled: boolean, redirect: boolean) { + if (enabled) { + return redirect ? icons.REDIRECT : icons.CHECK; + } + + return icons.BLOCKLIST; +} + +function getIconTooltip(enabled: boolean, redirect: boolean) { + if (enabled) { + return redirect ? translate('EnabledRedirected') : translate('Enabled'); + } + + return translate('Disabled'); +} + interface IndexerStatusCellProps { className: string; enabled: boolean; redirect: boolean; - status: IndexerStatus; + status?: IndexerStatus; longDateFormat: string; timeFormat: string; component?: React.ElementType; @@ -30,22 +54,14 @@ function IndexerStatusCell(props: IndexerStatusCellProps) { ...otherProps } = props; - const enableKind = redirect ? kinds.INFO : kinds.SUCCESS; - const enableIcon = redirect ? icons.REDIRECT : icons.CHECK; - const enableTitle = redirect - ? translate('EnabledRedirected') - : translate('Enabled'); - return ( - { - - } + {status ? ( + {translate(firstCharToUpper(privacy))} + + ); +} + +export default PrivacyLabel; diff --git a/frontend/src/Indexer/Index/Table/ProtocolLabel.css b/frontend/src/Indexer/Index/Table/ProtocolLabel.css index 110c7e01c..c94e383b1 100644 --- a/frontend/src/Indexer/Index/Table/ProtocolLabel.css +++ b/frontend/src/Indexer/Index/Table/ProtocolLabel.css @@ -11,3 +11,7 @@ border-color: var(--usenetColor); background-color: var(--usenetColor); } + +.unknown { + composes: label from '~Components/Label.css'; +} diff --git a/frontend/src/Indexer/Index/Table/ProtocolLabel.css.d.ts b/frontend/src/Indexer/Index/Table/ProtocolLabel.css.d.ts index f3b389e3d..ba0cb260d 100644 --- a/frontend/src/Indexer/Index/Table/ProtocolLabel.css.d.ts +++ b/frontend/src/Indexer/Index/Table/ProtocolLabel.css.d.ts @@ -2,6 +2,7 @@ // Please do not change this file! interface CssExports { 'torrent': string; + 'unknown': string; 'usenet': string; } export const cssExports: CssExports; diff --git a/frontend/src/Indexer/Index/Table/ProtocolLabel.tsx b/frontend/src/Indexer/Index/Table/ProtocolLabel.tsx index d1318678d..c1824452a 100644 --- a/frontend/src/Indexer/Index/Table/ProtocolLabel.tsx +++ b/frontend/src/Indexer/Index/Table/ProtocolLabel.tsx @@ -1,14 +1,13 @@ import React from 'react'; import Label from 'Components/Label'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; import styles from './ProtocolLabel.css'; interface ProtocolLabelProps { - protocol: string; + protocol: DownloadProtocol; } -function ProtocolLabel(props: ProtocolLabelProps) { - const { protocol } = props; - +function ProtocolLabel({ protocol }: ProtocolLabelProps) { const protocolName = protocol === 'usenet' ? 'nzb' : protocol; return ; diff --git a/frontend/src/Indexer/Index/Table/selectTableOptions.ts b/frontend/src/Indexer/Index/Table/selectTableOptions.ts index 1578c2cf8..56a00866d 100644 --- a/frontend/src/Indexer/Index/Table/selectTableOptions.ts +++ b/frontend/src/Indexer/Index/Table/selectTableOptions.ts @@ -1,7 +1,8 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; const selectTableOptions = createSelector( - (state) => state.indexerIndex.tableOptions, + (state: AppState) => state.indexerIndex.tableOptions, (tableOptions) => tableOptions ); diff --git a/frontend/src/Indexer/Index/createIndexerIndexItemSelector.ts b/frontend/src/Indexer/Index/createIndexerIndexItemSelector.ts index 12d042f7a..4d6b9d803 100644 --- a/frontend/src/Indexer/Index/createIndexerIndexItemSelector.ts +++ b/frontend/src/Indexer/Index/createIndexerIndexItemSelector.ts @@ -1,26 +1,16 @@ import { createSelector } from 'reselect'; -import Indexer from 'Indexer/Indexer'; import createIndexerAppProfileSelector from 'Store/Selectors/createIndexerAppProfileSelector'; -import createIndexerSelector from 'Store/Selectors/createIndexerSelector'; +import { createIndexerSelectorForHook } from 'Store/Selectors/createIndexerSelector'; import createIndexerStatusSelector from 'Store/Selectors/createIndexerStatusSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; function createIndexerIndexItemSelector(indexerId: number) { return createSelector( - createIndexerSelector(indexerId), + createIndexerSelectorForHook(indexerId), createIndexerAppProfileSelector(indexerId), createIndexerStatusSelector(indexerId), createUISettingsSelector(), - (indexer: Indexer, appProfile, status, uiSettings) => { - // If a series is deleted this selector may fire before the parent - // selectors, which will result in an undefined series, if that happens - // we want to return early here and again in the render function to avoid - // trying to show a series that has no information available. - - if (!indexer) { - return {}; - } - + (indexer, appProfile, status, uiSettings) => { return { indexer, appProfile, diff --git a/frontend/src/Indexer/Indexer.ts b/frontend/src/Indexer/Indexer.ts index 5ce83264b..c363d472c 100644 --- a/frontend/src/Indexer/Indexer.ts +++ b/frontend/src/Indexer/Indexer.ts @@ -1,4 +1,5 @@ import ModelBase from 'App/ModelBase'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; export interface IndexerStatus extends ModelBase { disabledTill: Date; @@ -24,30 +25,46 @@ export interface IndexerCapabilities extends ModelBase { categories: IndexerCategory[]; } +export type IndexerPrivacy = 'public' | 'semiPrivate' | 'private'; + export interface IndexerField extends ModelBase { + order: number; name: string; label: string; advanced: boolean; type: string; value: string; + privacy: string; } interface Indexer extends ModelBase { name: string; + definitionName: string; description: string; encoding: string; language: string; added: Date; enable: boolean; redirect: boolean; - protocol: string; - privacy: string; + supportsRss: boolean; + supportsSearch: boolean; + supportsRedirect: boolean; + supportsPagination: boolean; + protocol: DownloadProtocol; + privacy: IndexerPrivacy; priority: number; fields: IndexerField[]; tags: number[]; + sortName: string; status: IndexerStatus; capabilities: IndexerCapabilities; indexerUrls: string[]; + legacyUrls: string[]; + appProfileId: number; + implementationName: string; + implementation: string; + configContract: string; + infoLink: string; } export default Indexer; diff --git a/frontend/src/Indexer/IndexerTitleLink.tsx b/frontend/src/Indexer/IndexerTitleLink.tsx index 82f294d69..be6e32db3 100644 --- a/frontend/src/Indexer/IndexerTitleLink.tsx +++ b/frontend/src/Indexer/IndexerTitleLink.tsx @@ -1,16 +1,16 @@ -import PropTypes from 'prop-types'; import React, { useCallback, useState } from 'react'; import Link from 'Components/Link/Link'; import IndexerInfoModal from './Info/IndexerInfoModal'; import styles from './IndexerTitleLink.css'; interface IndexerTitleLinkProps { - indexerName: string; indexerId: number; + title: string; + onCloneIndexerPress(id: number): void; } function IndexerTitleLink(props: IndexerTitleLinkProps) { - const { indexerName, indexerId } = props; + const { title, indexerId, onCloneIndexerPress } = props; const [isIndexerInfoModalOpen, setIsIndexerInfoModalOpen] = useState(false); @@ -25,20 +25,17 @@ function IndexerTitleLink(props: IndexerTitleLinkProps) { return (
- {indexerName} + {title}
); } -IndexerTitleLink.propTypes = { - indexerName: PropTypes.string.isRequired, -}; - export default IndexerTitleLink; diff --git a/frontend/src/Indexer/Info/History/IndexerHistory.tsx b/frontend/src/Indexer/Info/History/IndexerHistory.tsx new file mode 100644 index 000000000..8cf62bde8 --- /dev/null +++ b/frontend/src/Indexer/Info/History/IndexerHistory.tsx @@ -0,0 +1,139 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import { IndexerHistoryAppState } from 'App/State/IndexerAppState'; +import Alert from 'Components/Alert'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import { kinds } from 'Helpers/Props'; +import Indexer from 'Indexer/Indexer'; +import { + clearIndexerHistory, + fetchIndexerHistory, +} from 'Store/Actions/indexerHistoryActions'; +import { createIndexerSelectorForHook } from 'Store/Selectors/createIndexerSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import translate from 'Utilities/String/translate'; +import IndexerHistoryRow from './IndexerHistoryRow'; + +const columns = [ + { + name: 'eventType', + isVisible: true, + }, + { + name: 'query', + label: () => translate('Query'), + isVisible: true, + }, + { + name: 'parameters', + label: () => translate('Parameters'), + isVisible: true, + }, + { + name: 'date', + label: () => translate('Date'), + isVisible: true, + }, + { + name: 'source', + label: () => translate('Source'), + isVisible: true, + }, + { + name: 'details', + label: () => translate('Details'), + isVisible: true, + }, +]; + +function createIndexerHistorySelector() { + return createSelector( + (state: AppState) => state.indexerHistory, + createUISettingsSelector(), + (state: AppState) => state.history.pageSize, + (indexerHistory: IndexerHistoryAppState, uiSettings, pageSize) => { + return { + ...indexerHistory, + shortDateFormat: uiSettings.shortDateFormat, + timeFormat: uiSettings.timeFormat, + pageSize, + }; + } + ); +} + +interface IndexerHistoryProps { + indexerId: number; +} + +function IndexerHistory(props: IndexerHistoryProps) { + const { + isFetching, + isPopulated, + error, + items, + shortDateFormat, + timeFormat, + pageSize, + } = useSelector(createIndexerHistorySelector()); + + const indexer = useSelector( + createIndexerSelectorForHook(props.indexerId) + ) as Indexer; + + const dispatch = useDispatch(); + + useEffect(() => { + dispatch( + fetchIndexerHistory({ indexerId: props.indexerId, limit: pageSize }) + ); + + return () => { + dispatch(clearIndexerHistory()); + }; + }, [props, pageSize, dispatch]); + + const hasItems = !!items.length; + + if (isFetching) { + return ; + } + + if (!isFetching && !!error) { + return ( + {translate('IndexerHistoryLoadError')} + ); + } + + if (isPopulated && !hasItems && !error) { + return {translate('NoIndexerHistory')}; + } + + if (isPopulated && hasItems && !error) { + return ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ); + } + + return null; +} + +export default IndexerHistory; diff --git a/frontend/src/Indexer/Info/History/IndexerHistoryRow.css b/frontend/src/Indexer/Info/History/IndexerHistoryRow.css new file mode 100644 index 000000000..d8bba1fe7 --- /dev/null +++ b/frontend/src/Indexer/Info/History/IndexerHistoryRow.css @@ -0,0 +1,23 @@ +.query { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 120px; +} + +.elapsedTime, +.source { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.details { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 70px; +} + +.parametersContent { + display: flex; + flex-wrap: wrap; +} diff --git a/frontend/src/Indexer/Info/History/IndexerHistoryRow.css.d.ts b/frontend/src/Indexer/Info/History/IndexerHistoryRow.css.d.ts new file mode 100644 index 000000000..28da0e31c --- /dev/null +++ b/frontend/src/Indexer/Info/History/IndexerHistoryRow.css.d.ts @@ -0,0 +1,11 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'details': string; + 'elapsedTime': string; + 'parametersContent': string; + 'query': string; + 'source': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Indexer/Info/History/IndexerHistoryRow.tsx b/frontend/src/Indexer/Info/History/IndexerHistoryRow.tsx new file mode 100644 index 000000000..28d45654c --- /dev/null +++ b/frontend/src/Indexer/Info/History/IndexerHistoryRow.tsx @@ -0,0 +1,106 @@ +import React, { useCallback, useState } from 'react'; +import IconButton from 'Components/Link/IconButton'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRow from 'Components/Table/TableRow'; +import { icons } from 'Helpers/Props'; +import HistoryDetailsModal from 'History/Details/HistoryDetailsModal'; +import HistoryEventTypeCell from 'History/HistoryEventTypeCell'; +import { historyParameters } from 'History/HistoryRow'; +import HistoryRowParameter from 'History/HistoryRowParameter'; +import Indexer from 'Indexer/Indexer'; +import { HistoryData } from 'typings/History'; +import translate from 'Utilities/String/translate'; +import styles from './IndexerHistoryRow.css'; + +interface IndexerHistoryRowProps { + data: HistoryData; + date: string; + eventType: string; + successful: boolean; + indexer: Indexer; + shortDateFormat: string; + timeFormat: string; +} + +function IndexerHistoryRow(props: IndexerHistoryRowProps) { + const { + data, + date, + eventType, + successful, + indexer, + shortDateFormat, + timeFormat, + } = props; + + const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); + + const onDetailsModalPress = useCallback(() => { + setIsDetailsModalOpen(true); + }, [setIsDetailsModalOpen]); + + const onDetailsModalClose = useCallback(() => { + setIsDetailsModalOpen(false); + }, [setIsDetailsModalOpen]); + + const parameters = historyParameters.filter( + (parameter) => + parameter.key in data && data[parameter.key as keyof HistoryData] + ); + + return ( + + + + {data.query} + + +
+ {parameters.map((parameter) => { + return ( + + ); + })} +
+
+ + + + + {data.source ? data.source : null} + + + + + + + +
+ ); +} + +export default IndexerHistoryRow; diff --git a/frontend/src/Indexer/Info/IndexerInfoModal.tsx b/frontend/src/Indexer/Info/IndexerInfoModal.tsx index df2ead86d..c15af5247 100644 --- a/frontend/src/Indexer/Info/IndexerInfoModal.tsx +++ b/frontend/src/Indexer/Info/IndexerInfoModal.tsx @@ -7,16 +7,18 @@ interface IndexerInfoModalProps { isOpen: boolean; indexerId: number; onModalClose(): void; + onCloneIndexerPress(id: number): void; } function IndexerInfoModal(props: IndexerInfoModalProps) { - const { isOpen, onModalClose, indexerId } = props; + const { isOpen, indexerId, onModalClose, onCloneIndexerPress } = props; return ( - + ); diff --git a/frontend/src/Indexer/Info/IndexerInfoModalContent.css b/frontend/src/Indexer/Info/IndexerInfoModalContent.css index 84fe0a573..9e8b59a88 100644 --- a/frontend/src/Indexer/Info/IndexerInfoModalContent.css +++ b/frontend/src/Indexer/Info/IndexerInfoModalContent.css @@ -9,3 +9,41 @@ margin-right: auto; } + +.tabs { + margin-top: -32px; +} + +.tabList { + margin: 0; + padding: 0; +} + +.tab { + position: relative; + bottom: -1px; + display: inline-block; + padding: 6px 12px; + border: 1px solid transparent; + border-top: none; + list-style: none; + cursor: pointer; +} + +.selectedTab { + border-color: var(--borderColor); + border-radius: 0 0 5px 5px; + background-color: rgba(239, 239, 239, 0.4); + color: var(--black); +} + +.tabContent { + margin-top: 20px; +} + +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + justify-content: space-between; +} + diff --git a/frontend/src/Indexer/Info/IndexerInfoModalContent.css.d.ts b/frontend/src/Indexer/Info/IndexerInfoModalContent.css.d.ts index 48f09127f..c9f832fd9 100644 --- a/frontend/src/Indexer/Info/IndexerInfoModalContent.css.d.ts +++ b/frontend/src/Indexer/Info/IndexerInfoModalContent.css.d.ts @@ -3,6 +3,12 @@ interface CssExports { 'deleteButton': string; 'description': string; + 'modalFooter': string; + 'selectedTab': string; + 'tab': string; + 'tabContent': string; + 'tabList': string; + 'tabs': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Indexer/Info/IndexerInfoModalContent.tsx b/frontend/src/Indexer/Info/IndexerInfoModalContent.tsx index 9056f70f5..344d91a98 100644 --- a/frontend/src/Indexer/Info/IndexerInfoModalContent.tsx +++ b/frontend/src/Indexer/Info/IndexerInfoModalContent.tsx @@ -1,6 +1,7 @@ +import { uniqBy } from 'lodash'; import React, { useCallback, useState } from 'react'; -import { useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; +import { Tab, TabList, TabPanel, Tabs } from 'react-tabs'; +import Alert from 'Components/Alert'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; @@ -21,31 +22,23 @@ import TagListConnector from 'Components/TagListConnector'; import { kinds } from 'Helpers/Props'; import DeleteIndexerModal from 'Indexer/Delete/DeleteIndexerModal'; import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector'; -import Indexer from 'Indexer/Indexer'; -import createIndexerSelector from 'Store/Selectors/createIndexerSelector'; +import PrivacyLabel from 'Indexer/Index/Table/PrivacyLabel'; +import Indexer, { IndexerCapabilities } from 'Indexer/Indexer'; +import useIndexer from 'Indexer/useIndexer'; import translate from 'Utilities/String/translate'; +import IndexerHistory from './History/IndexerHistory'; import styles from './IndexerInfoModalContent.css'; -function createIndexerInfoItemSelector(indexerId: number) { - return createSelector( - createIndexerSelector(indexerId), - (indexer: Indexer) => { - return { - indexer, - }; - } - ); -} +const TABS = ['details', 'categories', 'history', 'stats']; interface IndexerInfoModalContentProps { indexerId: number; onModalClose(): void; + onCloneIndexerPress(id: number): void; } function IndexerInfoModalContent(props: IndexerInfoModalContentProps) { - const { indexer } = useSelector( - createIndexerInfoItemSelector(props.indexerId) - ); + const { indexerId, onModalClose, onCloneIndexerPress } = props; const { id, @@ -57,265 +50,329 @@ function IndexerInfoModalContent(props: IndexerInfoModalContentProps) { fields, tags, protocol, - capabilities, - } = indexer; + privacy, + capabilities = {} as IndexerCapabilities, + } = useIndexer(indexerId) as Indexer; - const { onModalClose } = props; + const [selectedTab, setSelectedTab] = useState(TABS[0]); + const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false); + const [isDeleteIndexerModalOpen, setIsDeleteIndexerModalOpen] = + useState(false); + + const handleTabSelect = useCallback( + (selectedIndex: number) => { + const selectedTab = TABS[selectedIndex]; + setSelectedTab(selectedTab); + }, + [setSelectedTab] + ); + + const handleEditIndexerPress = useCallback(() => { + setIsEditIndexerModalOpen(true); + }, [setIsEditIndexerModalOpen]); + + const handleEditIndexerModalClose = useCallback(() => { + setIsEditIndexerModalOpen(false); + }, [setIsEditIndexerModalOpen]); + + const handleDeleteIndexerPress = useCallback(() => { + setIsEditIndexerModalOpen(false); + setIsDeleteIndexerModalOpen(true); + }, [setIsDeleteIndexerModalOpen]); + + const handleDeleteIndexerModalClose = useCallback(() => { + setIsDeleteIndexerModalOpen(false); + onModalClose(); + }, [setIsDeleteIndexerModalOpen, onModalClose]); + + const handleCloneIndexerPressWrapper = useCallback(() => { + onCloneIndexerPress(id); + onModalClose(); + }, [id, onCloneIndexerPress, onModalClose]); const baseUrl = fields.find((field) => field.name === 'baseUrl')?.value ?? (Array.isArray(indexerUrls) ? indexerUrls[0] : undefined); + const indexerUrl = baseUrl?.replace(/(:\/\/)api\./, '$1'); + const vipExpiration = fields.find((field) => field.name === 'vipExpiration')?.value ?? undefined; - const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false); - const [isDeleteIndexerModalOpen, setIsDeleteIndexerModalOpen] = - useState(false); - - const onEditIndexerPress = useCallback(() => { - setIsEditIndexerModalOpen(true); - }, [setIsEditIndexerModalOpen]); - - const onEditIndexerModalClose = useCallback(() => { - setIsEditIndexerModalOpen(false); - }, [setIsEditIndexerModalOpen]); - - const onDeleteIndexerPress = useCallback(() => { - setIsEditIndexerModalOpen(false); - setIsDeleteIndexerModalOpen(true); - }, [setIsDeleteIndexerModalOpen]); - - const onDeleteIndexerModalClose = useCallback(() => { - setIsDeleteIndexerModalOpen(false); - onModalClose(); - }, [setIsDeleteIndexerModalOpen, onModalClose]); - return ( {`${name}`} -
-
- - - - - - {vipExpiration ? ( - - ) : null} - - {translate('IndexerSite')} - - - - {baseUrl.replace(/(:\/\/)api\./, '$1')} - - - - {protocol === 'usenet' - ? translate('NewznabUrl') - : translate('TorznabUrl')} - - - {`${window.location.origin}${window.Prowlarr.urlBase}/${id}/api`} - - {tags.length > 0 ? ( - <> - - {translate('Tags')} - - - - - - ) : null} - -
-
+ + + + {translate('Details')} + -
-
- - - - {capabilities.searchParams[0]} - - ) - } - /> - { - return ( - - ); - }) - } - /> - { - return ( - - ); - }) - } - /> - { - return ( - - ); - }) - } - /> - { - return ( - - ); - }) - } - /> - -
-
+ + {translate('Categories')} + - {capabilities.categories !== null && - capabilities.categories.length > 0 ? ( -
- - {capabilities.categories - .sort((a, b) => a.id - b.id) - .map((category) => { - return ( - - - {category.id} - {category.name} - - {category.subCategories !== null && - category.subCategories.length > 0 - ? category.subCategories - .sort((a, b) => a.id - b.id) - .map((subCategory) => { + + {translate('History')} + + + +
+
+
+ + + + + + : '-'} + /> + {vipExpiration ? ( + + ) : null} + + {translate('IndexerSite')} + + + {indexerUrl ? ( + {indexerUrl} + ) : ( + '-' + )} + + + {protocol === 'usenet' + ? translate('NewznabUrl') + : translate('TorznabUrl')} + + + {`${window.location.origin}${window.Prowlarr.urlBase}/${id}/api`} + + {tags.length > 0 ? ( + <> + + {translate('Tags')} + + + + + + ) : null} + +
+
+ +
+
+ + + 0 ? ( + + ) : ( + translate('NotSupported') + ) + } + /> + 0 + ? capabilities.tvSearchParams.map((p) => { return ( - - {subCategory.id} - - {subCategory.name} - - + ); }) - : null} - - ); - })} -
-
- ) : null} + : translate('NotSupported') + } + /> + 0 + ? capabilities.movieSearchParams.map((p) => { + return ( + + ); + }) + : translate('NotSupported') + } + /> + 0 + ? capabilities.bookSearchParams.map((p) => { + return ( + + ); + }) + : translate('NotSupported') + } + /> + 0 + ? capabilities.musicSearchParams.map((p) => { + return ( + + ); + }) + : translate('NotSupported') + } + /> + +
+ +
+ + +
+ {capabilities?.categories?.length > 0 ? ( +
+ + {uniqBy(capabilities.categories, 'id') + .sort((a, b) => a.id - b.id) + .map((category) => { + return ( + + + {category.id} + {category.name} + + {category?.subCategories?.length > 0 + ? uniqBy(category.subCategories, 'id') + .sort((a, b) => a.id - b.id) + .map((subCategory) => { + return ( + + + {subCategory.id} + + + {subCategory.name} + + + ); + }) + : null} + + ); + })} +
+
+ ) : ( + + {translate('NoIndexerCategories')} + + )} +
+
+ +
+ +
+
+ - - - - + +
+ + +
+
+ + +
); diff --git a/frontend/src/Indexer/NoIndexer.css b/frontend/src/Indexer/NoIndexer.css index 38a01f391..4ad534de3 100644 --- a/frontend/src/Indexer/NoIndexer.css +++ b/frontend/src/Indexer/NoIndexer.css @@ -1,4 +1,6 @@ .message { + composes: alert from '~Components/Alert.css'; + margin-top: 10px; margin-bottom: 30px; text-align: center; diff --git a/frontend/src/Indexer/NoIndexer.js b/frontend/src/Indexer/NoIndexer.tsx similarity index 54% rename from frontend/src/Indexer/NoIndexer.js rename to frontend/src/Indexer/NoIndexer.tsx index f94df7902..bf5afa1fe 100644 --- a/frontend/src/Indexer/NoIndexer.js +++ b/frontend/src/Indexer/NoIndexer.tsx @@ -1,23 +1,23 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import Alert from 'Components/Alert'; import Button from 'Components/Link/Button'; import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import styles from './NoIndexer.css'; -function NoIndexer(props) { - const { - totalItems, - onAddIndexerPress - } = props; +interface NoIndexerProps { + totalItems: number; + onAddIndexerPress(): void; +} + +function NoIndexer(props: NoIndexerProps) { + const { totalItems, onAddIndexerPress } = props; if (totalItems > 0) { return ( -
-
- {translate('AllIndexersHiddenDueToFilter')} -
-
+ + {translate('AllIndexersHiddenDueToFilter')} + ); } @@ -28,10 +28,7 @@ function NoIndexer(props) {
-
@@ -39,9 +36,4 @@ function NoIndexer(props) { ); } -NoIndexer.propTypes = { - totalItems: PropTypes.number.isRequired, - onAddIndexerPress: PropTypes.func.isRequired -}; - export default NoIndexer; diff --git a/frontend/src/Indexer/Stats/IndexerStats.css b/frontend/src/Indexer/Stats/IndexerStats.css new file mode 100644 index 000000000..975f5ddae --- /dev/null +++ b/frontend/src/Indexer/Stats/IndexerStats.css @@ -0,0 +1,52 @@ +.fullWidthChart { + display: inline-block; + width: 100%; +} + +.halfWidthChart { + display: inline-block; + width: 50%; +} + +.quarterWidthChart { + display: inline-block; + width: 25%; +} + +.chartContainer { + margin: 5px; + padding: 15px 25px; + height: 300px; + border-radius: 10px; + background-color: var(--chartBackgroundColor); +} + +.statContainer { + margin: 5px; + padding: 15px 25px; + height: 150px; + border-radius: 10px; + background-color: var(--chartBackgroundColor); +} + +.statTitle { + font-weight: bold; + font-size: 14px; +} + +.stat { + font-weight: bold; + font-size: 60px; +} + +@media only screen and (max-width: $breakpointSmall) { + .halfWidthChart { + display: inline-block; + width: 100%; + } + + .quarterWidthChart { + display: inline-block; + width: 100%; + } +} diff --git a/frontend/src/Indexer/Stats/IndexerStats.css.d.ts b/frontend/src/Indexer/Stats/IndexerStats.css.d.ts new file mode 100644 index 000000000..e2aae98c6 --- /dev/null +++ b/frontend/src/Indexer/Stats/IndexerStats.css.d.ts @@ -0,0 +1,13 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'chartContainer': string; + 'fullWidthChart': string; + 'halfWidthChart': string; + 'quarterWidthChart': string; + 'stat': string; + 'statContainer': string; + 'statTitle': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Indexer/Stats/IndexerStats.tsx b/frontend/src/Indexer/Stats/IndexerStats.tsx new file mode 100644 index 000000000..bccd49cbe --- /dev/null +++ b/frontend/src/Indexer/Stats/IndexerStats.tsx @@ -0,0 +1,373 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import IndexerStatsAppState from 'App/State/IndexerStatsAppState'; +import Alert from 'Components/Alert'; +import BarChart from 'Components/Chart/BarChart'; +import DoughnutChart from 'Components/Chart/DoughnutChart'; +import StackedBarChart from 'Components/Chart/StackedBarChart'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import { align, icons, kinds } from 'Helpers/Props'; +import { + fetchIndexerStats, + setIndexerStatsFilter, +} from 'Store/Actions/indexerStatsActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; +import { + IndexerStatsHost, + IndexerStatsIndexer, + IndexerStatsUserAgent, +} from 'typings/IndexerStats'; +import abbreviateNumber from 'Utilities/Number/abbreviateNumber'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import IndexerStatsFilterModal from './IndexerStatsFilterModal'; +import styles from './IndexerStats.css'; + +function getAverageResponseTimeData(indexerStats: IndexerStatsIndexer[]) { + const statistics = [...indexerStats].sort((a, b) => + a.averageResponseTime === b.averageResponseTime + ? b.averageGrabResponseTime - a.averageGrabResponseTime + : b.averageResponseTime - a.averageResponseTime + ); + + return { + labels: statistics.map((indexer) => indexer.indexerName), + datasets: [ + { + label: translate('AverageQueries'), + data: statistics.map((indexer) => indexer.averageResponseTime), + }, + { + label: translate('AverageGrabs'), + data: statistics.map((indexer) => indexer.averageGrabResponseTime), + }, + ], + }; +} + +function getFailureRateData(indexerStats: IndexerStatsIndexer[]) { + const data = [...indexerStats] + .map((indexer) => ({ + label: indexer.indexerName, + value: + (indexer.numberOfFailedQueries + + indexer.numberOfFailedRssQueries + + indexer.numberOfFailedAuthQueries + + indexer.numberOfFailedGrabs) / + (indexer.numberOfQueries + + indexer.numberOfRssQueries + + indexer.numberOfAuthQueries + + indexer.numberOfGrabs), + })) + .filter((s) => s.value > 0); + + data.sort((a, b) => b.value - a.value); + + return data; +} + +function getTotalRequestsData(indexerStats: IndexerStatsIndexer[]) { + const statistics = [...indexerStats] + .filter( + (s) => + s.numberOfQueries > 0 || + s.numberOfRssQueries > 0 || + s.numberOfAuthQueries > 0 + ) + .sort( + (a, b) => + b.numberOfQueries + + b.numberOfRssQueries + + b.numberOfAuthQueries - + (a.numberOfQueries + a.numberOfRssQueries + a.numberOfAuthQueries) + ); + + return { + labels: statistics.map((indexer) => indexer.indexerName), + datasets: [ + { + label: translate('SearchQueries'), + data: statistics.map((indexer) => indexer.numberOfQueries), + }, + { + label: translate('RssQueries'), + data: statistics.map((indexer) => indexer.numberOfRssQueries), + }, + { + label: translate('AuthQueries'), + data: statistics.map((indexer) => indexer.numberOfAuthQueries), + }, + ], + }; +} + +function getNumberGrabsData(indexerStats: IndexerStatsIndexer[]) { + const data = [...indexerStats] + .map((indexer) => ({ + label: indexer.indexerName, + value: indexer.numberOfGrabs - indexer.numberOfFailedGrabs, + })) + .filter((s) => s.value > 0); + + data.sort((a, b) => b.value - a.value); + + return data; +} + +function getUserAgentGrabsData(indexerStats: IndexerStatsUserAgent[]) { + const data = indexerStats.map((indexer) => ({ + label: indexer.userAgent ? indexer.userAgent : 'Other', + value: indexer.numberOfGrabs, + })); + + data.sort((a, b) => b.value - a.value); + + return data; +} + +function getUserAgentQueryData(indexerStats: IndexerStatsUserAgent[]) { + const data = indexerStats.map((indexer) => ({ + label: indexer.userAgent ? indexer.userAgent : 'Other', + value: indexer.numberOfQueries, + })); + + data.sort((a, b) => b.value - a.value); + + return data; +} + +function getHostGrabsData(indexerStats: IndexerStatsHost[]) { + const data = indexerStats.map((indexer) => ({ + label: indexer.host ? indexer.host : 'Other', + value: indexer.numberOfGrabs, + })); + + data.sort((a, b) => b.value - a.value); + + return data; +} + +function getHostQueryData(indexerStats: IndexerStatsHost[]) { + const data = indexerStats.map((indexer) => ({ + label: indexer.host ? indexer.host : 'Other', + value: indexer.numberOfQueries, + })); + + data.sort((a, b) => b.value - a.value); + + return data; +} + +const indexerStatsSelector = () => { + return createSelector( + (state: AppState) => state.indexerStats, + createCustomFiltersSelector('indexerStats'), + (indexerStats: IndexerStatsAppState, customFilters) => { + return { + ...indexerStats, + customFilters, + }; + } + ); +}; + +function IndexerStats() { + const { + isFetching, + isPopulated, + item, + error, + filters, + customFilters, + selectedFilterKey, + } = useSelector(indexerStatsSelector()); + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(fetchIndexerStats()); + }, [dispatch]); + + const onRefreshPress = useCallback(() => { + dispatch(fetchIndexerStats()); + }, [dispatch]); + + const onFilterSelect = useCallback( + (value: string) => { + dispatch(setIndexerStatsFilter({ selectedFilterKey: value })); + }, + [dispatch] + ); + + const isLoaded = !error && isPopulated; + const indexerCount = item.indexers?.length ?? 0; + const userAgentCount = item.userAgents?.length ?? 0; + const queryCount = + item.indexers?.reduce((total, indexer) => { + return ( + total + + indexer.numberOfQueries + + indexer.numberOfRssQueries + + indexer.numberOfAuthQueries + ); + }, 0) ?? 0; + const grabCount = + item.indexers?.reduce((total, indexer) => { + return total + indexer.numberOfGrabs; + }, 0) ?? 0; + + return ( + + + + + + + + + + + + {isFetching && !isPopulated && } + + {!isFetching && !!error && ( + + {getErrorMessage(error, 'Failed to load indexer stats from API')} + + )} + + {isLoaded && ( +
+
+
+
+ {translate('ActiveIndexers')} +
+
{indexerCount}
+
+
+
+
+
+ {translate('TotalQueries')} +
+
+ {abbreviateNumber(queryCount)} +
+
+
+
+
+
+ {translate('TotalGrabs')} +
+
{abbreviateNumber(grabCount)}
+
+
+
+
+
+ {translate('ActiveApps')} +
+
{userAgentCount}
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+ )} +
+
+ ); +} + +export default IndexerStats; diff --git a/frontend/src/Indexer/Stats/IndexerStatsFilterModal.tsx b/frontend/src/Indexer/Stats/IndexerStatsFilterModal.tsx new file mode 100644 index 000000000..6e3a49dfb --- /dev/null +++ b/frontend/src/Indexer/Stats/IndexerStatsFilterModal.tsx @@ -0,0 +1,56 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setIndexerStatsFilter } from 'Store/Actions/indexerStatsActions'; + +function createIndexerStatsSelector() { + return createSelector( + (state: AppState) => state.indexerStats.item, + (indexerStats) => { + return indexerStats; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.indexerStats.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface IndexerStatsFilterModalProps { + isOpen: boolean; +} + +export default function IndexerStatsFilterModal( + props: IndexerStatsFilterModalProps +) { + const sectionItems = [useSelector(createIndexerStatsSelector())]; + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'indexerStats'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setIndexerStatsFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Indexer/Stats/Stats.css b/frontend/src/Indexer/Stats/Stats.css deleted file mode 100644 index 249dcc448..000000000 --- a/frontend/src/Indexer/Stats/Stats.css +++ /dev/null @@ -1,22 +0,0 @@ -.fullWidthChart { - display: inline-block; - padding: 15px 25px; - width: 100%; - height: 300px; -} - -.halfWidthChart { - display: inline-block; - padding: 15px 25px; - width: 50%; - height: 300px; -} - -@media only screen and (max-width: $breakpointSmall) { - .halfWidthChart { - display: inline-block; - padding: 15px 25px; - width: 100%; - height: 300px; - } -} diff --git a/frontend/src/Indexer/Stats/Stats.js b/frontend/src/Indexer/Stats/Stats.js deleted file mode 100644 index 30bff5f84..000000000 --- a/frontend/src/Indexer/Stats/Stats.js +++ /dev/null @@ -1,261 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Alert from 'Components/Alert'; -import BarChart from 'Components/Chart/BarChart'; -import DoughnutChart from 'Components/Chart/DoughnutChart'; -import StackedBarChart from 'Components/Chart/StackedBarChart'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; -import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; -import { align, kinds } from 'Helpers/Props'; -import getErrorMessage from 'Utilities/Object/getErrorMessage'; -import translate from 'Utilities/String/translate'; -import StatsFilterMenu from './StatsFilterMenu'; -import styles from './Stats.css'; - -function getAverageResponseTimeData(indexerStats) { - const data = indexerStats.map((indexer) => { - return { - label: indexer.indexerName, - value: indexer.averageResponseTime - }; - }); - - data.sort((a, b) => { - return b.value - a.value; - }); - - return data; -} - -function getFailureRateData(indexerStats) { - const data = indexerStats.map((indexer) => { - return { - label: indexer.indexerName, - value: (indexer.numberOfFailedQueries + indexer.numberOfFailedRssQueries + indexer.numberOfFailedAuthQueries + indexer.numberOfFailedGrabs) / - (indexer.numberOfQueries + indexer.numberOfRssQueries + indexer.numberOfAuthQueries + indexer.numberOfGrabs) - }; - }); - - data.sort((a, b) => { - return b.value - a.value; - }); - - return data; -} - -function getTotalRequestsData(indexerStats) { - const data = { - labels: indexerStats.map((indexer) => indexer.indexerName), - datasets: [ - { - label: 'Search Queries', - data: indexerStats.map((indexer) => indexer.numberOfQueries) - }, - { - label: 'Rss Queries', - data: indexerStats.map((indexer) => indexer.numberOfRssQueries) - }, - { - label: 'Auth Queries', - data: indexerStats.map((indexer) => indexer.numberOfAuthQueries) - } - ] - }; - - return data; -} - -function getNumberGrabsData(indexerStats) { - const data = indexerStats.map((indexer) => { - return { - label: indexer.indexerName, - value: indexer.numberOfGrabs - indexer.numberOfFailedGrabs - }; - }); - - data.sort((a, b) => { - return b.value - a.value; - }); - - return data; -} - -function getUserAgentGrabsData(indexerStats) { - const data = indexerStats.map((indexer) => { - return { - label: indexer.userAgent ? indexer.userAgent : 'Other', - value: indexer.numberOfGrabs - }; - }); - - data.sort((a, b) => { - return b.value - a.value; - }); - - return data; -} - -function getUserAgentQueryData(indexerStats) { - const data = indexerStats.map((indexer) => { - return { - label: indexer.userAgent ? indexer.userAgent : 'Other', - value: indexer.numberOfQueries - }; - }); - - data.sort((a, b) => { - return b.value - a.value; - }); - - return data; -} - -function getHostGrabsData(indexerStats) { - const data = indexerStats.map((indexer) => { - return { - label: indexer.host ? indexer.host : 'Other', - value: indexer.numberOfGrabs - }; - }); - - data.sort((a, b) => { - return b.value - a.value; - }); - - return data; -} - -function getHostQueryData(indexerStats) { - const data = indexerStats.map((indexer) => { - return { - label: indexer.host ? indexer.host : 'Other', - value: indexer.numberOfQueries - }; - }); - - data.sort((a, b) => { - return b.value - a.value; - }); - - return data; -} - -function Stats(props) { - const { - item, - isFetching, - isPopulated, - error, - filters, - selectedFilterKey, - onFilterSelect - } = props; - - const isLoaded = !!(!error && isPopulated); - - return ( - - - - - - - - { - isFetching && !isPopulated && - - } - - { - !isFetching && !!error && - - {getErrorMessage(error, 'Failed to load indexer stats from API')} - - } - - { - isLoaded && -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- } -
-
- ); -} - -Stats.propTypes = { - item: PropTypes.object.isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - selectedFilterKey: PropTypes.string.isRequired, - onFilterSelect: PropTypes.func.isRequired, - error: PropTypes.object, - data: PropTypes.object -}; - -export default Stats; diff --git a/frontend/src/Indexer/Stats/StatsConnector.js b/frontend/src/Indexer/Stats/StatsConnector.js deleted file mode 100644 index 006716953..000000000 --- a/frontend/src/Indexer/Stats/StatsConnector.js +++ /dev/null @@ -1,51 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchIndexerStats, setIndexerStatsFilter } from 'Store/Actions/indexerStatsActions'; -import Stats from './Stats'; - -function createMapStateToProps() { - return createSelector( - (state) => state.indexerStats, - (indexerStats) => indexerStats - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onFilterSelect(selectedFilterKey) { - dispatch(setIndexerStatsFilter({ selectedFilterKey })); - }, - dispatchFetchIndexerStats() { - dispatch(fetchIndexerStats()); - } - }; -} - -class StatsConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.dispatchFetchIndexerStats(); - } - - // - // Render - - render() { - return ( - - ); - } -} - -StatsConnector.propTypes = { - dispatchFetchIndexerStats: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, createMapDispatchToProps)(StatsConnector); diff --git a/frontend/src/Indexer/Stats/StatsFilterMenu.js b/frontend/src/Indexer/Stats/StatsFilterMenu.js deleted file mode 100644 index 283159b7e..000000000 --- a/frontend/src/Indexer/Stats/StatsFilterMenu.js +++ /dev/null @@ -1,37 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import FilterMenu from 'Components/Menu/FilterMenu'; -import { align } from 'Helpers/Props'; - -function StatsFilterMenu(props) { - const { - selectedFilterKey, - filters, - isDisabled, - onFilterSelect - } = props; - - return ( - - ); -} - -StatsFilterMenu.propTypes = { - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - isDisabled: PropTypes.bool.isRequired, - onFilterSelect: PropTypes.func.isRequired -}; - -StatsFilterMenu.defaultProps = { - showCustomFilters: false -}; - -export default StatsFilterMenu; diff --git a/frontend/src/Indexer/Stats/StatsFilterModalConnector.js b/frontend/src/Indexer/Stats/StatsFilterModalConnector.js deleted file mode 100644 index 53bf2ed3c..000000000 --- a/frontend/src/Indexer/Stats/StatsFilterModalConnector.js +++ /dev/null @@ -1,24 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import FilterModal from 'Components/Filter/FilterModal'; -import { setIndexerStatsFilter } from 'Store/Actions/indexerStatsActions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.indexerStats.items, - (state) => state.indexerStats.filterBuilderProps, - (sectionItems, filterBuilderProps) => { - return { - sectionItems, - filterBuilderProps, - customFilterType: 'indexerStats' - }; - } - ); -} - -const mapDispatchToProps = { - dispatchSetFilter: setIndexerStatsFilter -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Indexer/useIndexer.ts b/frontend/src/Indexer/useIndexer.ts new file mode 100644 index 000000000..a1b2ffa9d --- /dev/null +++ b/frontend/src/Indexer/useIndexer.ts @@ -0,0 +1,19 @@ +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +export function createIndexerSelector(indexerId?: number) { + return createSelector( + (state: AppState) => state.indexers.itemMap, + (state: AppState) => state.indexers.items, + (itemMap, allIndexers) => { + return indexerId ? allIndexers[itemMap[indexerId]] : undefined; + } + ); +} + +function useIndexer(indexerId?: number) { + return useSelector(createIndexerSelector(indexerId)); +} + +export default useIndexer; diff --git a/frontend/src/Search/Menus/SearchIndexFilterMenu.tsx b/frontend/src/Search/Menus/SearchIndexFilterMenu.tsx index 52806ff83..9a8c243b4 100644 --- a/frontend/src/Search/Menus/SearchIndexFilterMenu.tsx +++ b/frontend/src/Search/Menus/SearchIndexFilterMenu.tsx @@ -1,10 +1,18 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import { CustomFilter } from 'App/State/AppState'; import FilterMenu from 'Components/Menu/FilterMenu'; import { align } from 'Helpers/Props'; import SearchIndexFilterModalConnector from 'Search/SearchIndexFilterModalConnector'; -function SearchIndexFilterMenu(props) { +interface SearchIndexFilterMenuProps { + selectedFilterKey: string | number; + filters: object[]; + customFilters: CustomFilter[]; + isDisabled: boolean; + onFilterSelect(filterName: string): unknown; +} + +function SearchIndexFilterMenu(props: SearchIndexFilterMenuProps) { const { selectedFilterKey, filters, @@ -26,15 +34,6 @@ function SearchIndexFilterMenu(props) { ); } -SearchIndexFilterMenu.propTypes = { - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) - .isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - isDisabled: PropTypes.bool.isRequired, - onFilterSelect: PropTypes.func.isRequired, -}; - SearchIndexFilterMenu.defaultProps = { showCustomFilters: false, }; diff --git a/frontend/src/Search/Menus/SearchIndexSortMenu.tsx b/frontend/src/Search/Menus/SearchIndexSortMenu.tsx index 302ef6a10..af4042283 100644 --- a/frontend/src/Search/Menus/SearchIndexSortMenu.tsx +++ b/frontend/src/Search/Menus/SearchIndexSortMenu.tsx @@ -1,12 +1,19 @@ -import PropTypes from 'prop-types'; import React from 'react'; import MenuContent from 'Components/Menu/MenuContent'; import SortMenu from 'Components/Menu/SortMenu'; import SortMenuItem from 'Components/Menu/SortMenuItem'; -import { align, sortDirections } from 'Helpers/Props'; +import { align } from 'Helpers/Props'; +import SortDirection from 'Helpers/Props/SortDirection'; import translate from 'Utilities/String/translate'; -function SearchIndexSortMenu(props) { +interface SearchIndexSortMenuProps { + sortKey?: string; + sortDirection?: SortDirection; + isDisabled: boolean; + onSortSelect(sortKey: string): unknown; +} + +function SearchIndexSortMenu(props: SearchIndexSortMenuProps) { const { sortKey, sortDirection, isDisabled, onSortSelect } = props; return ( @@ -97,11 +104,4 @@ function SearchIndexSortMenu(props) { ); } -SearchIndexSortMenu.propTypes = { - sortKey: PropTypes.string, - sortDirection: PropTypes.oneOf(sortDirections.all), - isDisabled: PropTypes.bool.isRequired, - onSortSelect: PropTypes.func.isRequired, -}; - export default SearchIndexSortMenu; diff --git a/frontend/src/Search/Mobile/SearchIndexOverview.css b/frontend/src/Search/Mobile/SearchIndexOverview.css index 4e184bd0a..e29ff1ef9 100644 --- a/frontend/src/Search/Mobile/SearchIndexOverview.css +++ b/frontend/src/Search/Mobile/SearchIndexOverview.css @@ -47,3 +47,42 @@ $hoverScale: 1.05; right: 0; white-space: nowrap; } + +.downloadLink { + composes: link from '~Components/Link/Link.css'; + + margin: 0 2px; + width: 22px; + color: var(--textColor); + text-align: center; +} + +.manualDownloadContent { + position: relative; + display: inline-block; + margin: 0 2px; + width: 22px; + height: 20.39px; + vertical-align: middle; + line-height: 20.39px; + + &:hover { + color: var(--iconButtonHoverColor); + } +} + +.interactiveIcon { + position: absolute; + top: 4px; + left: 0; + /* width: 100%; */ + text-align: center; +} + +.downloadIcon { + position: absolute; + top: 7px; + left: 8px; + /* width: 100%; */ + text-align: center; +} diff --git a/frontend/src/Search/Mobile/SearchIndexOverview.css.d.ts b/frontend/src/Search/Mobile/SearchIndexOverview.css.d.ts index 266cf7fca..68256eb25 100644 --- a/frontend/src/Search/Mobile/SearchIndexOverview.css.d.ts +++ b/frontend/src/Search/Mobile/SearchIndexOverview.css.d.ts @@ -4,9 +4,13 @@ interface CssExports { 'actions': string; 'container': string; 'content': string; + 'downloadIcon': string; + 'downloadLink': string; 'indexerRow': string; 'info': string; 'infoRow': string; + 'interactiveIcon': string; + 'manualDownloadContent': string; 'title': string; 'titleRow': string; } diff --git a/frontend/src/Search/Mobile/SearchIndexOverview.js b/frontend/src/Search/Mobile/SearchIndexOverview.js deleted file mode 100644 index 7294d1f6c..000000000 --- a/frontend/src/Search/Mobile/SearchIndexOverview.js +++ /dev/null @@ -1,202 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import TextTruncate from 'react-text-truncate'; -import Label from 'Components/Label'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import { icons, kinds } from 'Helpers/Props'; -import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel'; -import CategoryLabel from 'Search/Table/CategoryLabel'; -import Peers from 'Search/Table/Peers'; -import dimensions from 'Styles/Variables/dimensions'; -import formatAge from 'Utilities/Number/formatAge'; -import formatBytes from 'Utilities/Number/formatBytes'; -import translate from 'Utilities/String/translate'; -import styles from './SearchIndexOverview.css'; - -const columnPadding = parseInt(dimensions.movieIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen); - -function getContentHeight(rowHeight, isSmallScreen) { - const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; - - return rowHeight - padding; -} - -function getDownloadIcon(isGrabbing, isGrabbed, grabError) { - if (isGrabbing) { - return icons.SPINNER; - } else if (isGrabbed) { - return icons.DOWNLOADING; - } else if (grabError) { - return icons.DOWNLOADING; - } - - return icons.DOWNLOAD; -} - -function getDownloadTooltip(isGrabbing, isGrabbed, grabError) { - if (isGrabbing) { - return ''; - } else if (isGrabbed) { - return translate('AddedToDownloadClient'); - } else if (grabError) { - return grabError; - } - - return translate('AddToDownloadClient'); -} - -class SearchIndexOverview extends Component { - - // - // Listeners - - onGrabPress = () => { - const { - guid, - indexerId, - onGrabPress - } = this.props; - - onGrabPress({ - guid, - indexerId - }); - }; - - // - // Render - - render() { - const { - title, - infoUrl, - protocol, - downloadUrl, - categories, - seeders, - leechers, - size, - age, - ageHours, - ageMinutes, - indexer, - rowHeight, - isSmallScreen, - isGrabbed, - isGrabbing, - grabError - } = this.props; - - const contentHeight = getContentHeight(rowHeight, isSmallScreen); - - return ( -
-
-
-
-
- - - - -
- -
- - - -
-
-
- {indexer} -
-
- - - { - protocol === 'torrent' && - - } - - - - - - -
-
-
-
- ); - } -} - -SearchIndexOverview.propTypes = { - guid: PropTypes.string.isRequired, - categories: PropTypes.arrayOf(PropTypes.object).isRequired, - protocol: PropTypes.string.isRequired, - age: PropTypes.number.isRequired, - ageHours: PropTypes.number.isRequired, - ageMinutes: PropTypes.number.isRequired, - publishDate: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - infoUrl: PropTypes.string.isRequired, - downloadUrl: PropTypes.string.isRequired, - indexerId: PropTypes.number.isRequired, - indexer: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - files: PropTypes.number, - grabs: PropTypes.number, - seeders: PropTypes.number, - leechers: PropTypes.number, - indexerFlags: PropTypes.arrayOf(PropTypes.string).isRequired, - rowHeight: PropTypes.number.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - onGrabPress: PropTypes.func.isRequired, - isGrabbing: PropTypes.bool.isRequired, - isGrabbed: PropTypes.bool.isRequired, - grabError: PropTypes.string -}; - -SearchIndexOverview.defaultProps = { - isGrabbing: false, - isGrabbed: false -}; - -export default SearchIndexOverview; diff --git a/frontend/src/Search/Mobile/SearchIndexOverview.tsx b/frontend/src/Search/Mobile/SearchIndexOverview.tsx new file mode 100644 index 000000000..21a42d70c --- /dev/null +++ b/frontend/src/Search/Mobile/SearchIndexOverview.tsx @@ -0,0 +1,264 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import TextTruncate from 'react-text-truncate'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import { icons, kinds } from 'Helpers/Props'; +import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel'; +import { IndexerCategory } from 'Indexer/Indexer'; +import OverrideMatchModal from 'Search/OverrideMatch/OverrideMatchModal'; +import CategoryLabel from 'Search/Table/CategoryLabel'; +import Peers from 'Search/Table/Peers'; +import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector'; +import dimensions from 'Styles/Variables/dimensions'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import formatBytes from 'Utilities/Number/formatBytes'; +import titleCase from 'Utilities/String/titleCase'; +import translate from 'Utilities/String/translate'; +import styles from './SearchIndexOverview.css'; + +const columnPadding = parseInt(dimensions.movieIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.movieIndexColumnPaddingSmallScreen +); + +function getDownloadIcon( + isGrabbing: boolean, + isGrabbed: boolean, + grabError?: string +) { + if (isGrabbing) { + return icons.SPINNER; + } else if (isGrabbed) { + return icons.DOWNLOADING; + } else if (grabError) { + return icons.DOWNLOADING; + } + + return icons.DOWNLOAD; +} + +function getDownloadKind(isGrabbed: boolean, grabError?: string) { + if (isGrabbed) { + return kinds.SUCCESS; + } + + if (grabError) { + return kinds.DANGER; + } + + return kinds.DEFAULT; +} + +function getDownloadTooltip( + isGrabbing: boolean, + isGrabbed: boolean, + grabError?: string +) { + if (isGrabbing) { + return ''; + } else if (isGrabbed) { + return translate('AddedToDownloadClient'); + } else if (grabError) { + return grabError; + } + + return translate('AddToDownloadClient'); +} + +interface SearchIndexOverviewProps { + guid: string; + protocol: DownloadProtocol; + age: number; + ageHours: number; + ageMinutes: number; + publishDate: string; + title: string; + infoUrl: string; + downloadUrl?: string; + magnetUrl?: string; + indexerId: number; + indexer: string; + categories: IndexerCategory[]; + size: number; + seeders?: number; + leechers?: number; + indexerFlags: string[]; + isGrabbing: boolean; + isGrabbed: boolean; + grabError?: string; + longDateFormat: string; + timeFormat: string; + rowHeight: number; + isSmallScreen: boolean; + onGrabPress(...args: unknown[]): void; +} + +function SearchIndexOverview(props: SearchIndexOverviewProps) { + const { + guid, + indexerId, + protocol, + categories, + age, + ageHours, + ageMinutes, + publishDate, + title, + infoUrl, + downloadUrl, + magnetUrl, + indexer, + size, + seeders, + leechers, + indexerFlags = [], + isGrabbing = false, + isGrabbed = false, + grabError, + longDateFormat, + timeFormat, + rowHeight, + isSmallScreen, + onGrabPress, + } = props; + + const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false); + + const { items: downloadClients } = useSelector( + createEnabledDownloadClientsSelector(protocol) + ); + + const onGrabPressWrapper = useCallback(() => { + onGrabPress({ + guid, + indexerId, + }); + }, [guid, indexerId, onGrabPress]); + + const onOverridePress = useCallback(() => { + setIsOverrideModalOpen(true); + }, [setIsOverrideModalOpen]); + + const onOverrideModalClose = useCallback(() => { + setIsOverrideModalOpen(false); + }, [setIsOverrideModalOpen]); + + const contentHeight = useMemo(() => { + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + + return rowHeight - padding; + }, [rowHeight, isSmallScreen]); + + return ( + <> +
+
+
+
+
+ + + +
+ +
+ + + {downloadClients.length > 1 ? ( + +
+ + + +
+ + ) : null} + + {downloadUrl || magnetUrl ? ( + + ) : null} +
+
+
{indexer}
+
+ + + {protocol === 'torrent' && ( + + )} + + + + + + + + {indexerFlags.length + ? indexerFlags + .sort((a, b) => + a.localeCompare(b, undefined, { numeric: true }) + ) + .map((flag, index) => { + return ( + + ); + }) + : null} +
+
+
+
+ + + + ); +} + +export default SearchIndexOverview; diff --git a/frontend/src/Search/Mobile/SearchIndexOverviews.js b/frontend/src/Search/Mobile/SearchIndexOverviews.js index 7fadb51e0..671c8e9fd 100644 --- a/frontend/src/Search/Mobile/SearchIndexOverviews.js +++ b/frontend/src/Search/Mobile/SearchIndexOverviews.js @@ -195,7 +195,7 @@ class SearchIndexOverviews extends Component { SearchIndexOverviews.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, sortKey: PropTypes.string, - scrollTop: PropTypes.number.isRequired, + scrollTop: PropTypes.number, jumpToCharacter: PropTypes.string, scroller: PropTypes.instanceOf(Element).isRequired, showRelativeDates: PropTypes.bool.isRequired, diff --git a/frontend/src/Search/NoSearchResults.css b/frontend/src/Search/NoSearchResults.css index eff6272f7..f17dd633e 100644 --- a/frontend/src/Search/NoSearchResults.css +++ b/frontend/src/Search/NoSearchResults.css @@ -1,4 +1,6 @@ .message { + composes: alert from '~Components/Alert.css'; + margin-top: 10px; margin-bottom: 30px; text-align: center; diff --git a/frontend/src/Search/NoSearchResults.js b/frontend/src/Search/NoSearchResults.js deleted file mode 100644 index 03fce4be9..000000000 --- a/frontend/src/Search/NoSearchResults.js +++ /dev/null @@ -1,32 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import translate from 'Utilities/String/translate'; -import styles from './NoSearchResults.css'; - -function NoSearchResults(props) { - const { totalItems } = props; - - if (totalItems > 0) { - return ( -
-
- {translate('AllIndexersHiddenDueToFilter')} -
-
- ); - } - - return ( -
-
- {translate('NoSearchResultsFound')} -
-
- ); -} - -NoSearchResults.propTypes = { - totalItems: PropTypes.number.isRequired -}; - -export default NoSearchResults; diff --git a/frontend/src/Search/NoSearchResults.tsx b/frontend/src/Search/NoSearchResults.tsx new file mode 100644 index 000000000..46fbc85e0 --- /dev/null +++ b/frontend/src/Search/NoSearchResults.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import Alert from 'Components/Alert'; +import { kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './NoSearchResults.css'; + +interface NoSearchResultsProps { + totalItems: number; +} + +function NoSearchResults(props: NoSearchResultsProps) { + const { totalItems } = props; + + if (totalItems > 0) { + return ( + + {translate('AllSearchResultsHiddenByFilter')} + + ); + } + + return ( + + {translate('NoSearchResultsFound')} + + ); +} + +export default NoSearchResults; diff --git a/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx new file mode 100644 index 000000000..7d623decd --- /dev/null +++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import { sizes } from 'Helpers/Props'; +import SelectDownloadClientModalContent from './SelectDownloadClientModalContent'; + +interface SelectDownloadClientModalProps { + isOpen: boolean; + protocol: DownloadProtocol; + modalTitle: string; + onDownloadClientSelect(downloadClientId: number): void; + onModalClose(): void; +} + +function SelectDownloadClientModal(props: SelectDownloadClientModalProps) { + const { isOpen, protocol, modalTitle, onDownloadClientSelect, onModalClose } = + props; + + return ( + + + + ); +} + +export default SelectDownloadClientModal; diff --git a/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx new file mode 100644 index 000000000..63e15808f --- /dev/null +++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import Alert from 'Components/Alert'; +import Form from 'Components/Form/Form'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import { kinds } from 'Helpers/Props'; +import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector'; +import translate from 'Utilities/String/translate'; +import SelectDownloadClientRow from './SelectDownloadClientRow'; + +interface SelectDownloadClientModalContentProps { + protocol: DownloadProtocol; + modalTitle: string; + onDownloadClientSelect(downloadClientId: number): void; + onModalClose(): void; +} + +function SelectDownloadClientModalContent( + props: SelectDownloadClientModalContentProps +) { + const { modalTitle, protocol, onDownloadClientSelect, onModalClose } = props; + + const { isFetching, isPopulated, error, items } = useSelector( + createEnabledDownloadClientsSelector(protocol) + ); + + return ( + + + {translate('SelectDownloadClientModalTitle', { modalTitle })} + + + + {isFetching ? : null} + + {!isFetching && error ? ( + + {translate('DownloadClientsLoadError')} + + ) : null} + + {isPopulated && !error ? ( +
+ {items.map((downloadClient) => { + const { id, name, priority } = downloadClient; + + return ( + + ); + })} + + ) : null} +
+ + + + +
+ ); +} + +export default SelectDownloadClientModalContent; diff --git a/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css new file mode 100644 index 000000000..6525db977 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css @@ -0,0 +1,6 @@ +.downloadClient { + display: flex; + justify-content: space-between; + padding: 8px; + border-bottom: 1px solid var(--borderColor); +} diff --git a/frontend/src/Indexer/Stats/Stats.css.d.ts b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts similarity index 74% rename from frontend/src/Indexer/Stats/Stats.css.d.ts rename to frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts index ce2364202..10c2d3948 100644 --- a/frontend/src/Indexer/Stats/Stats.css.d.ts +++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts @@ -1,8 +1,7 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'fullWidthChart': string; - 'halfWidthChart': string; + 'downloadClient': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx new file mode 100644 index 000000000..6f98d60b4 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx @@ -0,0 +1,32 @@ +import React, { useCallback } from 'react'; +import Link from 'Components/Link/Link'; +import translate from 'Utilities/String/translate'; +import styles from './SelectDownloadClientRow.css'; + +interface SelectSeasonRowProps { + id: number; + name: string; + priority: number; + onDownloadClientSelect(downloadClientId: number): unknown; +} + +function SelectDownloadClientRow(props: SelectSeasonRowProps) { + const { id, name, priority, onDownloadClientSelect } = props; + + const onSeasonSelectWrapper = useCallback(() => { + onDownloadClientSelect(id); + }, [id, onDownloadClientSelect]); + + return ( + +
{name}
+
{translate('PrioritySettings', { priority })}
+ + ); +} + +export default SelectDownloadClientRow; diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchData.css b/frontend/src/Search/OverrideMatch/OverrideMatchData.css new file mode 100644 index 000000000..bd4d2f788 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/OverrideMatchData.css @@ -0,0 +1,17 @@ +.link { + composes: link from '~Components/Link/Link.css'; + + width: 100%; +} + +.placeholder { + display: inline-block; + margin: -2px 0; + width: 100%; + outline: 2px dashed var(--dangerColor); + outline-offset: -2px; +} + +.optional { + outline: 2px dashed var(--gray); +} diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchData.css.d.ts b/frontend/src/Search/OverrideMatch/OverrideMatchData.css.d.ts new file mode 100644 index 000000000..dd3ac4575 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/OverrideMatchData.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'link': string; + 'optional': string; + 'placeholder': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchData.tsx b/frontend/src/Search/OverrideMatch/OverrideMatchData.tsx new file mode 100644 index 000000000..82d6bd812 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/OverrideMatchData.tsx @@ -0,0 +1,35 @@ +import classNames from 'classnames'; +import React from 'react'; +import Link from 'Components/Link/Link'; +import styles from './OverrideMatchData.css'; + +interface OverrideMatchDataProps { + value?: string | number | JSX.Element | JSX.Element[]; + isDisabled?: boolean; + isOptional?: boolean; + onPress: () => void; +} + +function OverrideMatchData(props: OverrideMatchDataProps) { + const { value, isDisabled = false, isOptional, onPress } = props; + + return ( + + {(value == null || (Array.isArray(value) && value.length === 0)) && + !isDisabled ? ( + +   + + ) : ( + value + )} + + ); +} + +export default OverrideMatchData; diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchModal.tsx b/frontend/src/Search/OverrideMatch/OverrideMatchModal.tsx new file mode 100644 index 000000000..16d62ea7c --- /dev/null +++ b/frontend/src/Search/OverrideMatch/OverrideMatchModal.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import { sizes } from 'Helpers/Props'; +import OverrideMatchModalContent from './OverrideMatchModalContent'; + +interface OverrideMatchModalProps { + isOpen: boolean; + title: string; + indexerId: number; + guid: string; + protocol: DownloadProtocol; + isGrabbing: boolean; + grabError?: string; + onModalClose(): void; +} + +function OverrideMatchModal(props: OverrideMatchModalProps) { + const { + isOpen, + title, + indexerId, + guid, + protocol, + isGrabbing, + grabError, + onModalClose, + } = props; + + return ( + + + + ); +} + +export default OverrideMatchModal; diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css new file mode 100644 index 000000000..a5b4b8d52 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css @@ -0,0 +1,49 @@ +.label { + composes: label from '~Components/Label.css'; + + cursor: pointer; +} + +.item { + display: block; + margin-bottom: 5px; + margin-left: 50px; +} + +.footer { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + display: flex; + justify-content: space-between; + overflow: hidden; +} + +.error { + margin-right: 20px; + color: var(--dangerColor); + word-break: break-word; +} + +.buttons { + display: flex; +} + +@media only screen and (max-width: $breakpointSmall) { + .item { + margin-left: 0; + } + + .footer { + display: block; + } + + .error { + margin-right: 0; + margin-bottom: 10px; + } + + .buttons { + justify-content: space-between; + flex-grow: 1; + } +} diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css.d.ts b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css.d.ts new file mode 100644 index 000000000..79c77d6b5 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css.d.ts @@ -0,0 +1,11 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'buttons': string; + 'error': string; + 'footer': string; + 'item': string; + 'label': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.tsx b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.tsx new file mode 100644 index 000000000..fbe0ec450 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.tsx @@ -0,0 +1,150 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { grabRelease } from 'Store/Actions/releaseActions'; +import { fetchDownloadClients } from 'Store/Actions/settingsActions'; +import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector'; +import translate from 'Utilities/String/translate'; +import SelectDownloadClientModal from './DownloadClient/SelectDownloadClientModal'; +import OverrideMatchData from './OverrideMatchData'; +import styles from './OverrideMatchModalContent.css'; + +type SelectType = 'select' | 'downloadClient'; + +interface OverrideMatchModalContentProps { + indexerId: number; + title: string; + guid: string; + protocol: DownloadProtocol; + isGrabbing: boolean; + grabError?: string; + onModalClose(): void; +} + +function OverrideMatchModalContent(props: OverrideMatchModalContentProps) { + const modalTitle = translate('ManualGrab'); + const { + indexerId, + title, + guid, + protocol, + isGrabbing, + grabError, + onModalClose, + } = props; + + const [downloadClientId, setDownloadClientId] = useState(null); + const [selectModalOpen, setSelectModalOpen] = useState( + null + ); + const previousIsGrabbing = usePrevious(isGrabbing); + + const dispatch = useDispatch(); + const { items: downloadClients } = useSelector( + createEnabledDownloadClientsSelector(protocol) + ); + + const onSelectModalClose = useCallback(() => { + setSelectModalOpen(null); + }, [setSelectModalOpen]); + + const onSelectDownloadClientPress = useCallback(() => { + setSelectModalOpen('downloadClient'); + }, [setSelectModalOpen]); + + const onDownloadClientSelect = useCallback( + (downloadClientId: number) => { + setDownloadClientId(downloadClientId); + setSelectModalOpen(null); + }, + [setDownloadClientId, setSelectModalOpen] + ); + + const onGrabPress = useCallback(() => { + dispatch( + grabRelease({ + indexerId, + guid, + downloadClientId, + }) + ); + }, [indexerId, guid, downloadClientId, dispatch]); + + useEffect(() => { + if (!isGrabbing && previousIsGrabbing) { + onModalClose(); + } + }, [isGrabbing, previousIsGrabbing, onModalClose]); + + useEffect( + () => { + dispatch(fetchDownloadClients()); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( + + + {translate('OverrideGrabModalTitle', { title })} + + + + + {downloadClients.length > 1 ? ( + downloadClient.id === downloadClientId + )?.name ?? translate('Default') + } + onPress={onSelectDownloadClientPress} + /> + } + /> + ) : null} + + + + +
{grabError}
+ +
+ + + + {translate('GrabRelease')} + +
+
+ + +
+ ); +} + +export default OverrideMatchModalContent; diff --git a/frontend/src/Search/QueryParameterModal.js b/frontend/src/Search/QueryParameterModal.js index 863ccc003..cd7b8e191 100644 --- a/frontend/src/Search/QueryParameterModal.js +++ b/frontend/src/Search/QueryParameterModal.js @@ -14,11 +14,11 @@ import QueryParameterOption from './QueryParameterOption'; import styles from './QueryParameterModal.css'; const searchOptions = [ - { key: 'search', value: translate('BasicSearch') }, - { key: 'tvsearch', value: translate('TvSearch') }, - { key: 'movie', value: translate('MovieSearch') }, - { key: 'music', value: translate( 'AudioSearch') }, - { key: 'book', value: translate('BookSearch') } + { key: 'search', value: () => translate('BasicSearch') }, + { key: 'tvsearch', value: () => translate('TvSearch') }, + { key: 'movie', value: () => translate('MovieSearch') }, + { key: 'music', value: () => translate( 'AudioSearch') }, + { key: 'book', value: () => translate('BookSearch') } ]; const seriesTokens = [ diff --git a/frontend/src/Search/SearchFooter.css b/frontend/src/Search/SearchFooter.css index 54e68660b..65121e5e3 100644 --- a/frontend/src/Search/SearchFooter.css +++ b/frontend/src/Search/SearchFooter.css @@ -24,7 +24,8 @@ flex-grow: 1; } -.searchButton { +.searchButton, +.grabReleasesButton { composes: button from '~Components/Link/SpinnerButton.css'; margin-left: 25px; @@ -32,18 +33,20 @@ } .selectedReleasesLabel { - margin-bottom: 3px; + margin-bottom: 5px; text-align: right; font-weight: bold; } @media only screen and (max-width: $breakpointSmall) { - .inputContainer { + .inputContainer, + .indexerContainer { margin-right: 0; } .buttonContainer { justify-content: flex-start; + margin-top: 10px; } .buttonContainerContent { @@ -52,5 +55,20 @@ .buttons { justify-content: space-between; + flex-direction: column; + gap: 10px; + } + + .grabReleasesButton, + .searchButton { + margin-left: 0; + } + + .grabReleasesButton { + display: none; + } + + .selectedReleasesLabel { + text-align: center; } } diff --git a/frontend/src/Search/SearchFooter.css.d.ts b/frontend/src/Search/SearchFooter.css.d.ts index 8bf441cf4..e72f81320 100644 --- a/frontend/src/Search/SearchFooter.css.d.ts +++ b/frontend/src/Search/SearchFooter.css.d.ts @@ -4,6 +4,7 @@ interface CssExports { 'buttonContainer': string; 'buttonContainerContent': string; 'buttons': string; + 'grabReleasesButton': string; 'indexerContainer': string; 'inputContainer': string; 'searchButton': string; diff --git a/frontend/src/Search/SearchFooter.js b/frontend/src/Search/SearchFooter.js index 404ad7e79..872328446 100644 --- a/frontend/src/Search/SearchFooter.js +++ b/frontend/src/Search/SearchFooter.js @@ -24,6 +24,7 @@ class SearchFooter extends Component { super(props, context); const { + defaultSearchQueryParams, defaultIndexerIds, defaultCategories, defaultSearchQuery, @@ -33,16 +34,16 @@ class SearchFooter extends Component { } = props; this.state = { - isQueryParameterModalOpen: false, - queryModalOptions: null, - searchType: defaultSearchType, + searchIndexerIds: defaultSearchQueryParams.searchIndexerIds ?? defaultIndexerIds, + searchCategories: defaultSearchQueryParams.searchCategories ?? defaultCategories, + searchQuery: (defaultSearchQueryParams.searchQuery ?? defaultSearchQuery) || '', + searchType: defaultSearchQueryParams.searchType ?? defaultSearchType, + searchLimit: defaultSearchQueryParams.searchLimit ?? defaultSearchLimit, + searchOffset: defaultSearchQueryParams.searchOffset ?? defaultSearchOffset, + newSearch: true, searchingReleases: false, - searchQuery: defaultSearchQuery || '', - searchIndexerIds: defaultIndexerIds, - searchCategories: defaultCategories, - searchLimit: defaultSearchLimit, - searchOffset: defaultSearchOffset, - newSearch: true + isQueryParameterModalOpen: false, + queryModalOptions: null }; } @@ -189,12 +190,13 @@ class SearchFooter extends Component { break; default: icon = icons.SEARCH; + break; } - let footerLabel = searchIndexerIds.length === 0 ? translate('SearchAllIndexers') : translate('SearchCountIndexers', [searchIndexerIds.length]); + let footerLabel = searchIndexerIds.length === 0 ? translate('SearchAllIndexers') : translate('SearchCountIndexers', { count: searchIndexerIds.length }); if (isPopulated) { - footerLabel = selectedCount === 0 ? translate('FoundCountReleases', [itemCount]) : translate('SelectedCountOfCountReleases', [selectedCount, itemCount]); + footerLabel = selectedCount === 0 ? translate('FoundCountReleases', { itemCount }) : translate('SelectedCountOfCountReleases', { selectedCount, itemCount }); } return ( @@ -210,7 +212,11 @@ class SearchFooter extends Component { name="searchQuery" value={searchQuery} buttons={ - + @@ -259,11 +265,10 @@ class SearchFooter extends Component { />
- { isPopulated && ); diff --git a/frontend/src/Search/SearchIndex.js b/frontend/src/Search/SearchIndex.js index ba25b0912..d12635070 100644 --- a/frontend/src/Search/SearchIndex.js +++ b/frontend/src/Search/SearchIndex.js @@ -30,13 +30,7 @@ import SearchFooterConnector from './SearchFooterConnector'; import SearchIndexTableConnector from './Table/SearchIndexTableConnector'; import styles from './SearchIndex.css'; -function getViewComponent(isSmallScreen) { - if (isSmallScreen) { - return SearchIndexOverviewsConnector; - } - - return SearchIndexTableConnector; -} +const getViewComponent = (isSmallScreen) => (isSmallScreen ? SearchIndexOverviewsConnector : SearchIndexTableConnector); class SearchIndex extends Component { @@ -78,7 +72,7 @@ class SearchIndex extends Component { if (sortKey !== prevProps.sortKey || sortDirection !== prevProps.sortDirection || - hasDifferentItemsOrOrder(prevProps.items, items) + hasDifferentItemsOrOrder(prevProps.items, items, 'guid') ) { this.setJumpBarItems(); this.setSelectedState(); @@ -100,7 +94,14 @@ class SearchIndex extends Component { if (this.state.allUnselected) { return []; } - return getSelectedIds(this.state.selectedState, { parseIds: false }); + + return _.reduce(this.state.selectedState, (result, value, id) => { + if (value) { + result.push(id); + } + + return result; + }, []); }; setSelectedState() { @@ -281,10 +282,10 @@ class SearchIndex extends Component { const ViewComponent = getViewComponent(isSmallScreen); const isLoaded = !!(!error && isPopulated && items.length && this.scrollerRef.current); - const hasNoIndexer = !totalItems; + const hasNoSearchResults = !totalItems; return ( - + @@ -313,7 +314,7 @@ class SearchIndex extends Component { selectedFilterKey={selectedFilterKey} filters={filters} customFilters={customFilters} - isDisabled={hasNoIndexer} + isDisabled={hasNoSearchResults} onFilterSelect={onFilterSelect} /> @@ -326,15 +327,17 @@ class SearchIndex extends Component { innerClassName={styles.tableInnerContentBody} > { - isFetching && !isPopulated && - + isFetching && !isPopulated ? + : + null } { - !isFetching && !!error && + !isFetching && !!error ? {getErrorMessage(error, 'Failed to load search results from API')} - + : + null } { @@ -359,16 +362,18 @@ class SearchIndex extends Component { } { - !error && !isFetching && !hasIndexers && + !error && !isFetching && !hasIndexers ? + /> : + null } { - !error && !isFetching && hasIndexers && !items.length && - + !error && !isFetching && isPopulated && hasIndexers && !items.length ? + : + null } { - isLoaded && !!jumpBarItems.order.length && + isLoaded && !!jumpBarItems.order.length ? + /> : + null }
diff --git a/frontend/src/Search/SearchIndexConnector.js b/frontend/src/Search/SearchIndexConnector.js index e3302e73c..78a9866b2 100644 --- a/frontend/src/Search/SearchIndexConnector.js +++ b/frontend/src/Search/SearchIndexConnector.js @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import withScrollPosition from 'Components/withScrollPosition'; import { bulkGrabReleases, cancelFetchReleases, clearReleases, fetchReleases, setReleasesFilter, setReleasesSort, setReleasesTableOption } from 'Store/Actions/releaseActions'; +import { fetchDownloadClients } from 'Store/Actions/Settings/downloadClients'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createReleaseClientSideCollectionItemsSelector from 'Store/Selectors/createReleaseClientSideCollectionItemsSelector'; import SearchIndex from './SearchIndex'; @@ -55,12 +56,20 @@ function createMapDispatchToProps(dispatch, props) { dispatchClearReleases() { dispatch(clearReleases()); + }, + + dispatchFetchDownloadClients() { + dispatch(fetchDownloadClients()); } }; } class SearchIndexConnector extends Component { + componentDidMount() { + this.props.dispatchFetchDownloadClients(); + } + componentWillUnmount() { this.props.dispatchCancelFetchReleases(); this.props.dispatchClearReleases(); @@ -85,6 +94,7 @@ SearchIndexConnector.propTypes = { onBulkGrabPress: PropTypes.func.isRequired, dispatchCancelFetchReleases: PropTypes.func.isRequired, dispatchClearReleases: PropTypes.func.isRequired, + dispatchFetchDownloadClients: PropTypes.func.isRequired, items: PropTypes.arrayOf(PropTypes.object) }; diff --git a/frontend/src/Search/Table/CategoryLabel.js b/frontend/src/Search/Table/CategoryLabel.js deleted file mode 100644 index 5c076c521..000000000 --- a/frontend/src/Search/Table/CategoryLabel.js +++ /dev/null @@ -1,43 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Label from 'Components/Label'; -import Tooltip from 'Components/Tooltip/Tooltip'; -import { kinds, tooltipPositions } from 'Helpers/Props'; - -function CategoryLabel({ categories }) { - const sortedCategories = categories.filter((cat) => cat.name !== undefined).sort((c) => c.id); - - if (categories?.length === 0) { - return ( - Unknown} - tooltip="Please report this issue to the GitHub as this shouldn't be happening" - position={tooltipPositions.LEFT} - /> - ); - } - - return ( - - { - sortedCategories.map((category) => { - return ( - - ); - }) - } - - ); -} - -CategoryLabel.defaultProps = { - categories: [] -}; - -CategoryLabel.propTypes = { - categories: PropTypes.arrayOf(PropTypes.object).isRequired -}; - -export default CategoryLabel; diff --git a/frontend/src/Search/Table/CategoryLabel.tsx b/frontend/src/Search/Table/CategoryLabel.tsx new file mode 100644 index 000000000..4cfdeb1b2 --- /dev/null +++ b/frontend/src/Search/Table/CategoryLabel.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import Label from 'Components/Label'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import { kinds, tooltipPositions } from 'Helpers/Props'; +import { IndexerCategory } from 'Indexer/Indexer'; +import translate from 'Utilities/String/translate'; + +interface CategoryLabelProps { + categories: IndexerCategory[]; +} + +function CategoryLabel({ categories = [] }: CategoryLabelProps) { + if (categories?.length === 0) { + return ( + {translate('Unknown')}} + tooltip="Please report this issue to the GitHub as this shouldn't be happening" + position={tooltipPositions.LEFT} + /> + ); + } + + const sortedCategories = categories + .filter((cat) => cat.name !== undefined) + .sort((a, b) => a.id - b.id); + + return ( + + {sortedCategories.map((category) => { + return ; + })} + + ); +} + +export default CategoryLabel; diff --git a/frontend/src/Search/Table/ReleaseLinks.css b/frontend/src/Search/Table/ReleaseLinks.css new file mode 100644 index 000000000..d37a082a1 --- /dev/null +++ b/frontend/src/Search/Table/ReleaseLinks.css @@ -0,0 +1,13 @@ +.links { + margin: 0; +} + +.link { + white-space: nowrap; +} + +.linkLabel { + composes: label from '~Components/Label.css'; + + cursor: pointer; +} diff --git a/frontend/src/Search/Table/ReleaseLinks.css.d.ts b/frontend/src/Search/Table/ReleaseLinks.css.d.ts new file mode 100644 index 000000000..9f91f93a4 --- /dev/null +++ b/frontend/src/Search/Table/ReleaseLinks.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'link': string; + 'linkLabel': string; + 'links': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Search/Table/ReleaseLinks.tsx b/frontend/src/Search/Table/ReleaseLinks.tsx new file mode 100644 index 000000000..38260bc21 --- /dev/null +++ b/frontend/src/Search/Table/ReleaseLinks.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import { kinds, sizes } from 'Helpers/Props'; +import { IndexerCategory } from 'Indexer/Indexer'; +import styles from './ReleaseLinks.css'; + +interface ReleaseLinksProps { + categories: IndexerCategory[]; + imdbId?: string; + tmdbId?: number; + tvdbId?: number; + tvMazeId?: number; +} + +function ReleaseLinks(props: ReleaseLinksProps) { + const { categories = [], imdbId, tmdbId, tvdbId, tvMazeId } = props; + + const categoryNames = categories + .filter((item) => item.id < 100000) + .map((c) => c.name); + + return ( +
+ {imdbId ? ( + + + + ) : null} + + {tmdbId ? ( + + + + ) : null} + + {tvdbId ? ( + + + + ) : null} + + {tvMazeId ? ( + + + + ) : null} +
+ ); +} + +export default ReleaseLinks; diff --git a/frontend/src/Search/Table/SearchIndexHeader.js b/frontend/src/Search/Table/SearchIndexHeader.js index 6b91adb45..17b79e2f7 100644 --- a/frontend/src/Search/Table/SearchIndexHeader.js +++ b/frontend/src/Search/Table/SearchIndexHeader.js @@ -96,7 +96,7 @@ class SearchIndexHeader extends Component { isSortable={isSortable} {...otherProps} > - {label} + {typeof label === 'function' ? label() : label} ); }) diff --git a/frontend/src/Search/Table/SearchIndexItemConnector.js b/frontend/src/Search/Table/SearchIndexItemConnector.js index 490214529..4cc7fb20c 100644 --- a/frontend/src/Search/Table/SearchIndexItemConnector.js +++ b/frontend/src/Search/Table/SearchIndexItemConnector.js @@ -2,7 +2,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { executeCommand } from 'Store/Actions/commandActions'; function createReleaseSelector() { return createSelector( @@ -37,10 +36,6 @@ function createMapStateToProps() { ); } -const mapDispatchToProps = { - dispatchExecuteCommand: executeCommand -}; - class SearchIndexItemConnector extends Component { // @@ -71,4 +66,4 @@ SearchIndexItemConnector.propTypes = { component: PropTypes.elementType.isRequired }; -export default connect(createMapStateToProps, mapDispatchToProps)(SearchIndexItemConnector); +export default connect(createMapStateToProps, null)(SearchIndexItemConnector); diff --git a/frontend/src/Search/Table/SearchIndexRow.css b/frontend/src/Search/Table/SearchIndexRow.css index 2e8282268..b36ec4071 100644 --- a/frontend/src/Search/Table/SearchIndexRow.css +++ b/frontend/src/Search/Table/SearchIndexRow.css @@ -59,10 +59,41 @@ margin: 0 2px; width: 22px; color: var(--textColor); + text-align: center; } .externalLinks { + composes: button from '~Components/Link/IconButton.css'; + + color: var(--textColor); +} + +.manualDownloadContent { + position: relative; + display: inline-block; margin: 0 2px; width: 22px; + height: 20.39px; + vertical-align: middle; + line-height: 20.39px; + + &:hover { + color: var(--iconButtonHoverColor); + } +} + +.interactiveIcon { + position: absolute; + top: 4px; + left: 0; + /* width: 100%; */ + text-align: center; +} + +.downloadIcon { + position: absolute; + top: 7px; + left: 8px; + /* width: 100%; */ text-align: center; } diff --git a/frontend/src/Search/Table/SearchIndexRow.css.d.ts b/frontend/src/Search/Table/SearchIndexRow.css.d.ts index 6d625f58a..7552b96f9 100644 --- a/frontend/src/Search/Table/SearchIndexRow.css.d.ts +++ b/frontend/src/Search/Table/SearchIndexRow.css.d.ts @@ -6,12 +6,15 @@ interface CssExports { 'category': string; 'cell': string; 'checkInput': string; + 'downloadIcon': string; 'downloadLink': string; 'externalLinks': string; 'files': string; 'grabs': string; 'indexer': string; 'indexerFlags': string; + 'interactiveIcon': string; + 'manualDownloadContent': string; 'peers': string; 'protocol': string; 'size': string; diff --git a/frontend/src/Search/Table/SearchIndexRow.js b/frontend/src/Search/Table/SearchIndexRow.js deleted file mode 100644 index 1b740b5da..000000000 --- a/frontend/src/Search/Table/SearchIndexRow.js +++ /dev/null @@ -1,366 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; -import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; -import Popover from 'Components/Tooltip/Popover'; -import { icons, kinds, tooltipPositions } from 'Helpers/Props'; -import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import formatAge from 'Utilities/Number/formatAge'; -import formatBytes from 'Utilities/Number/formatBytes'; -import titleCase from 'Utilities/String/titleCase'; -import translate from 'Utilities/String/translate'; -import CategoryLabel from './CategoryLabel'; -import Peers from './Peers'; -import styles from './SearchIndexRow.css'; - -function getDownloadIcon(isGrabbing, isGrabbed, grabError) { - if (isGrabbing) { - return icons.SPINNER; - } else if (isGrabbed) { - return icons.DOWNLOADING; - } else if (grabError) { - return icons.DOWNLOADING; - } - - return icons.DOWNLOAD; -} - -function getDownloadTooltip(isGrabbing, isGrabbed, grabError) { - if (isGrabbing) { - return ''; - } else if (isGrabbed) { - return translate('AddedToDownloadClient'); - } else if (grabError) { - return grabError; - } - - return translate('AddToDownloadClient'); -} - -class SearchIndexRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isConfirmGrabModalOpen: false - }; - } - - // - // Listeners - - onGrabPress = () => { - const { - guid, - indexerId, - onGrabPress - } = this.props; - - onGrabPress({ - guid, - indexerId - }); - }; - - onSavePress = () => { - const { - downloadUrl, - fileName, - onSavePress - } = this.props; - - onSavePress({ - downloadUrl, - fileName - }); - }; - - // - // Render - - render() { - const { - guid, - protocol, - categories, - age, - ageHours, - ageMinutes, - publishDate, - title, - infoUrl, - indexer, - size, - files, - grabs, - seeders, - leechers, - indexerFlags, - columns, - isGrabbing, - isGrabbed, - grabError, - longDateFormat, - timeFormat, - isSelected, - onSelectedChange - } = this.props; - - return ( - <> - { - columns.map((column) => { - const { - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (column.name === 'select') { - return ( - - ); - } - - if (column.name === 'protocol') { - return ( - - - - ); - } - - if (column.name === 'age') { - return ( - - {formatAge(age, ageHours, ageMinutes)} - - ); - } - - if (column.name === 'sortTitle') { - return ( - - -
- {title} -
- -
- ); - } - - if (column.name === 'indexer') { - return ( - - {indexer} - - ); - } - - if (column.name === 'size') { - return ( - - {formatBytes(size)} - - ); - } - - if (column.name === 'files') { - return ( - - {files} - - ); - } - - if (column.name === 'grabs') { - return ( - - {grabs} - - ); - } - - if (column.name === 'peers') { - return ( - - { - protocol === 'torrent' && - - } - - ); - } - - if (column.name === 'category') { - return ( - - - - ); - } - - if (column.name === 'indexerFlags') { - return ( - - { - !!indexerFlags.length && - - } - title={translate('IndexerFlags')} - body={ -
    - { - indexerFlags.map((flag, index) => { - return ( -
  • - {titleCase(flag)} -
  • - ); - }) - } -
- } - position={tooltipPositions.LEFT} - /> - } -
- ); - } - - if (column.name === 'actions') { - return ( - - - - - - ); - } - - return null; - }) - } - - ); - } -} - -SearchIndexRow.propTypes = { - guid: PropTypes.string.isRequired, - categories: PropTypes.arrayOf(PropTypes.object).isRequired, - protocol: PropTypes.string.isRequired, - age: PropTypes.number.isRequired, - ageHours: PropTypes.number.isRequired, - ageMinutes: PropTypes.number.isRequired, - publishDate: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - fileName: PropTypes.string.isRequired, - infoUrl: PropTypes.string.isRequired, - downloadUrl: PropTypes.string.isRequired, - indexerId: PropTypes.number.isRequired, - indexer: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - files: PropTypes.number, - grabs: PropTypes.number, - seeders: PropTypes.number, - leechers: PropTypes.number, - indexerFlags: PropTypes.arrayOf(PropTypes.string).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - onGrabPress: PropTypes.func.isRequired, - onSavePress: PropTypes.func.isRequired, - isGrabbing: PropTypes.bool.isRequired, - isGrabbed: PropTypes.bool.isRequired, - grabError: PropTypes.string, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isSelected: PropTypes.bool, - onSelectedChange: PropTypes.func.isRequired -}; - -SearchIndexRow.defaultProps = { - isGrabbing: false, - isGrabbed: false -}; - -export default SearchIndexRow; diff --git a/frontend/src/Search/Table/SearchIndexRow.tsx b/frontend/src/Search/Table/SearchIndexRow.tsx new file mode 100644 index 000000000..1136a7f64 --- /dev/null +++ b/frontend/src/Search/Table/SearchIndexRow.tsx @@ -0,0 +1,395 @@ +import React, { useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; +import Column from 'Components/Table/Column'; +import Popover from 'Components/Tooltip/Popover'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel'; +import { IndexerCategory } from 'Indexer/Indexer'; +import OverrideMatchModal from 'Search/OverrideMatch/OverrideMatchModal'; +import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector'; +import { SelectStateInputProps } from 'typings/props'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import formatBytes from 'Utilities/Number/formatBytes'; +import titleCase from 'Utilities/String/titleCase'; +import translate from 'Utilities/String/translate'; +import CategoryLabel from './CategoryLabel'; +import Peers from './Peers'; +import ReleaseLinks from './ReleaseLinks'; +import styles from './SearchIndexRow.css'; + +function getDownloadIcon( + isGrabbing: boolean, + isGrabbed: boolean, + grabError?: string +) { + if (isGrabbing) { + return icons.SPINNER; + } else if (isGrabbed) { + return icons.DOWNLOADING; + } else if (grabError) { + return icons.DOWNLOADING; + } + + return icons.DOWNLOAD; +} + +function getDownloadKind(isGrabbed: boolean, grabError?: string) { + if (isGrabbed) { + return kinds.SUCCESS; + } + + if (grabError) { + return kinds.DANGER; + } + + return kinds.DEFAULT; +} + +function getDownloadTooltip( + isGrabbing: boolean, + isGrabbed: boolean, + grabError?: string +) { + if (isGrabbing) { + return ''; + } else if (isGrabbed) { + return translate('AddedToDownloadClient'); + } else if (grabError) { + return grabError; + } + + return translate('AddToDownloadClient'); +} + +interface SearchIndexRowProps { + guid: string; + protocol: DownloadProtocol; + age: number; + ageHours: number; + ageMinutes: number; + publishDate: string; + title: string; + fileName: string; + infoUrl: string; + downloadUrl?: string; + magnetUrl?: string; + indexerId: number; + indexer: string; + categories: IndexerCategory[]; + size: number; + files?: number; + grabs?: number; + seeders?: number; + leechers?: number; + imdbId?: string; + tmdbId?: number; + tvdbId?: number; + tvMazeId?: number; + indexerFlags: string[]; + isGrabbing: boolean; + isGrabbed: boolean; + grabError?: string; + longDateFormat: string; + timeFormat: string; + columns: Column[]; + isSelected?: boolean; + onSelectedChange(result: SelectStateInputProps): void; + onGrabPress(...args: unknown[]): void; + onSavePress(...args: unknown[]): void; +} + +function SearchIndexRow(props: SearchIndexRowProps) { + const { + guid, + indexerId, + protocol, + categories, + age, + ageHours, + ageMinutes, + publishDate, + title, + fileName, + infoUrl, + downloadUrl, + magnetUrl, + indexer, + size, + files, + grabs, + seeders, + leechers, + imdbId, + tmdbId, + tvdbId, + tvMazeId, + indexerFlags = [], + isGrabbing = false, + isGrabbed = false, + grabError, + longDateFormat, + timeFormat, + columns, + isSelected, + onSelectedChange, + onGrabPress, + onSavePress, + } = props; + + const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false); + + const { items: downloadClients } = useSelector( + createEnabledDownloadClientsSelector(protocol) + ); + + const onGrabPressWrapper = useCallback(() => { + onGrabPress({ + guid, + indexerId, + }); + }, [guid, indexerId, onGrabPress]); + + const onSavePressWrapper = useCallback(() => { + onSavePress({ + downloadUrl, + fileName, + }); + }, [downloadUrl, fileName, onSavePress]); + + const onOverridePress = useCallback(() => { + setIsOverrideModalOpen(true); + }, [setIsOverrideModalOpen]); + + const onOverrideModalClose = useCallback(() => { + setIsOverrideModalOpen(false); + }, [setIsOverrideModalOpen]); + + return ( + <> + {columns.map((column) => { + const { name, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'select') { + return ( + + ); + } + + if (name === 'protocol') { + return ( + + + + ); + } + + if (name === 'age') { + return ( + + {formatAge(age, ageHours, ageMinutes)} + + ); + } + + if (name === 'sortTitle') { + return ( + + +
{title}
+ +
+ ); + } + + if (name === 'indexer') { + return ( + + {indexer} + + ); + } + + if (name === 'size') { + return ( + + {formatBytes(size)} + + ); + } + + if (name === 'files') { + return ( + + {files} + + ); + } + + if (name === 'grabs') { + return ( + + {grabs} + + ); + } + + if (name === 'peers') { + return ( + + {protocol === 'torrent' && ( + + )} + + ); + } + + if (name === 'category') { + return ( + + + + ); + } + + if (name === 'indexerFlags') { + return ( + + {!!indexerFlags.length && ( + } + title={translate('IndexerFlags')} + body={ +
    + {indexerFlags.map((flag, index) => { + return
  • {titleCase(flag)}
  • ; + })} +
+ } + position={tooltipPositions.LEFT} + /> + )} +
+ ); + } + + if (name === 'actions') { + return ( + + + + {downloadClients.length > 1 ? ( + +
+ + + +
+ + ) : null} + + {downloadUrl ? ( + + ) : null} + + {magnetUrl ? ( + + ) : null} + + {imdbId || tmdbId || tvdbId || tvMazeId ? ( + + } + title={translate('Links')} + body={ + + } + position={tooltipPositions.TOP} + /> + ) : null} +
+ ); + } + + return null; + })} + + + + ); +} + +export default SearchIndexRow; diff --git a/frontend/src/Settings/Applications/ApplicationSettings.js b/frontend/src/Settings/Applications/ApplicationSettings.js deleted file mode 100644 index d06b7fb65..000000000 --- a/frontend/src/Settings/Applications/ApplicationSettings.js +++ /dev/null @@ -1,103 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component, Fragment } from 'react'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; -import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; -import { icons } from 'Helpers/Props'; -import AppProfilesConnector from 'Settings/Profiles/App/AppProfilesConnector'; -import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; -import translate from 'Utilities/String/translate'; -import ApplicationsConnector from './Applications/ApplicationsConnector'; -import ManageApplicationsModal from './Applications/Manage/ManageApplicationsModal'; - -class ApplicationSettings extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isManageApplicationsOpen: false - }; - } - - // - // Listeners - - onManageApplicationsPress = () => { - this.setState({ isManageApplicationsOpen: true }); - }; - - onManageApplicationsModalClose = () => { - this.setState({ isManageApplicationsOpen: false }); - }; - - // - // Render - - render() { - const { - isTestingAll, - isSyncingIndexers, - onTestAllPress, - onAppIndexerSyncPress - } = this.props; - - const { isManageApplicationsOpen } = this.state; - - return ( - - - - - - - - - - - } - /> - - - - - - - - - ); - } -} - -ApplicationSettings.propTypes = { - isTestingAll: PropTypes.bool.isRequired, - isSyncingIndexers: PropTypes.bool.isRequired, - onTestAllPress: PropTypes.func.isRequired, - onAppIndexerSyncPress: PropTypes.func.isRequired -}; - -export default ApplicationSettings; diff --git a/frontend/src/Settings/Applications/ApplicationSettings.tsx b/frontend/src/Settings/Applications/ApplicationSettings.tsx new file mode 100644 index 000000000..7fc4b1d7b --- /dev/null +++ b/frontend/src/Settings/Applications/ApplicationSettings.tsx @@ -0,0 +1,102 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import { APP_INDEXER_SYNC } from 'Commands/commandNames'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import { icons } from 'Helpers/Props'; +import AppProfilesConnector from 'Settings/Profiles/App/AppProfilesConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { testAllApplications } from 'Store/Actions/Settings/applications'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import translate from 'Utilities/String/translate'; +import ApplicationsConnector from './Applications/ApplicationsConnector'; +import ManageApplicationsModal from './Applications/Manage/ManageApplicationsModal'; + +function ApplicationSettings() { + const isSyncingIndexers = useSelector( + createCommandExecutingSelector(APP_INDEXER_SYNC) + ); + const isTestingAll = useSelector( + (state: AppState) => state.settings.applications.isTestingAll + ); + const dispatch = useDispatch(); + + const [isManageApplicationsOpen, setIsManageApplicationsOpen] = + useState(false); + + const onManageApplicationsPress = useCallback(() => { + setIsManageApplicationsOpen(true); + }, [setIsManageApplicationsOpen]); + + const onManageApplicationsModalClose = useCallback(() => { + setIsManageApplicationsOpen(false); + }, [setIsManageApplicationsOpen]); + + const onAppIndexerSyncPress = useCallback(() => { + dispatch( + executeCommand({ + name: APP_INDEXER_SYNC, + forceSync: true, + }) + ); + }, [dispatch]); + + const onTestAllPress = useCallback(() => { + dispatch(testAllApplications()); + }, [dispatch]); + + return ( + + + + + + + + + + + } + /> + + + {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} + {/* @ts-ignore */} + + {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} + {/* @ts-ignore */} + + + + + + ); +} + +export default ApplicationSettings; diff --git a/frontend/src/Settings/Applications/ApplicationSettingsConnector.js b/frontend/src/Settings/Applications/ApplicationSettingsConnector.js deleted file mode 100644 index aece6e91f..000000000 --- a/frontend/src/Settings/Applications/ApplicationSettingsConnector.js +++ /dev/null @@ -1,35 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import { executeCommand } from 'Store/Actions/commandActions'; -import { testAllApplications } from 'Store/Actions/settingsActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import ApplicationSettings from './ApplicationSettings'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.applications.isTestingAll, - createCommandExecutingSelector(commandNames.APP_INDEXER_SYNC), - (isTestingAll, isSyncingIndexers) => { - return { - isTestingAll, - isSyncingIndexers - }; - } - ); -} - -function mapDispatchToProps(dispatch, props) { - return { - onTestAllPress() { - dispatch(testAllApplications()); - }, - onAppIndexerSyncPress() { - dispatch(executeCommand({ - name: commandNames.APP_INDEXER_SYNC - })); - } - }; -} - -export default connect(createMapStateToProps, mapDispatchToProps)(ApplicationSettings); diff --git a/frontend/src/Settings/Applications/Applications/AddApplicationItem.js b/frontend/src/Settings/Applications/Applications/AddApplicationItem.js index bb0053824..bae97990b 100644 --- a/frontend/src/Settings/Applications/Applications/AddApplicationItem.js +++ b/frontend/src/Settings/Applications/Applications/AddApplicationItem.js @@ -16,10 +16,11 @@ class AddApplicationItem extends Component { onApplicationSelect = () => { const { - implementation + implementation, + implementationName } = this.props; - this.props.onApplicationSelect({ implementation }); + this.props.onApplicationSelect({ implementation, implementationName }); }; // @@ -77,6 +78,7 @@ class AddApplicationItem extends Component { key={preset.name} name={preset.name} implementation={implementation} + implementationName={implementationName} onPress={onApplicationSelect} /> ); diff --git a/frontend/src/Settings/Applications/Applications/AddApplicationPresetMenuItem.js b/frontend/src/Settings/Applications/Applications/AddApplicationPresetMenuItem.js index 9974f7132..d04aef4f0 100644 --- a/frontend/src/Settings/Applications/Applications/AddApplicationPresetMenuItem.js +++ b/frontend/src/Settings/Applications/Applications/AddApplicationPresetMenuItem.js @@ -10,12 +10,14 @@ class AddApplicationPresetMenuItem extends Component { onPress = () => { const { name, - implementation + implementation, + implementationName } = this.props; this.props.onPress({ name, - implementation + implementation, + implementationName }); }; @@ -26,6 +28,7 @@ class AddApplicationPresetMenuItem extends Component { const { name, implementation, + implementationName, ...otherProps } = this.props; @@ -43,6 +46,7 @@ class AddApplicationPresetMenuItem extends Component { AddApplicationPresetMenuItem.propTypes = { name: PropTypes.string.isRequired, implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, onPress: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/Applications/Applications/Application.css b/frontend/src/Settings/Applications/Applications/Application.css index 93912850e..2fde249c7 100644 --- a/frontend/src/Settings/Applications/Applications/Application.css +++ b/frontend/src/Settings/Applications/Applications/Application.css @@ -4,6 +4,11 @@ width: 290px; } +.nameContainer { + display: flex; + justify-content: space-between; +} + .name { @add-mixin truncate; @@ -12,6 +17,12 @@ font-size: 24px; } +.externalLink { + composes: button from '~Components/Link/IconButton.css'; + + height: 36px; +} + .enabled { display: flex; flex-wrap: wrap; diff --git a/frontend/src/Settings/Applications/Applications/Application.css.d.ts b/frontend/src/Settings/Applications/Applications/Application.css.d.ts index 58a29f414..085b1a3c5 100644 --- a/frontend/src/Settings/Applications/Applications/Application.css.d.ts +++ b/frontend/src/Settings/Applications/Applications/Application.css.d.ts @@ -3,7 +3,9 @@ interface CssExports { 'application': string; 'enabled': string; + 'externalLink': string; 'name': string; + 'nameContainer': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Settings/Applications/Applications/Application.js b/frontend/src/Settings/Applications/Applications/Application.js index aebb015a9..086d39ee1 100644 --- a/frontend/src/Settings/Applications/Applications/Application.js +++ b/frontend/src/Settings/Applications/Applications/Application.js @@ -2,9 +2,10 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import Card from 'Components/Card'; import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; import ConfirmModal from 'Components/Modal/ConfirmModal'; import TagList from 'Components/TagList'; -import { kinds } from 'Helpers/Props'; +import { icons, kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import EditApplicationModalConnector from './EditApplicationModalConnector'; import styles from './Application.css'; @@ -56,19 +57,35 @@ class Application extends Component { const { id, name, + enable, syncLevel, + fields, tags, tagList } = this.props; + const applicationUrl = fields.find((field) => field.name === 'baseUrl')?.value; + return ( -
- {name} +
+
+ {name} +
+ + { + enable && applicationUrl ? + : null + }
{ @@ -111,7 +128,7 @@ class Application extends Component { isOpen={this.state.isDeleteApplicationModalOpen} kind={kinds.DANGER} title={translate('DeleteApplication')} - message={translate('DeleteApplicationMessageText', [name])} + message={translate('DeleteApplicationMessageText', { name })} confirmLabel={translate('Delete')} onConfirm={this.onConfirmDeleteApplication} onCancel={this.onDeleteApplicationModalClose} @@ -124,7 +141,9 @@ class Application extends Component { Application.propTypes = { id: PropTypes.number.isRequired, name: PropTypes.string.isRequired, + enable: PropTypes.bool.isRequired, syncLevel: PropTypes.string.isRequired, + fields: PropTypes.arrayOf(PropTypes.object).isRequired, tags: PropTypes.arrayOf(PropTypes.number).isRequired, tagList: PropTypes.arrayOf(PropTypes.object).isRequired, onConfirmDeleteApplication: PropTypes.func diff --git a/frontend/src/Settings/Applications/Applications/Applications.js b/frontend/src/Settings/Applications/Applications/Applications.js index c6421c9ec..66d02088e 100644 --- a/frontend/src/Settings/Applications/Applications/Applications.js +++ b/frontend/src/Settings/Applications/Applications/Applications.js @@ -62,7 +62,7 @@ class Applications extends Component { return (
diff --git a/frontend/src/Settings/Applications/Applications/ApplicationsConnector.js b/frontend/src/Settings/Applications/Applications/ApplicationsConnector.js index bbf8722c5..9f5e570c5 100644 --- a/frontend/src/Settings/Applications/Applications/ApplicationsConnector.js +++ b/frontend/src/Settings/Applications/Applications/ApplicationsConnector.js @@ -5,12 +5,12 @@ import { createSelector } from 'reselect'; import { deleteApplication, fetchApplications } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import createTagsSelector from 'Store/Selectors/createTagsSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import Applications from './Applications'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.applications', sortByName), + createSortedSectionSelector('settings.applications', sortByProp('name')), createTagsSelector(), (applications, tagList) => { return { diff --git a/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js b/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js index a8b0636de..00e30cdb7 100644 --- a/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js +++ b/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js @@ -19,9 +19,24 @@ import translate from 'Utilities/String/translate'; import styles from './EditApplicationModalContent.css'; const syncLevelOptions = [ - { key: 'disabled', value: translate('Disabled') }, - { key: 'addOnly', value: translate('AddRemoveOnly') }, - { key: 'fullSync', value: translate('FullSync') } + { + key: 'disabled', + get value() { + return translate('Disabled'); + } + }, + { + key: 'addOnly', + get value() { + return translate('AddRemoveOnly'); + } + }, + { + key: 'fullSync', + get value() { + return translate('FullSync'); + } + } ]; function EditApplicationModalContent(props) { @@ -45,6 +60,7 @@ function EditApplicationModalContent(props) { const { id, + implementationName, name, syncLevel, tags, @@ -55,7 +71,7 @@ function EditApplicationModalContent(props) { return ( - {`${id ? translate('Edit') : translate('Add')} ${translate('Application')}`} + {id ? translate('EditApplicationImplementation', { implementationName }) : translate('AddApplicationImplementation', { implementationName })} @@ -102,7 +118,10 @@ function EditApplicationModalContent(props) { type={inputTypes.SELECT} values={syncLevelOptions} name="syncLevel" - helpText={`${translate('SyncLevelAddRemove')}
${translate('SyncLevelFull')}`} + helpTexts={[ + translate('SyncLevelAddRemove'), + translate('SyncLevelFull') + ]} {...syncLevel} onChange={onInputChange} /> @@ -114,7 +133,8 @@ function EditApplicationModalContent(props) { diff --git a/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx index dfdd1639b..57e88a4fe 100644 --- a/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx +++ b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx @@ -25,10 +25,31 @@ interface ManageApplicationsEditModalContentProps { const NO_CHANGE = 'noChange'; const syncLevelOptions = [ - { key: NO_CHANGE, value: translate('NoChange'), disabled: true }, - { key: ApplicationSyncLevel.Disabled, value: translate('Disabled') }, - { key: ApplicationSyncLevel.AddOnly, value: translate('AddOnly') }, - { key: ApplicationSyncLevel.FullSync, value: translate('FullSync') }, + { + key: NO_CHANGE, + get value() { + return translate('NoChange'); + }, + isDisabled: true, + }, + { + key: ApplicationSyncLevel.Disabled, + get value() { + return translate('Disabled'); + }, + }, + { + key: ApplicationSyncLevel.AddOnly, + get value() { + return translate('AddRemoveOnly'); + }, + }, + { + key: ApplicationSyncLevel.FullSync, + get value() { + return translate('FullSync'); + }, + }, ]; function ManageApplicationsEditModalContent( @@ -82,9 +103,10 @@ function ManageApplicationsEditModalContent( name="syncLevel" value={syncLevel} values={syncLevelOptions} - helpText={`${translate('SyncLevelAddRemove')}
${translate( - 'SyncLevelFull' - )}`} + helpTexts={[ + translate('SyncLevelAddRemove'), + translate('SyncLevelFull'), + ]} onChange={onInputChange} /> @@ -92,7 +114,7 @@ function ManageApplicationsEditModalContent(
- {translate('CountApplicationsSelected', [selectedCount])} + {translate('CountApplicationsSelected', { count: selectedCount })}
diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx index b6c636fbe..bb81729f3 100644 --- a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx +++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx @@ -14,9 +14,11 @@ import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import useSelectState from 'Helpers/Hooks/useSelectState'; import { kinds } from 'Helpers/Props'; +import SortDirection from 'Helpers/Props/SortDirection'; import { bulkDeleteApplications, bulkEditApplications, + setManageApplicationsSort, } from 'Store/Actions/settingsActions'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import { SelectStateInputProps } from 'typings/props'; @@ -36,25 +38,25 @@ type OnSelectedChangeCallback = React.ComponentProps< const COLUMNS = [ { name: 'name', - label: translate('Name'), + label: () => translate('Name'), isSortable: true, isVisible: true, }, { name: 'implementation', - label: translate('Implementation'), + label: () => translate('Implementation'), isSortable: true, isVisible: true, }, { name: 'syncLevel', - label: translate('SyncLevel'), + label: () => translate('SyncLevel'), isSortable: true, isVisible: true, }, { name: 'tags', - label: translate('Tags'), + label: () => translate('Tags'), isSortable: true, isVisible: true, }, @@ -62,6 +64,8 @@ const COLUMNS = [ interface ManageApplicationsModalContentProps { onModalClose(): void; + sortKey?: string; + sortDirection?: SortDirection; } function ManageApplicationsModalContent( @@ -76,6 +80,8 @@ function ManageApplicationsModalContent( isSaving, error, items, + sortKey, + sortDirection, }: ApplicationAppState = useSelector( createClientSideCollectionSelector('settings.applications') ); @@ -96,6 +102,13 @@ function ManageApplicationsModalContent( const selectedCount = selectedIds.length; + const onSortPress = useCallback( + (value: string) => { + dispatch(setManageApplicationsSort({ sortKey: value })); + }, + [dispatch] + ); + const onDeletePress = useCallback(() => { setIsDeleteModalOpen(true); }, [setIsDeleteModalOpen]); @@ -200,7 +213,10 @@ function ManageApplicationsModalContent( selectAll={true} allSelected={allSelected} allUnselected={allUnselected} + sortKey={sortKey} + sortDirection={sortDirection} onSelectAllChange={onSelectAllChange} + onSortPress={onSortPress} > {items.map((item) => { @@ -252,9 +268,9 @@ function ManageApplicationsModalContent( diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Categories/AddCategoryModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/Categories/AddCategoryModalContent.js index 71c51849c..e79f615ea 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Categories/AddCategoryModalContent.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Categories/AddCategoryModalContent.js @@ -35,7 +35,7 @@ function AddCategoryModalContent(props) { return ( - {`${id ? 'Edit' : 'Add'} Category`} + {id ? translate('EditCategory') : translate('AddCategory')} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Categories/Category.js b/frontend/src/Settings/DownloadClients/DownloadClients/Categories/Category.js index 6e0a25a2d..1d1f61469 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Categories/Category.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Categories/Category.js @@ -88,7 +88,7 @@ class Category extends Component { message={
- {translate('AreYouSureYouWantToDeleteCategory', [name])} + {translate('AreYouSureYouWantToDeleteCategory')}
} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js index 7136f531e..13b24343d 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js @@ -105,7 +105,7 @@ class DownloadClient extends Component { isOpen={this.state.isDeleteDownloadClientModalOpen} kind={kinds.DANGER} title={translate('DeleteDownloadClient')} - message={translate('DeleteDownloadClientMessageText', [name])} + message={translate('DeleteDownloadClientMessageText', { name })} confirmLabel={translate('Delete')} onConfirm={this.onConfirmDeleteDownloadClient} onCancel={this.onDeleteDownloadClientModalClose} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js index 640d56a89..51f390d4f 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js @@ -1,10 +1,11 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import Alert from 'Components/Alert'; import Card from 'Components/Card'; import FieldSet from 'Components/FieldSet'; import Icon from 'Components/Icon'; import PageSectionContent from 'Components/Page/PageSectionContent'; -import { icons } from 'Helpers/Props'; +import { icons, kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import AddDownloadClientModal from './AddDownloadClientModal'; import DownloadClient from './DownloadClient'; @@ -59,48 +60,59 @@ class DownloadClients extends Component { } = this.state; return ( -
- -
- { - items.map((item) => { - return ( - - ); - }) - } - - -
- -
-
+
+ +
+ {translate('ProwlarrDownloadClientsAlert')}
+
+ {translate('ProwlarrDownloadClientsInAppOnlyAlert')} +
+
- +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } - - -
+ +
+ +
+
+
+ + + + + +
+
); } } diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js index 9cba9c1cc..4f6833fcb 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js @@ -4,12 +4,12 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { deleteDownloadClient, fetchDownloadClients } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import DownloadClients from './DownloadClients'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.downloadClients', sortByName), + createSortedSectionSelector('settings.downloadClients', sortByProp('name')), (downloadClients) => downloadClients ); } diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js index b9cb2cc9c..c57432710 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js @@ -17,6 +17,7 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { icons, inputTypes, kinds } from 'Helpers/Props'; +import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton'; import translate from 'Utilities/String/translate'; import AddCategoryModalConnector from './Categories/AddCategoryModalConnector'; import Category from './Categories/Category'; @@ -61,6 +62,7 @@ class EditDownloadClientModalContent extends Component { onModalClose, onSavePress, onTestPress, + onAdvancedSettingsPress, onDeleteDownloadClientPress, onConfirmDeleteCategory, ...otherProps @@ -84,7 +86,7 @@ class EditDownloadClientModalContent extends Component { return ( - {`${id ? translate('Edit') : translate('Add')} ${translate('DownloadClient')} - ${implementationName}`} + {id ? translate('EditDownloadClientImplementation', { implementationName }) : translate('AddDownloadClientImplementation', { implementationName })} @@ -219,6 +221,12 @@ class EditDownloadClientModalContent extends Component { } + + { + this.props.toggleAdvancedSettings(); + }; + onConfirmDeleteCategory = (id) => { this.props.deleteDownloadClientCategory({ id }); }; @@ -81,6 +94,7 @@ class EditDownloadClientModalContentConnector extends Component { {...this.props} onSavePress={this.onSavePress} onTestPress={this.onTestPress} + onAdvancedSettingsPress={this.onAdvancedSettingsPress} onInputChange={this.onInputChange} onFieldChange={this.onFieldChange} onConfirmDeleteCategory={this.onConfirmDeleteCategory} @@ -102,6 +116,7 @@ EditDownloadClientModalContentConnector.propTypes = { setDownloadClientFieldValue: PropTypes.func.isRequired, saveDownloadClient: PropTypes.func.isRequired, testDownloadClient: PropTypes.func.isRequired, + toggleAdvancedSettings: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx index c9279de3b..d18e694c9 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx @@ -25,9 +25,25 @@ interface ManageDownloadClientsEditModalContentProps { const NO_CHANGE = 'noChange'; const enableOptions = [ - { key: NO_CHANGE, value: translate('NoChange'), disabled: true }, - { key: 'enabled', value: translate('Enabled') }, - { key: 'disabled', value: translate('Disabled') }, + { + key: NO_CHANGE, + get value() { + return translate('NoChange'); + }, + isDisabled: true, + }, + { + key: 'enabled', + get value() { + return translate('Enabled'); + }, + }, + { + key: 'disabled', + get value() { + return translate('Disabled'); + }, + }, ]; function ManageDownloadClientsEditModalContent( @@ -113,7 +129,7 @@ function ManageDownloadClientsEditModalContent(
- {translate('CountDownloadClientsSelected', [selectedCount])} + {translate('CountDownloadClientsSelected', { count: selectedCount })}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx index c7291b012..fa82d61b9 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx @@ -14,9 +14,11 @@ import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import useSelectState from 'Helpers/Hooks/useSelectState'; import { kinds } from 'Helpers/Props'; +import SortDirection from 'Helpers/Props/SortDirection'; import { bulkDeleteDownloadClients, bulkEditDownloadClients, + setManageDownloadClientsSort, } from 'Store/Actions/settingsActions'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import { SelectStateInputProps } from 'typings/props'; @@ -35,25 +37,25 @@ type OnSelectedChangeCallback = React.ComponentProps< const COLUMNS = [ { name: 'name', - label: translate('Name'), + label: () => translate('Name'), isSortable: true, isVisible: true, }, { name: 'implementation', - label: translate('Implementation'), + label: () => translate('Implementation'), isSortable: true, isVisible: true, }, { name: 'enable', - label: translate('Enabled'), + label: () => translate('Enabled'), isSortable: true, isVisible: true, }, { name: 'priority', - label: translate('ClientPriority'), + label: () => translate('ClientPriority'), isSortable: true, isVisible: true, }, @@ -61,6 +63,8 @@ const COLUMNS = [ interface ManageDownloadClientsModalContentProps { onModalClose(): void; + sortKey?: string; + sortDirection?: SortDirection; } function ManageDownloadClientsModalContent( @@ -75,6 +79,8 @@ function ManageDownloadClientsModalContent( isSaving, error, items, + sortKey, + sortDirection, }: DownloadClientAppState = useSelector( createClientSideCollectionSelector('settings.downloadClients') ); @@ -93,6 +99,13 @@ function ManageDownloadClientsModalContent( const selectedCount = selectedIds.length; + const onSortPress = useCallback( + (value: string) => { + dispatch(setManageDownloadClientsSort({ sortKey: value })); + }, + [dispatch] + ); + const onDeletePress = useCallback(() => { setIsDeleteModalOpen(true); }, [setIsDeleteModalOpen]); @@ -173,7 +186,10 @@ function ManageDownloadClientsModalContent( selectAll={true} allSelected={allSelected} allUnselected={allUnselected} + sortKey={sortKey} + sortDirection={sortDirection} onSelectAllChange={onSelectAllChange} + onSortPress={onSortPress} > {items.map((item) => { @@ -217,18 +233,18 @@ function ManageDownloadClientsModalContent( diff --git a/frontend/src/Settings/General/LoggingSettings.js b/frontend/src/Settings/General/LoggingSettings.js index 540e29b01..61a259258 100644 --- a/frontend/src/Settings/General/LoggingSettings.js +++ b/frontend/src/Settings/General/LoggingSettings.js @@ -15,12 +15,14 @@ const logLevelOptions = [ function LoggingSettings(props) { const { + advancedSettings, settings, onInputChange } = props; const { - logLevel + logLevel, + logSizeLimit } = settings; return ( @@ -37,11 +39,30 @@ function LoggingSettings(props) { {...logLevel} /> + + + {translate('LogSizeLimit')} + + +
); } LoggingSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, settings: PropTypes.object.isRequired, onInputChange: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/General/SecuritySettings.js b/frontend/src/Settings/General/SecuritySettings.js index 4b382800c..8e2597741 100644 --- a/frontend/src/Settings/General/SecuritySettings.js +++ b/frontend/src/Settings/General/SecuritySettings.js @@ -11,24 +11,69 @@ import ConfirmModal from 'Components/Modal/ConfirmModal'; import { icons, inputTypes, kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; -export const authenticationRequiredWarning = translate('AuthenticationRequiredWarning'); - export const authenticationMethodOptions = [ - { key: 'none', value: 'None', isDisabled: true }, - { key: 'external', value: 'External', isHidden: true }, - { key: 'basic', value: 'Basic (Browser Popup)' }, - { key: 'forms', value: 'Forms (Login Page)' } + { + key: 'none', + get value() { + return translate('None'); + }, + isDisabled: true + }, + { + key: 'external', + get value() { + return translate('External'); + }, + isHidden: true + }, + { + key: 'basic', + get value() { + return translate('AuthBasic'); + } + }, + { + key: 'forms', + get value() { + return translate('AuthForm'); + } + } ]; export const authenticationRequiredOptions = [ - { key: 'enabled', value: 'Enabled' }, - { key: 'disabledForLocalAddresses', value: 'Disabled for Local Addresses' } + { + key: 'enabled', + get value() { + return translate('Enabled'); + } + }, + { + key: 'disabledForLocalAddresses', + get value() { + return translate('DisabledForLocalAddresses'); + } + } ]; const certificateValidationOptions = [ - { key: 'enabled', value: 'Enabled' }, - { key: 'disabledForLocalAddresses', value: 'Disabled for Local Addresses' }, - { key: 'disabled', value: 'Disabled' } + { + key: 'enabled', + get value() { + return translate('Enabled'); + } + }, + { + key: 'disabledForLocalAddresses', + get value() { + return translate('DisabledForLocalAddresses'); + } + }, + { + key: 'disabled', + get value() { + return translate('Disabled'); + } + } ]; class SecuritySettings extends Component { @@ -79,6 +124,7 @@ class SecuritySettings extends Component { authenticationRequired, username, password, + passwordConfirmation, apiKey, certificateValidation } = settings; @@ -95,7 +141,7 @@ class SecuritySettings extends Component { name="authenticationMethod" values={authenticationMethodOptions} helpText={translate('AuthenticationMethodHelpText')} - helpTextWarning={authenticationRequiredWarning} + helpTextWarning={translate('AuthenticationRequiredWarning')} onChange={onInputChange} {...authenticationMethod} /> @@ -148,6 +194,21 @@ class SecuritySettings extends Component { null } + { + authenticationEnabled ? + + {translate('PasswordConfirmation')} + + + : + null + } + {translate('ApiKey')} @@ -155,6 +216,7 @@ class SecuritySettings extends Component { type={inputTypes.TEXT} name="apiKey" readOnly={true} + helpTextWarning={translate('RestartRequiredHelpTextWarning')} buttons={[ @@ -62,61 +61,58 @@ function UpdateSettings(props) { /> - { - !isWindows && -
- - {translate('Automatic')} +
+ + {translate('Automatic')} - - + + + + {translate('Mechanism')} + + + + + { + updateMechanism.value === 'script' && - {translate('Mechanism')} + {translate('ScriptPath')} - - { - updateMechanism.value === 'script' && - - {translate('ScriptPath')} - - - - } -
- } + } +
); } diff --git a/frontend/src/Settings/Indexers/IndexerProxies/AddIndexerProxyItem.js b/frontend/src/Settings/Indexers/IndexerProxies/AddIndexerProxyItem.js index ff238c915..77e1e6a98 100644 --- a/frontend/src/Settings/Indexers/IndexerProxies/AddIndexerProxyItem.js +++ b/frontend/src/Settings/Indexers/IndexerProxies/AddIndexerProxyItem.js @@ -16,10 +16,11 @@ class AddIndexerProxyItem extends Component { onIndexerProxySelect = () => { const { - implementation + implementation, + implementationName } = this.props; - this.props.onIndexerProxySelect({ implementation }); + this.props.onIndexerProxySelect({ implementation, implementationName }); }; // @@ -77,6 +78,7 @@ class AddIndexerProxyItem extends Component { key={preset.name} name={preset.name} implementation={implementation} + implementationName={implementationName} onPress={onIndexerProxySelect} /> ); diff --git a/frontend/src/Settings/Indexers/IndexerProxies/EditIndexerProxyModalContent.js b/frontend/src/Settings/Indexers/IndexerProxies/EditIndexerProxyModalContent.js index 59ce4e820..59e10422b 100644 --- a/frontend/src/Settings/Indexers/IndexerProxies/EditIndexerProxyModalContent.js +++ b/frontend/src/Settings/Indexers/IndexerProxies/EditIndexerProxyModalContent.js @@ -14,6 +14,7 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes, kinds } from 'Helpers/Props'; +import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton'; import translate from 'Utilities/String/translate'; import styles from './EditIndexerProxyModalContent.css'; @@ -31,6 +32,7 @@ function EditIndexerProxyModalContent(props) { onModalClose, onSavePress, onTestPress, + onAdvancedSettingsPress, onDeleteIndexerProxyPress, ...otherProps } = props; @@ -47,7 +49,7 @@ function EditIndexerProxyModalContent(props) { return ( - {`${id ? 'Edit' : 'Add'} Proxy - ${implementationName}`} + {id ? translate('EditIndexerProxyImplementation', { implementationName }) : translate('AddIndexerProxyImplementation', { implementationName })} @@ -130,6 +132,12 @@ function EditIndexerProxyModalContent(props) { } + + { + this.props.toggleAdvancedSettings(); + }; + // // Render @@ -65,6 +76,7 @@ class EditIndexerProxyModalContentConnector extends Component { {...this.props} onSavePress={this.onSavePress} onTestPress={this.onTestPress} + onAdvancedSettingsPress={this.onAdvancedSettingsPress} onInputChange={this.onInputChange} onFieldChange={this.onFieldChange} /> @@ -82,6 +94,7 @@ EditIndexerProxyModalContentConnector.propTypes = { setIndexerProxyFieldValue: PropTypes.func.isRequired, saveIndexerProxy: PropTypes.func.isRequired, testIndexerProxy: PropTypes.func.isRequired, + toggleAdvancedSettings: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/Indexers/IndexerProxies/IndexerProxiesConnector.js b/frontend/src/Settings/Indexers/IndexerProxies/IndexerProxiesConnector.js index 9d2188a7c..0d2acae87 100644 --- a/frontend/src/Settings/Indexers/IndexerProxies/IndexerProxiesConnector.js +++ b/frontend/src/Settings/Indexers/IndexerProxies/IndexerProxiesConnector.js @@ -5,13 +5,13 @@ import { createSelector } from 'reselect'; import { deleteIndexerProxy, fetchIndexerProxies } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import createTagsSelector from 'Store/Selectors/createTagsSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import IndexerProxies from './IndexerProxies'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.indexerProxies', sortByName), - createSortedSectionSelector('indexers', sortByName), + createSortedSectionSelector('settings.indexerProxies', sortByProp('name')), + createSortedSectionSelector('indexers', sortByProp('name')), createTagsSelector(), (indexerProxies, indexers, tagList) => { return { diff --git a/frontend/src/Settings/Indexers/IndexerProxies/IndexerProxy.js b/frontend/src/Settings/Indexers/IndexerProxies/IndexerProxy.js index 84292ae65..dcd7e1f35 100644 --- a/frontend/src/Settings/Indexers/IndexerProxies/IndexerProxy.js +++ b/frontend/src/Settings/Indexers/IndexerProxies/IndexerProxy.js @@ -122,7 +122,7 @@ class IndexerProxy extends Component { isOpen={this.state.isDeleteIndexerProxyModalOpen} kind={kinds.DANGER} title={translate('DeleteIndexerProxy')} - message={translate('DeleteIndexerProxyMessageText', [name])} + message={translate('DeleteIndexerProxyMessageText', { name })} confirmLabel={translate('Delete')} onConfirm={this.onConfirmDeleteIndexerProxy} onCancel={this.onDeleteIndexerProxyModalClose} diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js index aaf784094..a48a8f9d5 100644 --- a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js @@ -28,7 +28,7 @@ class AddNotificationModalContent extends Component { return ( - Add Notification + {translate('AddConnection')} diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js index ec20ccff1..ed00d96e6 100644 --- a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js @@ -14,6 +14,7 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes, kinds } from 'Helpers/Props'; +import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton'; import translate from 'Utilities/String/translate'; import NotificationEventItems from './NotificationEventItems'; import styles from './EditNotificationModalContent.css'; @@ -32,6 +33,7 @@ function EditNotificationModalContent(props) { onModalClose, onSavePress, onTestPress, + onAdvancedSettingsPress, onDeleteNotificationPress, ...otherProps } = props; @@ -48,7 +50,7 @@ function EditNotificationModalContent(props) { return ( - {`${id ? 'Edit' : 'Add'} Connection - ${implementationName}`} + {id ? translate('EditConnectionImplementation', { implementationName }) : translate('AddConnectionImplementation', { implementationName })} @@ -136,6 +138,12 @@ function EditNotificationModalContent(props) { } + + { + this.props.toggleAdvancedSettings(); + }; + // // Render @@ -65,6 +76,7 @@ class EditNotificationModalContentConnector extends Component { {...this.props} onSavePress={this.onSavePress} onTestPress={this.onTestPress} + onAdvancedSettingsPress={this.onAdvancedSettingsPress} onInputChange={this.onInputChange} onFieldChange={this.onFieldChange} /> @@ -82,6 +94,7 @@ EditNotificationModalContentConnector.propTypes = { setNotificationFieldValue: PropTypes.func.isRequired, saveNotification: PropTypes.func.isRequired, testNotification: PropTypes.func.isRequired, + toggleAdvancedSettings: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.js b/frontend/src/Settings/Notifications/Notifications/Notification.js index 0ffd49b40..4ecf33047 100644 --- a/frontend/src/Settings/Notifications/Notifications/Notification.js +++ b/frontend/src/Settings/Notifications/Notifications/Notification.js @@ -137,7 +137,7 @@ class Notification extends Component { isOpen={this.state.isDeleteNotificationModalOpen} kind={kinds.DANGER} title={translate('DeleteNotification')} - message={translate('DeleteNotificationMessageText', [name])} + message={translate('DeleteNotificationMessageText', { name })} confirmLabel={translate('Delete')} onConfirm={this.onConfirmDeleteNotification} onCancel={this.onDeleteNotificationModalClose} diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js index b306f742a..6351c6f8a 100644 --- a/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js +++ b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js @@ -5,12 +5,12 @@ import { createSelector } from 'reselect'; import { deleteNotification, fetchNotifications } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import createTagsSelector from 'Store/Selectors/createTagsSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import Notifications from './Notifications'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.notifications', sortByName), + createSortedSectionSelector('settings.notifications', sortByProp('name')), createTagsSelector(), (notifications, tagList) => { return { diff --git a/frontend/src/Settings/PendingChangesModal.js b/frontend/src/Settings/PendingChangesModal.js index 4cb83e8f6..213445c65 100644 --- a/frontend/src/Settings/PendingChangesModal.js +++ b/frontend/src/Settings/PendingChangesModal.js @@ -15,12 +15,17 @@ function PendingChangesModal(props) { isOpen, onConfirm, onCancel, - bindShortcut + bindShortcut, + unbindShortcut } = props; useEffect(() => { - bindShortcut('enter', onConfirm); - }, [bindShortcut, onConfirm]); + if (isOpen) { + bindShortcut('enter', onConfirm); + + return () => unbindShortcut('enter', onConfirm); + } + }, [bindShortcut, unbindShortcut, isOpen, onConfirm]); return ( - {translate('RSS')} + {translate('Rss')} } @@ -130,7 +130,7 @@ class AppProfile extends Component { isOpen={this.state.isDeleteAppProfileModalOpen} kind={kinds.DANGER} title={translate('DeleteAppProfile')} - message={translate('AppProfileDeleteConfirm', [name])} + message={translate('DeleteAppProfileMessageText', { name })} confirmLabel={translate('Delete')} isSpinning={isDeleting} onConfirm={this.onConfirmDeleteAppProfile} diff --git a/frontend/src/Settings/Profiles/App/AppProfilesConnector.js b/frontend/src/Settings/Profiles/App/AppProfilesConnector.js index a150655a6..02bf845df 100644 --- a/frontend/src/Settings/Profiles/App/AppProfilesConnector.js +++ b/frontend/src/Settings/Profiles/App/AppProfilesConnector.js @@ -4,12 +4,12 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { cloneAppProfile, deleteAppProfile, fetchAppProfiles } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import AppProfiles from './AppProfiles'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.appProfiles', sortByName), + createSortedSectionSelector('settings.appProfiles', sortByProp('name')), (appProfiles) => appProfiles ); } diff --git a/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.js b/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.js index aace8e039..ac67c77f2 100644 --- a/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.js +++ b/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.js @@ -97,20 +97,6 @@ class EditAppProfileModalContent extends Component { /> - - - {translate('EnableInteractiveSearch')} - - - - - {translate('EnableAutomaticSearch')} @@ -125,6 +111,20 @@ class EditAppProfileModalContent extends Component { /> + + + {translate('EnableInteractiveSearch')} + + + + + {translate('MinimumSeeders')} diff --git a/frontend/src/Settings/Tags/Tag.js b/frontend/src/Settings/Tags/Tag.js index 607a67543..afeb3863a 100644 --- a/frontend/src/Settings/Tags/Tag.js +++ b/frontend/src/Settings/Tags/Tag.js @@ -137,7 +137,7 @@ class Tag extends Component { isOpen={isDeleteTagModalOpen} kind={kinds.DANGER} title={translate('DeleteTag')} - message={translate('DeleteTagMessageText', [label])} + message={translate('DeleteTagMessageText', { label })} confirmLabel={translate('Delete')} onConfirm={this.onConfirmDeleteTag} onCancel={this.onDeleteTagModalClose} diff --git a/frontend/src/Settings/Tags/TagsConnector.js b/frontend/src/Settings/Tags/TagsConnector.js index 4f311e984..1f3de2034 100644 --- a/frontend/src/Settings/Tags/TagsConnector.js +++ b/frontend/src/Settings/Tags/TagsConnector.js @@ -3,12 +3,14 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { fetchApplications, fetchIndexerProxies, fetchNotifications } from 'Store/Actions/settingsActions'; -import { fetchTagDetails } from 'Store/Actions/tagActions'; +import { fetchTagDetails, fetchTags } from 'Store/Actions/tagActions'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import sortByProp from 'Utilities/Array/sortByProp'; import Tags from './Tags'; function createMapStateToProps() { return createSelector( - (state) => state.tags, + createSortedSectionSelector('tags', sortByProp('label')), (tags) => { const isFetching = tags.isFetching || tags.details.isFetching; const error = tags.error || tags.details.error; @@ -25,6 +27,7 @@ function createMapStateToProps() { } const mapDispatchToProps = { + dispatchFetchTags: fetchTags, dispatchFetchTagDetails: fetchTagDetails, dispatchFetchNotifications: fetchNotifications, dispatchFetchIndexerProxies: fetchIndexerProxies, @@ -38,12 +41,14 @@ class MetadatasConnector extends Component { componentDidMount() { const { + dispatchFetchTags, dispatchFetchTagDetails, dispatchFetchNotifications, dispatchFetchIndexerProxies, dispatchFetchApplications } = this.props; + dispatchFetchTags(); dispatchFetchTagDetails(); dispatchFetchNotifications(); dispatchFetchIndexerProxies(); @@ -63,6 +68,7 @@ class MetadatasConnector extends Component { } MetadatasConnector.propTypes = { + dispatchFetchTags: PropTypes.func.isRequired, dispatchFetchTagDetails: PropTypes.func.isRequired, dispatchFetchNotifications: PropTypes.func.isRequired, dispatchFetchIndexerProxies: PropTypes.func.isRequired, diff --git a/frontend/src/Settings/UI/UISettings.js b/frontend/src/Settings/UI/UISettings.js index 614fd2fe0..d156f4ff3 100644 --- a/frontend/src/Settings/UI/UISettings.js +++ b/frontend/src/Settings/UI/UISettings.js @@ -21,19 +21,19 @@ export const firstDayOfWeekOptions = [ ]; export const weekColumnOptions = [ - { key: 'ddd M/D', value: 'Tue 3/25' }, - { key: 'ddd MM/DD', value: 'Tue 03/25' }, - { key: 'ddd D/M', value: 'Tue 25/3' }, - { key: 'ddd DD/MM', value: 'Tue 25/03' } + { key: 'ddd M/D', value: 'Tue 3/25', hint: 'ddd M/D' }, + { key: 'ddd MM/DD', value: 'Tue 03/25', hint: 'ddd MM/DD' }, + { key: 'ddd D/M', value: 'Tue 25/3', hint: 'ddd D/M' }, + { key: 'ddd DD/MM', value: 'Tue 25/03', hint: 'ddd DD/MM' } ]; const shortDateFormatOptions = [ - { key: 'MMM D YYYY', value: 'Mar 25 2014' }, - { key: 'DD MMM YYYY', value: '25 Mar 2014' }, - { key: 'MM/D/YYYY', value: '03/25/2014' }, - { key: 'MM/DD/YYYY', value: '03/25/2014' }, - { key: 'DD/MM/YYYY', value: '25/03/2014' }, - { key: 'YYYY-MM-DD', value: '2014-03-25' } + { key: 'MMM D YYYY', value: 'Mar 25 2014', hint: 'MMM D YYYY' }, + { key: 'DD MMM YYYY', value: '25 Mar 2014', hint: 'DD MMM YYYY' }, + { key: 'MM/D/YYYY', value: '03/25/2014', hint: 'MM/D/YYYY' }, + { key: 'MM/DD/YYYY', value: '03/25/2014', hint: 'MM/DD/YYYY' }, + { key: 'DD/MM/YYYY', value: '25/03/2014', hint: 'DD/MM/YYYY' }, + { key: 'YYYY-MM-DD', value: '2014-03-25', hint: 'YYYY-MM-DD' } ]; const longDateFormatOptions = [ @@ -147,7 +147,7 @@ class UISettings extends Component { language.key === settings.uiLanguage.value) ? + settings.uiLanguage.errors : + [ + ...settings.uiLanguage.errors, + { message: translate('InvalidUILanguage') } + ]} /> diff --git a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js index a80ee1e45..f5ef10a4d 100644 --- a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js +++ b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js @@ -6,6 +6,8 @@ import getSectionState from 'Utilities/State/getSectionState'; import { set, updateServerSideCollection } from '../baseActions'; function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter) { + const [baseSection] = section.split('.'); + return function(getState, payload, dispatch) { dispatch(set({ section, isFetching: true })); @@ -25,10 +27,13 @@ function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter const { selectedFilterKey, - filters, - customFilters + filters } = sectionState; + const customFilters = getState().customFilters.items.filter((customFilter) => { + return customFilter.type === section || customFilter.type === baseSection; + }); + const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters); selectedFilters.forEach((filter) => { @@ -37,7 +42,8 @@ function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter const promise = createAjaxRequest({ url, - data + data, + traditional: true }).request; promise.done((response) => { diff --git a/frontend/src/Store/Actions/Creators/createTestProviderHandler.js b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js index ca26883fb..e35157dbd 100644 --- a/frontend/src/Store/Actions/Creators/createTestProviderHandler.js +++ b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js @@ -1,8 +1,11 @@ +import $ from 'jquery'; +import _ from 'lodash'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import getProviderState from 'Utilities/State/getProviderState'; import { set } from '../baseActions'; const abortCurrentRequests = {}; +let lastTestData = null; export function createCancelTestProviderHandler(section) { return function(getState, payload, dispatch) { @@ -17,10 +20,25 @@ function createTestProviderHandler(section, url) { return function(getState, payload, dispatch) { dispatch(set({ section, isTesting: true })); - const testData = getProviderState(payload, getState, section); + const { + queryParams = {}, + ...otherPayload + } = payload; + + const testData = getProviderState({ ...otherPayload }, getState, section); + const params = { ...queryParams }; + + // If the user is re-testing the same provider without changes + // force it to be tested. + + if (_.isEqual(testData, lastTestData)) { + params.forceTest = true; + } + + lastTestData = testData; const ajaxOptions = { - url: `${url}/test`, + url: `${url}/test?${$.param(params, true)}`, method: 'POST', contentType: 'application/json', dataType: 'json', @@ -32,6 +50,8 @@ function createTestProviderHandler(section, url) { abortCurrentRequests[section] = abortRequest; request.done((data) => { + lastTestData = null; + dispatch(set({ section, isTesting: false, diff --git a/frontend/src/Store/Actions/Settings/appProfiles.js b/frontend/src/Store/Actions/Settings/appProfiles.js index 357588b75..92a48e0b8 100644 --- a/frontend/src/Store/Actions/Settings/appProfiles.js +++ b/frontend/src/Store/Actions/Settings/appProfiles.js @@ -7,6 +7,7 @@ import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/create import { createThunk } from 'Store/thunks'; import getSectionState from 'Utilities/State/getSectionState'; import updateSectionState from 'Utilities/State/updateSectionState'; +import translate from 'Utilities/String/translate'; // // Variables @@ -87,7 +88,7 @@ export default { const pendingChanges = { ...item, id: 0 }; delete pendingChanges.id; - pendingChanges.name = `${pendingChanges.name} - Copy`; + pendingChanges.name = translate('DefaultNameCopiedProfile', { name: pendingChanges.name }); newState.pendingChanges = pendingChanges; return updateSectionState(state, section, newState); diff --git a/frontend/src/Store/Actions/Settings/applications.js b/frontend/src/Store/Actions/Settings/applications.js index 9f7111960..53a008b0c 100644 --- a/frontend/src/Store/Actions/Settings/applications.js +++ b/frontend/src/Store/Actions/Settings/applications.js @@ -1,4 +1,5 @@ import { createAction } from 'redux-actions'; +import { sortDirections } from 'Helpers/Props'; import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler'; import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler'; import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; @@ -7,6 +8,7 @@ import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHand import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createSetClientSideCollectionSortReducer from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer'; import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; import { createThunk } from 'Store/thunks'; @@ -30,9 +32,10 @@ export const CANCEL_SAVE_APPLICATION = 'settings/applications/cancelSaveApplicat export const DELETE_APPLICATION = 'settings/applications/deleteApplication'; export const TEST_APPLICATION = 'settings/applications/testApplication'; export const CANCEL_TEST_APPLICATION = 'settings/applications/cancelTestApplication'; -export const TEST_ALL_APPLICATIONS = 'indexers/testAllApplications'; +export const TEST_ALL_APPLICATIONS = 'settings/applications/testAllApplications'; export const BULK_EDIT_APPLICATIONS = 'settings/applications/bulkEditApplications'; export const BULK_DELETE_APPLICATIONS = 'settings/applications/bulkDeleteApplications'; +export const SET_MANAGE_APPLICATIONS_SORT = 'settings/applications/setManageApplicationsSort'; // // Action Creators @@ -49,6 +52,7 @@ export const cancelTestApplication = createThunk(CANCEL_TEST_APPLICATION); export const testAllApplications = createThunk(TEST_ALL_APPLICATIONS); export const bulkEditApplications = createThunk(BULK_EDIT_APPLICATIONS); export const bulkDeleteApplications = createThunk(BULK_DELETE_APPLICATIONS); +export const setManageApplicationsSort = createAction(SET_MANAGE_APPLICATIONS_SORT); export const setApplicationValue = createAction(SET_APPLICATION_VALUE, (payload) => { return { @@ -88,7 +92,14 @@ export default { isTesting: false, isTestingAll: false, items: [], - pendingChanges: {} + pendingChanges: {}, + sortKey: 'name', + sortDirection: sortDirections.ASCENDING, + sortPredicates: { + name: function(item) { + return item.name.toLowerCase(); + } + } }, // @@ -117,14 +128,14 @@ export default { [SELECT_APPLICATION_SCHEMA]: (state, { payload }) => { return selectProviderSchema(state, section, payload, (selectedSchema) => { - selectedSchema.onGrab = selectedSchema.supportsOnGrab; - selectedSchema.onDownload = selectedSchema.supportsOnDownload; - selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade; - selectedSchema.onRename = selectedSchema.supportsOnRename; + selectedSchema.name = selectedSchema.implementationName; return selectedSchema; }); - } + }, + + [SET_MANAGE_APPLICATIONS_SORT]: createSetClientSideCollectionSortReducer(section) + } }; diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js index b4513e7c1..56784d5d0 100644 --- a/frontend/src/Store/Actions/Settings/downloadClients.js +++ b/frontend/src/Store/Actions/Settings/downloadClients.js @@ -1,4 +1,5 @@ import { createAction } from 'redux-actions'; +import { sortDirections } from 'Helpers/Props'; import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler'; import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler'; import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; @@ -7,6 +8,7 @@ import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHand import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createSetClientSideCollectionSortReducer from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer'; import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; import { createThunk } from 'Store/thunks'; @@ -34,6 +36,7 @@ export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestD export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients'; export const BULK_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients'; export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients'; +export const SET_MANAGE_DOWNLOAD_CLIENTS_SORT = 'settings/downloadClients/setManageDownloadClientsSort'; // // Action Creators @@ -50,6 +53,7 @@ export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT) export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS); export const bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS); export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS); +export const setManageDownloadClientsSort = createAction(SET_MANAGE_DOWNLOAD_CLIENTS_SORT); export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => { return { @@ -89,7 +93,14 @@ export default { isTesting: false, isTestingAll: false, items: [], - pendingChanges: {} + pendingChanges: {}, + sortKey: 'name', + sortDirection: sortDirections.ASCENDING, + sortPredicates: { + name: function(item) { + return item.name.toLowerCase(); + } + } }, // @@ -142,11 +153,15 @@ export default { [SELECT_DOWNLOAD_CLIENT_SCHEMA]: (state, { payload }) => { return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.name = selectedSchema.implementationName; selectedSchema.enable = true; return selectedSchema; }); - } + }, + + [SET_MANAGE_DOWNLOAD_CLIENTS_SORT]: createSetClientSideCollectionSortReducer(section) + } }; diff --git a/frontend/src/Store/Actions/Settings/indexerProxies.js b/frontend/src/Store/Actions/Settings/indexerProxies.js index 20edb2099..6c07540be 100644 --- a/frontend/src/Store/Actions/Settings/indexerProxies.js +++ b/frontend/src/Store/Actions/Settings/indexerProxies.js @@ -104,6 +104,8 @@ export default { [SELECT_INDEXER_PROXY_SCHEMA]: (state, { payload }) => { return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.name = selectedSchema.implementationName; + return selectedSchema; }); } diff --git a/frontend/src/Store/Actions/Settings/notifications.js b/frontend/src/Store/Actions/Settings/notifications.js index de927ba4f..28346e9a6 100644 --- a/frontend/src/Store/Actions/Settings/notifications.js +++ b/frontend/src/Store/Actions/Settings/notifications.js @@ -104,6 +104,7 @@ export default { [SELECT_NOTIFICATION_SCHEMA]: (state, { payload }) => { return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.name = selectedSchema.implementationName; selectedSchema.onGrab = selectedSchema.supportsOnGrab; selectedSchema.onApplicationUpdate = selectedSchema.supportsOnApplicationUpdate; diff --git a/frontend/src/Store/Actions/appActions.js b/frontend/src/Store/Actions/appActions.js index a273c7292..2b779d1b0 100644 --- a/frontend/src/Store/Actions/appActions.js +++ b/frontend/src/Store/Actions/appActions.js @@ -4,6 +4,7 @@ import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import getSectionState from 'Utilities/State/getSectionState'; import updateSectionState from 'Utilities/State/updateSectionState'; +import { fetchTranslations as fetchAppTranslations } from 'Utilities/String/translate'; import createHandleActions from './Creators/createHandleActions'; function getDimensions(width, height) { @@ -41,7 +42,12 @@ export const defaultState = { isReconnecting: false, isDisconnected: false, isRestarting: false, - isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen + isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen, + translations: { + isFetching: true, + isPopulated: false, + error: null + } }; // @@ -53,6 +59,7 @@ export const SAVE_DIMENSIONS = 'app/saveDimensions'; export const SET_VERSION = 'app/setVersion'; export const SET_APP_VALUE = 'app/setAppValue'; export const SET_IS_SIDEBAR_VISIBLE = 'app/setIsSidebarVisible'; +export const FETCH_TRANSLATIONS = 'app/fetchTranslations'; export const PING_SERVER = 'app/pingServer'; @@ -66,6 +73,7 @@ export const setAppValue = createAction(SET_APP_VALUE); export const showMessage = createAction(SHOW_MESSAGE); export const hideMessage = createAction(HIDE_MESSAGE); export const pingServer = createThunk(PING_SERVER); +export const fetchTranslations = createThunk(FETCH_TRANSLATIONS); // // Helpers @@ -127,6 +135,17 @@ function pingServerAfterTimeout(getState, dispatch) { export const actionHandlers = handleThunks({ [PING_SERVER]: function(getState, payload, dispatch) { pingServerAfterTimeout(getState, dispatch); + }, + [FETCH_TRANSLATIONS]: async function(getState, payload, dispatch) { + const isFetchingComplete = await fetchAppTranslations(); + + dispatch(setAppValue({ + translations: { + isFetching: false, + isPopulated: isFetchingComplete, + error: isFetchingComplete ? null : 'Failed to load translations from API' + } + })); } }); diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js index c8019e6d4..c324fe227 100644 --- a/frontend/src/Store/Actions/historyActions.js +++ b/frontend/src/Store/Actions/historyActions.js @@ -1,5 +1,5 @@ import { createAction } from 'redux-actions'; -import { filterTypes, sortDirections } from 'Helpers/Props'; +import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; @@ -30,67 +30,73 @@ export const defaultState = { columns: [ { name: 'eventType', - columnLabel: translate('EventType'), + columnLabel: () => translate('EventType'), isVisible: true, isModifiable: false }, { name: 'indexer', - label: translate('Indexer'), + label: () => translate('Indexer'), isSortable: false, isVisible: true }, { name: 'query', - label: translate('Query'), + label: () => translate('Query'), isSortable: false, isVisible: true }, { name: 'parameters', - label: translate('Parameters'), + label: () => translate('Parameters'), isSortable: false, isVisible: true }, { name: 'grabTitle', - label: translate('GrabTitle'), + label: () => translate('GrabTitle'), isSortable: false, isVisible: false }, { name: 'queryType', - label: translate('QueryType'), + label: () => translate('QueryType'), isSortable: false, isVisible: false }, { name: 'categories', - label: translate('Categories'), + label: () => translate('Categories'), isSortable: false, isVisible: true }, { name: 'date', - label: translate('Date'), + label: () => translate('Date'), isSortable: true, isVisible: true }, { name: 'source', - label: translate('Source'), + label: () => translate('Source'), + isSortable: false, + isVisible: false + }, + { + name: 'host', + label: () => translate('Host'), isSortable: false, isVisible: false }, { name: 'elapsedTime', - label: translate('ElapsedTime'), + label: () => translate('ElapsedTime'), isSortable: false, isVisible: true }, { name: 'details', - columnLabel: translate('Details'), + columnLabel: () => translate('Details'), isVisible: true, isModifiable: false } @@ -101,12 +107,12 @@ export const defaultState = { filters: [ { key: 'all', - label: translate('All'), + label: () => translate('All'), filters: [] }, { key: 'releaseGrabbed', - label: translate('Grabbed'), + label: () => translate('Grabbed'), filters: [ { key: 'eventType', @@ -117,7 +123,7 @@ export const defaultState = { }, { key: 'indexerRss', - label: translate('IndexerRss'), + label: () => translate('IndexerRss'), filters: [ { key: 'eventType', @@ -128,7 +134,7 @@ export const defaultState = { }, { key: 'indexerQuery', - label: translate('IndexerQuery'), + label: () => translate('IndexerQuery'), filters: [ { key: 'eventType', @@ -139,7 +145,7 @@ export const defaultState = { }, { key: 'indexerAuth', - label: translate('IndexerAuth'), + label: () => translate('IndexerAuth'), filters: [ { key: 'eventType', @@ -150,7 +156,7 @@ export const defaultState = { }, { key: 'failed', - label: translate('Failed'), + label: () => translate('Failed'), filters: [ { key: 'successful', @@ -159,6 +165,27 @@ export const defaultState = { } ] } + ], + + filterBuilderProps: [ + { + name: 'eventType', + label: () => translate('EventType'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE + }, + { + name: 'indexerIds', + label: () => translate('Indexer'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.INDEXER + }, + { + name: 'successful', + label: () => translate('Successful'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.BOOL + } ] }; diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 98db37faf..a25144d5a 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -4,6 +4,7 @@ import * as commands from './commandActions'; import * as customFilters from './customFilterActions'; import * as history from './historyActions'; import * as indexers from './indexerActions'; +import * as indexerHistory from './indexerHistoryActions'; import * as indexerIndex from './indexerIndexActions'; import * as indexerStats from './indexerStatsActions'; import * as indexerStatus from './indexerStatusActions'; @@ -28,6 +29,7 @@ export default [ releases, localization, indexers, + indexerHistory, indexerIndex, indexerStats, indexerStatus, diff --git a/frontend/src/Store/Actions/indexerActions.js b/frontend/src/Store/Actions/indexerActions.js index 3a2e7d5ce..e11051c2f 100644 --- a/frontend/src/Store/Actions/indexerActions.js +++ b/frontend/src/Store/Actions/indexerActions.js @@ -1,11 +1,15 @@ import _ from 'lodash'; import { createAction } from 'redux-actions'; -import { sortDirections } from 'Helpers/Props'; +import { filterTypePredicates, sortDirections } from 'Helpers/Props'; import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; -import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createSaveProviderHandler, { + createCancelSaveProviderHandler +} from 'Store/Actions/Creators/createSaveProviderHandler'; import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; -import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createTestProviderHandler, { + createCancelTestProviderHandler +} from 'Store/Actions/Creators/createTestProviderHandler'; import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; import { createThunk, handleThunks } from 'Store/thunks'; @@ -16,6 +20,7 @@ import translate from 'Utilities/String/translate'; import createBulkEditItemHandler from './Creators/createBulkEditItemHandler'; import createBulkRemoveItemHandler from './Creators/createBulkRemoveItemHandler'; import createHandleActions from './Creators/createHandleActions'; +import createClearReducer from './Creators/Reducers/createClearReducer'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; // @@ -54,7 +59,7 @@ export const defaultState = { export const filters = [ { key: 'all', - label: translate('All'), + label: () => translate('All'), filters: [] } ]; @@ -69,15 +74,68 @@ export const filterPredicates = { item.fields.find((field) => field.name === 'vipExpiration')?.value ?? null; return dateFilterPredicate(vipExpiration, filterValue, type); + }, + + categories: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + + const { categories = [] } = item.capabilities || {}; + + const categoryList = categories + .filter((category) => category.id < 100000) + .reduce((acc, element) => { + acc.push(element.id); + + if (element.subCategories && element.subCategories.length > 0) { + element.subCategories.forEach((subCat) => { + acc.push(subCat.id); + }); + } + + return acc; + }, []); + + return predicate(categoryList, filterValue); } }; export const sortPredicates = { - vipExpiration: function(item) { - const vipExpiration = - item.fields.find((field) => field.name === 'vipExpiration')?.value ?? ''; + status: function({ enable, redirect }) { + let result = 0; - return vipExpiration; + if (redirect) { + result++; + } + + if (enable) { + result += 2; + } + + return result; + }, + + vipExpiration: function({ fields = [] }) { + return fields.find((field) => field.name === 'vipExpiration')?.value ?? ''; + }, + + minimumSeeders: function({ fields = [] }) { + return fields.find((field) => field.name === 'torrentBaseSettings.appMinimumSeeders')?.value ?? undefined; + }, + + seedRatio: function({ fields = [] }) { + return fields.find((field) => field.name === 'torrentBaseSettings.seedRatio')?.value ?? undefined; + }, + + seedTime: function({ fields = [] }) { + return fields.find((field) => field.name === 'torrentBaseSettings.seedTime')?.value ?? undefined; + }, + + packSeedTime: function({ fields = [] }) { + return fields.find((field) => field.name === 'torrentBaseSettings.packSeedTime')?.value ?? undefined; + }, + + preferMagnetUrl: function({ fields = [] }) { + return fields.find((field) => field.name === 'torrentBaseSettings.preferMagnetUrl')?.value ?? undefined; } }; @@ -88,6 +146,7 @@ export const FETCH_INDEXERS = 'indexers/fetchIndexers'; export const FETCH_INDEXER_SCHEMA = 'indexers/fetchIndexerSchema'; export const SELECT_INDEXER_SCHEMA = 'indexers/selectIndexerSchema'; export const SET_INDEXER_SCHEMA_SORT = 'indexers/setIndexerSchemaSort'; +export const CLEAR_INDEXER_SCHEMA = 'indexers/clearIndexerSchema'; export const CLONE_INDEXER = 'indexers/cloneIndexer'; export const SET_INDEXER_VALUE = 'indexers/setIndexerValue'; export const SET_INDEXER_FIELD_VALUE = 'indexers/setIndexerFieldValue'; @@ -107,6 +166,7 @@ export const fetchIndexers = createThunk(FETCH_INDEXERS); export const fetchIndexerSchema = createThunk(FETCH_INDEXER_SCHEMA); export const selectIndexerSchema = createAction(SELECT_INDEXER_SCHEMA); export const setIndexerSchemaSort = createAction(SET_INDEXER_SCHEMA_SORT); +export const clearIndexerSchema = createAction(CLEAR_INDEXER_SCHEMA); export const cloneIndexer = createAction(CLONE_INDEXER); export const saveIndexer = createThunk(SAVE_INDEXER); @@ -184,12 +244,16 @@ export const reducers = createHandleActions({ [SELECT_INDEXER_SCHEMA]: (state, { payload }) => { return selectSchema(state, payload, (selectedSchema) => { + selectedSchema.name = payload.name ?? payload.implementationName; + selectedSchema.implementationName = payload.implementationName; selectedSchema.enable = selectedSchema.supportsRss; return selectedSchema; }); }, + [CLEAR_INDEXER_SCHEMA]: createClearReducer(schemaSection, defaultState), + [CLONE_INDEXER]: function(state, { payload }) { const id = payload.id; const newState = getSectionState(state, section); @@ -201,14 +265,20 @@ export const reducers = createHandleActions({ delete selectedSchema.name; selectedSchema.fields = selectedSchema.fields.map((field) => { - return { ...field }; + const newField = { ...field }; + + if (newField.privacy === 'apiKey' || newField.privacy === 'password') { + newField.value = ''; + } + + return newField; }); newState.selectedSchema = selectedSchema; // Set the name in pendingChanges newState.pendingChanges = { - name: `${item.name} - Copy` + name: translate('DefaultNameCopiedProfile', { name: item.name }) }; return updateSectionState(state, section, newState); diff --git a/frontend/src/Store/Actions/indexerHistoryActions.js b/frontend/src/Store/Actions/indexerHistoryActions.js new file mode 100644 index 000000000..2cec678e1 --- /dev/null +++ b/frontend/src/Store/Actions/indexerHistoryActions.js @@ -0,0 +1,81 @@ +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set, update } from './baseActions'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'indexerHistory'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_INDEXER_HISTORY = 'indexerHistory/fetchIndexerHistory'; +export const CLEAR_INDEXER_HISTORY = 'indexerHistory/clearIndexerHistory'; + +// +// Action Creators + +export const fetchIndexerHistory = createThunk(FETCH_INDEXER_HISTORY); +export const clearIndexerHistory = createAction(CLEAR_INDEXER_HISTORY); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_INDEXER_HISTORY]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const promise = createAjaxRequest({ + url: '/history/indexer', + data: payload + }).request; + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_INDEXER_HISTORY]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/indexerIndexActions.js b/frontend/src/Store/Actions/indexerIndexActions.js index a42897996..a002d9b41 100644 --- a/frontend/src/Store/Actions/indexerIndexActions.js +++ b/frontend/src/Store/Actions/indexerIndexActions.js @@ -32,93 +32,105 @@ export const defaultState = { columns: [ { name: 'status', - columnLabel: translate('IndexerStatus'), + columnLabel: () => translate('IndexerStatus'), isSortable: true, isVisible: true, isModifiable: false }, + { + name: 'id', + columnLabel: () => translate('IndexerId'), + label: () => translate('Id'), + isSortable: true, + isVisible: false + }, { name: 'sortName', - label: translate('IndexerName'), + label: () => translate('IndexerName'), isSortable: true, - isVisible: true, - isModifiable: false + isVisible: true }, { name: 'protocol', - label: translate('Protocol'), + label: () => translate('Protocol'), isSortable: true, isVisible: true }, { name: 'privacy', - label: translate('Privacy'), + label: () => translate('Privacy'), isSortable: true, isVisible: true }, { name: 'priority', - label: translate('Priority'), + label: () => translate('Priority'), isSortable: true, isVisible: true }, { name: 'appProfileId', - label: translate('SyncProfile'), + label: () => translate('SyncProfile'), isSortable: true, isVisible: true }, { name: 'added', - label: translate('Added'), + label: () => translate('Added'), isSortable: true, isVisible: true }, { name: 'vipExpiration', - label: translate('VipExpiration'), + label: () => translate('VipExpiration'), isSortable: true, isVisible: false }, { name: 'capabilities', - label: translate('Categories'), + label: () => translate('Categories'), isSortable: false, isVisible: true }, { name: 'minimumSeeders', - label: translate('MinimumSeeders'), + label: () => translate('MinimumSeeders'), isSortable: true, isVisible: false }, { name: 'seedRatio', - label: translate('SeedRatio'), + label: () => translate('SeedRatio'), isSortable: true, isVisible: false }, { name: 'seedTime', - label: translate('SeedTime'), + label: () => translate('SeedTime'), isSortable: true, isVisible: false }, { name: 'packSeedTime', - label: translate('PackSeedTime'), + label: () => translate('PackSeedTime'), + isSortable: true, + isVisible: false + }, + { + name: 'preferMagnetUrl', + label: () => translate('PreferMagnetUrl'), isSortable: true, isVisible: false }, { name: 'tags', - label: translate('Tags'), + label: () => translate('Tags'), isSortable: false, isVisible: false }, { name: 'actions', - columnLabel: translate('Actions'), + columnLabel: () => translate('Actions'), isVisible: true, isModifiable: false } @@ -136,53 +148,59 @@ export const defaultState = { filterBuilderProps: [ { name: 'name', - label: translate('IndexerName'), + label: () => translate('IndexerName'), type: filterBuilderTypes.STRING }, { name: 'enable', - label: translate('Enabled'), + label: () => translate('Enabled'), type: filterBuilderTypes.EXACT, valueType: filterBuilderValueTypes.BOOL }, { name: 'added', - label: translate('Added'), + label: () => translate('Added'), type: filterBuilderTypes.DATE, valueType: filterBuilderValueTypes.DATE }, { name: 'vipExpiration', - label: translate('VipExpiration'), + label: () => translate('VipExpiration'), type: filterBuilderTypes.DATE, valueType: filterBuilderValueTypes.DATE }, { name: 'priority', - label: translate('Priority'), + label: () => translate('Priority'), type: filterBuilderTypes.NUMBER }, { name: 'protocol', - label: translate('Protocol'), + label: () => translate('Protocol'), type: filterBuilderTypes.EXACT, valueType: filterBuilderValueTypes.PROTOCOL }, { name: 'privacy', - label: translate('Privacy'), + label: () => translate('Privacy'), type: filterBuilderTypes.EXACT, valueType: filterBuilderValueTypes.PRIVACY }, { name: 'appProfileId', - label: translate('SyncProfile'), + label: () => translate('SyncProfile'), type: filterBuilderTypes.EXACT, valueType: filterBuilderValueTypes.APP_PROFILE }, + { + name: 'categories', + label: () => translate('Categories'), + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.CATEGORY + }, { name: 'tags', - label: translate('Tags'), + label: () => translate('Tags'), type: filterBuilderTypes.ARRAY, valueType: filterBuilderValueTypes.TAG } diff --git a/frontend/src/Store/Actions/indexerStatsActions.js b/frontend/src/Store/Actions/indexerStatsActions.js index e937cee93..06c9586b5 100644 --- a/frontend/src/Store/Actions/indexerStatsActions.js +++ b/frontend/src/Store/Actions/indexerStatsActions.js @@ -3,6 +3,7 @@ import { batchActions } from 'redux-batched-actions'; import { filterBuilderTypes, filterBuilderValueTypes } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; import translate from 'Utilities/String/translate'; import { set, update } from './baseActions'; import createHandleActions from './Creators/createHandleActions'; @@ -33,7 +34,7 @@ export const defaultState = { filters: [ { key: 'all', - label: translate('All'), + label: () => translate('All'), filters: [] }, { @@ -55,19 +56,27 @@ export const defaultState = { filterBuilderProps: [ { - name: 'startDate', - label: 'Start Date', - type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.DATE + name: 'indexers', + label: () => translate('Indexers'), + type: filterBuilderTypes.CONTAINS, + valueType: filterBuilderValueTypes.INDEXER }, { - name: 'endDate', - label: 'End Date', + name: 'protocols', + label: () => translate('Protocols'), type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.DATE + valueType: filterBuilderValueTypes.PROTOCOL + }, + { + name: 'tags', + label: () => translate('Tags'), + type: filterBuilderTypes.CONTAINS, + valueType: filterBuilderValueTypes.TAG } ], + selectedFilterKey: 'all' + }; export const persistState = [ @@ -81,6 +90,10 @@ export const persistState = [ export const FETCH_INDEXER_STATS = 'indexerStats/fetchIndexerStats'; export const SET_INDEXER_STATS_FILTER = 'indexerStats/setIndexerStatsFilter'; +function getCustomFilters(state, type) { + return state.customFilters.items.filter((customFilter) => customFilter.type === type); +} + // // Action Creators @@ -94,23 +107,39 @@ export const actionHandlers = handleThunks({ [FETCH_INDEXER_STATS]: function(getState, payload, dispatch) { const state = getState(); const indexerStats = state.indexerStats; + const customFilters = getCustomFilters(state, section); + const selectedFilters = findSelectedFilters(indexerStats.selectedFilterKey, indexerStats.filters, customFilters); const requestParams = { endDate: moment().toISOString() }; + selectedFilters.forEach((selectedFilter) => { + if (selectedFilter.key === 'indexers') { + requestParams.indexers = selectedFilter.value.join(','); + } + + if (selectedFilter.key === 'protocols') { + requestParams.protocols = selectedFilter.value.join(','); + } + + if (selectedFilter.key === 'tags') { + requestParams.tags = selectedFilter.value.join(','); + } + }); + if (indexerStats.selectedFilterKey !== 'all') { - let dayCount = 7; + if (indexerStats.selectedFilterKey === 'lastSeven') { + requestParams.startDate = moment().add(-7, 'days').endOf('day').toISOString(); + } if (indexerStats.selectedFilterKey === 'lastThirty') { - dayCount = 30; + requestParams.startDate = moment().add(-30, 'days').endOf('day').toISOString(); } if (indexerStats.selectedFilterKey === 'lastNinety') { - dayCount = 90; + requestParams.startDate = moment().add(-90, 'days').endOf('day').toISOString(); } - - requestParams.startDate = moment().add(-dayCount, 'days').endOf('day').toISOString(); } const basesAttrs = { diff --git a/frontend/src/Store/Actions/oAuthActions.js b/frontend/src/Store/Actions/oAuthActions.js index b6b05d05e..b5b4966ac 100644 --- a/frontend/src/Store/Actions/oAuthActions.js +++ b/frontend/src/Store/Actions/oAuthActions.js @@ -60,7 +60,7 @@ function showOAuthWindow(url, payload) { responseJSON: [ { propertyName: payload.name, - errorMessage: translate('OAuthPopupMessage') + errorMessage: () => translate('OAuthPopupMessage') } ] }; diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js index 298357a82..fd2fe441b 100644 --- a/frontend/src/Store/Actions/releaseActions.js +++ b/frontend/src/Store/Actions/releaseActions.js @@ -1,7 +1,9 @@ import $ from 'jquery'; +import React from 'react'; import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; -import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import { filterBuilderTypes, filterBuilderValueTypes, filterTypePredicates, icons, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import getSectionState from 'Utilities/State/getSectionState'; @@ -56,67 +58,71 @@ export const defaultState = { }, { name: 'protocol', - label: translate('Protocol'), + label: () => translate('Protocol'), isSortable: true, isVisible: true }, { name: 'age', - label: translate('Age'), + label: () => translate('Age'), isSortable: true, isVisible: true }, { name: 'sortTitle', - label: translate('Title'), + label: () => translate('Title'), isSortable: true, isVisible: true }, { name: 'indexer', - label: translate('Indexer'), + label: () => translate('Indexer'), isSortable: true, isVisible: true }, { name: 'size', - label: translate('Size'), + label: () => translate('Size'), isSortable: true, isVisible: true }, { name: 'files', - label: translate('Files'), + label: () => translate('Files'), isSortable: true, isVisible: false }, { name: 'grabs', - label: translate('Grabs'), + label: () => translate('Grabs'), isSortable: true, isVisible: true }, { name: 'peers', - label: translate('Peers'), + label: () => translate('Peers'), isSortable: true, isVisible: true }, { name: 'category', - label: translate('Category'), + label: () => translate('Category'), isSortable: true, isVisible: true }, { name: 'indexerFlags', - columnLabel: 'Indexer Flags', + columnLabel: () => translate('IndexerFlags'), + label: React.createElement(Icon, { + name: icons.FLAG, + title: () => translate('IndexerFlags') + }), isSortable: true, isVisible: true }, { name: 'actions', - columnLabel: translate('Actions'), + columnLabel: () => translate('Actions'), isVisible: true, isModifiable: false } @@ -158,57 +164,70 @@ export const defaultState = { filters: [ { key: 'all', - label: translate('All'), + label: () => translate('All'), filters: [] } ], + filterPredicates: { + peers: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + + const seeders = item.seeders || 0; + const leechers = item.leechers || 0; + const peers = seeders + leechers; + + return predicate(peers, filterValue); + } + }, + filterBuilderProps: [ { name: 'title', - label: translate('Title'), + label: () => translate('Title'), type: filterBuilderTypes.STRING }, { name: 'age', - label: translate('Age'), + label: () => translate('Age'), type: filterBuilderTypes.NUMBER }, { name: 'protocol', - label: translate('Protocol'), + label: () => translate('Protocol'), type: filterBuilderTypes.EXACT, valueType: filterBuilderValueTypes.PROTOCOL }, { name: 'indexerId', - label: translate('Indexer'), + label: () => translate('Indexer'), type: filterBuilderTypes.EXACT, valueType: filterBuilderValueTypes.INDEXER }, { name: 'size', - label: translate('Size'), - type: filterBuilderTypes.NUMBER + label: () => translate('Size'), + type: filterBuilderTypes.NUMBER, + valueType: filterBuilderValueTypes.BYTES }, { name: 'files', - label: translate('Files'), + label: () => translate('Files'), type: filterBuilderTypes.NUMBER }, { name: 'grabs', - label: translate('Grabs'), + label: () => translate('Grabs'), type: filterBuilderTypes.NUMBER }, { name: 'seeders', - label: translate('Seeders'), + label: () => translate('Seeders'), type: filterBuilderTypes.NUMBER }, { name: 'peers', - label: translate('Peers'), + label: () => translate('Peers'), type: filterBuilderTypes.NUMBER } ], @@ -350,8 +369,9 @@ export const actionHandlers = handleThunks({ promise.done((data) => { dispatch(batchActions([ - ...data.map((release) => { + ...data.map(({ guid }) => { return updateRelease({ + guid, isGrabbing: false, isGrabbed: true, grabError: null @@ -381,7 +401,16 @@ export const actionHandlers = handleThunks({ export const reducers = createHandleActions({ [CLEAR_RELEASES]: (state) => { - return Object.assign({}, state, defaultState); + const { + sortKey, + sortDirection, + customFilters, + selectedFilterKey, + columns, + ...otherDefaultState + } = defaultState; + + return Object.assign({}, state, otherDefaultState); }, [UPDATE_RELEASE]: (state, { payload }) => { diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js index 4910e462d..75d2595cf 100644 --- a/frontend/src/Store/Actions/systemActions.js +++ b/frontend/src/Store/Actions/systemActions.js @@ -82,35 +82,34 @@ export const defaultState = { columns: [ { name: 'level', - columnLabel: translate('Level'), + columnLabel: () => translate('Level'), isSortable: false, isVisible: true, isModifiable: false }, { name: 'time', - label: translate('Time'), + label: () => translate('Time'), isSortable: true, isVisible: true, isModifiable: false }, { name: 'logger', - label: translate('Component'), + label: () => translate('Component'), isSortable: false, isVisible: true, isModifiable: false }, { name: 'message', - label: translate('Message'), + label: () => translate('Message'), isVisible: true, isModifiable: false }, { name: 'actions', - columnLabel: translate('Actions'), - isSortable: true, + columnLabel: () => translate('Actions'), isVisible: true, isModifiable: false } @@ -121,12 +120,12 @@ export const defaultState = { filters: [ { key: 'all', - label: translate('All'), + label: () => translate('All'), filters: [] }, { key: 'info', - label: translate('Info'), + label: () => translate('Info'), filters: [ { key: 'level', @@ -137,7 +136,7 @@ export const defaultState = { }, { key: 'warn', - label: translate('Warn'), + label: () => translate('Warn'), filters: [ { key: 'level', @@ -148,7 +147,7 @@ export const defaultState = { }, { key: 'error', - label: translate('Error'), + label: () => translate('Error'), filters: [ { key: 'level', diff --git a/frontend/src/Store/Middleware/createPersistState.js b/frontend/src/Store/Middleware/createPersistState.js index 73047b5de..1840959ed 100644 --- a/frontend/src/Store/Middleware/createPersistState.js +++ b/frontend/src/Store/Middleware/createPersistState.js @@ -36,10 +36,17 @@ function mergeColumns(path, initialState, persistedState, computedState) { const column = initialColumns.find((i) => i.name === persistedColumn.name); if (column) { - columns.push({ - ...column, - isVisible: persistedColumn.isVisible - }); + const newColumn = {}; + + // We can't use a spread operator or Object.assign to clone the column + // or any accessors are lost and can break translations. + for (const prop of Object.keys(column)) { + Object.defineProperty(newColumn, prop, Object.getOwnPropertyDescriptor(column, prop)); + } + + newColumn.isVisible = persistedColumn.isVisible; + + columns.push(newColumn); } }); diff --git a/frontend/src/Store/Selectors/createAllIndexersSelector.js b/frontend/src/Store/Selectors/createAllIndexersSelector.ts similarity index 71% rename from frontend/src/Store/Selectors/createAllIndexersSelector.js rename to frontend/src/Store/Selectors/createAllIndexersSelector.ts index 178c54eed..76641025f 100644 --- a/frontend/src/Store/Selectors/createAllIndexersSelector.js +++ b/frontend/src/Store/Selectors/createAllIndexersSelector.ts @@ -1,8 +1,9 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createAllIndexersSelector() { return createSelector( - (state) => state.indexers, + (state: AppState) => state.indexers, (indexers) => { return indexers.items; } diff --git a/frontend/src/Store/Selectors/createAppProfileSelector.js b/frontend/src/Store/Selectors/createAppProfileSelector.js deleted file mode 100644 index 42452ccfd..000000000 --- a/frontend/src/Store/Selectors/createAppProfileSelector.js +++ /dev/null @@ -1,15 +0,0 @@ -import { createSelector } from 'reselect'; - -function createAppProfileSelector() { - return createSelector( - (state, { appProfileId }) => appProfileId, - (state) => state.settings.appProfiles.items, - (appProfileId, appProfiles) => { - return appProfiles.find((profile) => { - return profile.id === appProfileId; - }); - } - ); -} - -export default createAppProfileSelector; diff --git a/frontend/src/Store/Selectors/createAppProfileSelector.ts b/frontend/src/Store/Selectors/createAppProfileSelector.ts new file mode 100644 index 000000000..b26ab71a4 --- /dev/null +++ b/frontend/src/Store/Selectors/createAppProfileSelector.ts @@ -0,0 +1,14 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +function createAppProfileSelector() { + return createSelector( + (_: AppState, { appProfileId }: { appProfileId: number }) => appProfileId, + (state: AppState) => state.settings.appProfiles.items, + (appProfileId, appProfiles) => { + return appProfiles.find((profile) => profile.id === appProfileId); + } + ); +} + +export default createAppProfileSelector; diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js index ae1031dca..1bac14f08 100644 --- a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js +++ b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js @@ -108,7 +108,7 @@ function sort(items, state) { return _.orderBy(items, clauses, orders); } -function createCustomFiltersSelector(type, alternateType) { +export function createCustomFiltersSelector(type, alternateType) { return createSelector( (state) => state.customFilters.items, (customFilters) => { diff --git a/frontend/src/Store/Selectors/createCommandExecutingSelector.js b/frontend/src/Store/Selectors/createCommandExecutingSelector.ts similarity index 50% rename from frontend/src/Store/Selectors/createCommandExecutingSelector.js rename to frontend/src/Store/Selectors/createCommandExecutingSelector.ts index 6037d5820..6a80e172b 100644 --- a/frontend/src/Store/Selectors/createCommandExecutingSelector.js +++ b/frontend/src/Store/Selectors/createCommandExecutingSelector.ts @@ -2,13 +2,10 @@ import { createSelector } from 'reselect'; import { isCommandExecuting } from 'Utilities/Command'; import createCommandSelector from './createCommandSelector'; -function createCommandExecutingSelector(name, contraints = {}) { - return createSelector( - createCommandSelector(name, contraints), - (command) => { - return isCommandExecuting(command); - } - ); +function createCommandExecutingSelector(name: string, contraints = {}) { + return createSelector(createCommandSelector(name, contraints), (command) => { + return isCommandExecuting(command); + }); } export default createCommandExecutingSelector; diff --git a/frontend/src/Store/Selectors/createCommandSelector.js b/frontend/src/Store/Selectors/createCommandSelector.js deleted file mode 100644 index 709dfebaf..000000000 --- a/frontend/src/Store/Selectors/createCommandSelector.js +++ /dev/null @@ -1,14 +0,0 @@ -import { createSelector } from 'reselect'; -import { findCommand } from 'Utilities/Command'; -import createCommandsSelector from './createCommandsSelector'; - -function createCommandSelector(name, contraints = {}) { - return createSelector( - createCommandsSelector(), - (commands) => { - return findCommand(commands, { name, ...contraints }); - } - ); -} - -export default createCommandSelector; diff --git a/frontend/src/Store/Selectors/createCommandSelector.ts b/frontend/src/Store/Selectors/createCommandSelector.ts new file mode 100644 index 000000000..cced7b186 --- /dev/null +++ b/frontend/src/Store/Selectors/createCommandSelector.ts @@ -0,0 +1,11 @@ +import { createSelector } from 'reselect'; +import { findCommand } from 'Utilities/Command'; +import createCommandsSelector from './createCommandsSelector'; + +function createCommandSelector(name: string, contraints = {}) { + return createSelector(createCommandsSelector(), (commands) => { + return findCommand(commands, { name, ...contraints }); + }); +} + +export default createCommandSelector; diff --git a/frontend/src/Store/Selectors/createCommandsSelector.js b/frontend/src/Store/Selectors/createCommandsSelector.ts similarity index 71% rename from frontend/src/Store/Selectors/createCommandsSelector.js rename to frontend/src/Store/Selectors/createCommandsSelector.ts index 7b9edffd9..2dd5d24a2 100644 --- a/frontend/src/Store/Selectors/createCommandsSelector.js +++ b/frontend/src/Store/Selectors/createCommandsSelector.ts @@ -1,8 +1,9 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createCommandsSelector() { return createSelector( - (state) => state.commands, + (state: AppState) => state.commands, (commands) => { return commands.items; } diff --git a/frontend/src/Store/Selectors/createDeepEqualSelector.js b/frontend/src/Store/Selectors/createDeepEqualSelector.js deleted file mode 100644 index 85562f28b..000000000 --- a/frontend/src/Store/Selectors/createDeepEqualSelector.js +++ /dev/null @@ -1,9 +0,0 @@ -import _ from 'lodash'; -import { createSelectorCreator, defaultMemoize } from 'reselect'; - -const createDeepEqualSelector = createSelectorCreator( - defaultMemoize, - _.isEqual -); - -export default createDeepEqualSelector; diff --git a/frontend/src/Store/Selectors/createDeepEqualSelector.ts b/frontend/src/Store/Selectors/createDeepEqualSelector.ts new file mode 100644 index 000000000..9d4a63d2e --- /dev/null +++ b/frontend/src/Store/Selectors/createDeepEqualSelector.ts @@ -0,0 +1,6 @@ +import { isEqual } from 'lodash'; +import { createSelectorCreator, defaultMemoize } from 'reselect'; + +const createDeepEqualSelector = createSelectorCreator(defaultMemoize, isEqual); + +export default createDeepEqualSelector; diff --git a/frontend/src/Store/Selectors/createDimensionsSelector.js b/frontend/src/Store/Selectors/createDimensionsSelector.ts similarity index 69% rename from frontend/src/Store/Selectors/createDimensionsSelector.js rename to frontend/src/Store/Selectors/createDimensionsSelector.ts index ce26b2e2c..b9602cb02 100644 --- a/frontend/src/Store/Selectors/createDimensionsSelector.js +++ b/frontend/src/Store/Selectors/createDimensionsSelector.ts @@ -1,8 +1,9 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createDimensionsSelector() { return createSelector( - (state) => state.app.dimensions, + (state: AppState) => state.app.dimensions, (dimensions) => { return dimensions; } diff --git a/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts b/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts new file mode 100644 index 000000000..3a581587b --- /dev/null +++ b/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts @@ -0,0 +1,26 @@ +import { createSelector } from 'reselect'; +import { DownloadClientAppState } from 'App/State/SettingsAppState'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import DownloadClient from 'typings/DownloadClient'; +import sortByProp from 'Utilities/Array/sortByProp'; + +export default function createEnabledDownloadClientsSelector( + protocol: DownloadProtocol +) { + return createSelector( + createSortedSectionSelector( + 'settings.downloadClients', + sortByProp('name') + ), + (downloadClients: DownloadClientAppState) => { + const { isFetching, isPopulated, error, items } = downloadClients; + + const clients = items.filter( + (item) => item.protocol === protocol && item.enable + ); + + return { isFetching, isPopulated, error, items: clients }; + } + ); +} diff --git a/frontend/src/Store/Selectors/createExecutingCommandsSelector.js b/frontend/src/Store/Selectors/createExecutingCommandsSelector.ts similarity index 78% rename from frontend/src/Store/Selectors/createExecutingCommandsSelector.js rename to frontend/src/Store/Selectors/createExecutingCommandsSelector.ts index 266865a8a..dd16571fc 100644 --- a/frontend/src/Store/Selectors/createExecutingCommandsSelector.js +++ b/frontend/src/Store/Selectors/createExecutingCommandsSelector.ts @@ -1,9 +1,10 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; import { isCommandExecuting } from 'Utilities/Command'; function createExecutingCommandsSelector() { return createSelector( - (state) => state.commands.items, + (state: AppState) => state.commands.items, (commands) => { return commands.filter((command) => isCommandExecuting(command)); } diff --git a/frontend/src/Store/Selectors/createExistingIndexerSelector.js b/frontend/src/Store/Selectors/createExistingIndexerSelector.ts similarity index 59% rename from frontend/src/Store/Selectors/createExistingIndexerSelector.js rename to frontend/src/Store/Selectors/createExistingIndexerSelector.ts index af16973b7..df98ab8d5 100644 --- a/frontend/src/Store/Selectors/createExistingIndexerSelector.js +++ b/frontend/src/Store/Selectors/createExistingIndexerSelector.ts @@ -1,13 +1,15 @@ -import _ from 'lodash'; +import { some } from 'lodash'; import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; import createAllIndexersSelector from './createAllIndexersSelector'; function createExistingIndexerSelector() { return createSelector( - (state, { definitionName }) => definitionName, + (_: AppState, { definitionName }: { definitionName: string }) => + definitionName, createAllIndexersSelector(), (definitionName, indexers) => { - return _.some(indexers, { definitionName }); + return some(indexers, { definitionName }); } ); } diff --git a/frontend/src/Store/Selectors/createIndexerAppProfileSelector.js b/frontend/src/Store/Selectors/createIndexerAppProfileSelector.js deleted file mode 100644 index 683f0419b..000000000 --- a/frontend/src/Store/Selectors/createIndexerAppProfileSelector.js +++ /dev/null @@ -1,16 +0,0 @@ -import { createSelector } from 'reselect'; -import createIndexerSelector from './createIndexerSelector'; - -function createIndexerAppProfileSelector(indexerId) { - return createSelector( - (state) => state.settings.appProfiles.items, - createIndexerSelector(indexerId), - (appProfiles, indexer = {}) => { - return appProfiles.find((profile) => { - return profile.id === indexer.appProfileId; - }); - } - ); -} - -export default createIndexerAppProfileSelector; diff --git a/frontend/src/Store/Selectors/createIndexerAppProfileSelector.ts b/frontend/src/Store/Selectors/createIndexerAppProfileSelector.ts new file mode 100644 index 000000000..ea95a9443 --- /dev/null +++ b/frontend/src/Store/Selectors/createIndexerAppProfileSelector.ts @@ -0,0 +1,16 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import Indexer from 'Indexer/Indexer'; +import { createIndexerSelectorForHook } from './createIndexerSelector'; + +function createIndexerAppProfileSelector(indexerId: number) { + return createSelector( + (state: AppState) => state.settings.appProfiles.items, + createIndexerSelectorForHook(indexerId), + (appProfiles, indexer = {} as Indexer) => { + return appProfiles.find((profile) => profile.id === indexer.appProfileId); + } + ); +} + +export default createIndexerAppProfileSelector; diff --git a/frontend/src/Store/Selectors/createIndexerSelector.js b/frontend/src/Store/Selectors/createIndexerSelector.js deleted file mode 100644 index 220f9b15e..000000000 --- a/frontend/src/Store/Selectors/createIndexerSelector.js +++ /dev/null @@ -1,24 +0,0 @@ -import { createSelector } from 'reselect'; - -function createIndexerSelector(id) { - if (id == null) { - return createSelector( - (state, { indexerId }) => indexerId, - (state) => state.indexers.itemMap, - (state) => state.indexers.items, - (indexerId, itemMap, allIndexers) => { - return allIndexers[itemMap[indexerId]]; - } - ); - } - - return createSelector( - (state) => state.indexers.itemMap, - (state) => state.indexers.items, - (itemMap, allIndexers) => { - return allIndexers[itemMap[id]]; - } - ); -} - -export default createIndexerSelector; diff --git a/frontend/src/Store/Selectors/createIndexerSelector.ts b/frontend/src/Store/Selectors/createIndexerSelector.ts new file mode 100644 index 000000000..7227d18a6 --- /dev/null +++ b/frontend/src/Store/Selectors/createIndexerSelector.ts @@ -0,0 +1,25 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +export function createIndexerSelectorForHook(indexerId: number) { + return createSelector( + (state: AppState) => state.indexers.itemMap, + (state: AppState) => state.indexers.items, + (itemMap, allIndexers) => { + return indexerId ? allIndexers[itemMap[indexerId]] : undefined; + } + ); +} + +function createIndexerSelector() { + return createSelector( + (_: AppState, { indexerId }: { indexerId: number }) => indexerId, + (state: AppState) => state.indexers.itemMap, + (state: AppState) => state.indexers.items, + (indexerId, itemMap, allIndexers) => { + return allIndexers[itemMap[indexerId]]; + } + ); +} + +export default createIndexerSelector; diff --git a/frontend/src/Store/Selectors/createIndexerStatusSelector.js b/frontend/src/Store/Selectors/createIndexerStatusSelector.js deleted file mode 100644 index 1912ea1a0..000000000 --- a/frontend/src/Store/Selectors/createIndexerStatusSelector.js +++ /dev/null @@ -1,13 +0,0 @@ -import _ from 'lodash'; -import { createSelector } from 'reselect'; - -function createIndexerStatusSelector(indexerId) { - return createSelector( - (state) => state.indexerStatus.items, - (indexerStatus) => { - return _.find(indexerStatus, { indexerId }); - } - ); -} - -export default createIndexerStatusSelector; diff --git a/frontend/src/Store/Selectors/createIndexerStatusSelector.ts b/frontend/src/Store/Selectors/createIndexerStatusSelector.ts new file mode 100644 index 000000000..035dfc3c4 --- /dev/null +++ b/frontend/src/Store/Selectors/createIndexerStatusSelector.ts @@ -0,0 +1,15 @@ +import { find } from 'lodash'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import { IndexerStatus } from 'Indexer/Indexer'; + +function createIndexerStatusSelector(indexerId: number) { + return createSelector( + (state: AppState) => state.indexerStatus.items, + (indexerStatus) => { + return find(indexerStatus, { indexerId }) as IndexerStatus | undefined; + } + ); +} + +export default createIndexerStatusSelector; diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.js b/frontend/src/Store/Selectors/createProfileInUseSelector.js deleted file mode 100644 index 807bf4673..000000000 --- a/frontend/src/Store/Selectors/createProfileInUseSelector.js +++ /dev/null @@ -1,24 +0,0 @@ -import _ from 'lodash'; -import { createSelector } from 'reselect'; -import createAllIndexersSelector from './createAllIndexersSelector'; - -function createProfileInUseSelector(profileProp) { - return createSelector( - (state, { id }) => id, - (state) => state.settings.appProfiles.items, - createAllIndexersSelector(), - (id, profiles, indexers) => { - if (!id) { - return false; - } - - if (_.some(indexers, { [profileProp]: id }) || profiles.length <= 1) { - return true; - } - - return false; - } - ); -} - -export default createProfileInUseSelector; diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.ts b/frontend/src/Store/Selectors/createProfileInUseSelector.ts new file mode 100644 index 000000000..8137db693 --- /dev/null +++ b/frontend/src/Store/Selectors/createProfileInUseSelector.ts @@ -0,0 +1,21 @@ +import { some } from 'lodash'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import createAllIndexersSelector from './createAllIndexersSelector'; + +function createProfileInUseSelector(profileProp: string) { + return createSelector( + (_: AppState, { id }: { id: number }) => id, + (state: AppState) => state.settings.appProfiles.items, + createAllIndexersSelector(), + (id, profiles, indexers) => { + if (!id) { + return false; + } + + return some(indexers, { [profileProp]: id }) || profiles.length <= 1; + } + ); +} + +export default createProfileInUseSelector; diff --git a/frontend/src/Store/Selectors/createSortedSectionSelector.js b/frontend/src/Store/Selectors/createSortedSectionSelector.ts similarity index 68% rename from frontend/src/Store/Selectors/createSortedSectionSelector.js rename to frontend/src/Store/Selectors/createSortedSectionSelector.ts index 331d890c9..abee01f75 100644 --- a/frontend/src/Store/Selectors/createSortedSectionSelector.js +++ b/frontend/src/Store/Selectors/createSortedSectionSelector.ts @@ -1,14 +1,18 @@ import { createSelector } from 'reselect'; import getSectionState from 'Utilities/State/getSectionState'; -function createSortedSectionSelector(section, comparer) { +function createSortedSectionSelector( + section: string, + comparer: (a: T, b: T) => number +) { return createSelector( (state) => state, (state) => { const sectionState = getSectionState(state, section, true); + return { ...sectionState, - items: [...sectionState.items].sort(comparer) + items: [...sectionState.items].sort(comparer), }; } ); diff --git a/frontend/src/Store/Selectors/createSystemStatusSelector.js b/frontend/src/Store/Selectors/createSystemStatusSelector.ts similarity index 70% rename from frontend/src/Store/Selectors/createSystemStatusSelector.js rename to frontend/src/Store/Selectors/createSystemStatusSelector.ts index df586bbb9..f5e276069 100644 --- a/frontend/src/Store/Selectors/createSystemStatusSelector.js +++ b/frontend/src/Store/Selectors/createSystemStatusSelector.ts @@ -1,8 +1,9 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createSystemStatusSelector() { return createSelector( - (state) => state.system.status, + (state: AppState) => state.system.status, (status) => { return status.item; } diff --git a/frontend/src/Store/Selectors/createTagDetailsSelector.js b/frontend/src/Store/Selectors/createTagDetailsSelector.ts similarity index 62% rename from frontend/src/Store/Selectors/createTagDetailsSelector.js rename to frontend/src/Store/Selectors/createTagDetailsSelector.ts index dd178944c..2a271cafe 100644 --- a/frontend/src/Store/Selectors/createTagDetailsSelector.js +++ b/frontend/src/Store/Selectors/createTagDetailsSelector.ts @@ -1,9 +1,10 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createTagDetailsSelector() { return createSelector( - (state, { id }) => id, - (state) => state.tags.details.items, + (_: AppState, { id }: { id: number }) => id, + (state: AppState) => state.tags.details.items, (id, tagDetails) => { return tagDetails.find((t) => t.id === id); } diff --git a/frontend/src/Store/Selectors/createTagsSelector.js b/frontend/src/Store/Selectors/createTagsSelector.ts similarity index 68% rename from frontend/src/Store/Selectors/createTagsSelector.js rename to frontend/src/Store/Selectors/createTagsSelector.ts index fbfd91cdb..f653ff6e3 100644 --- a/frontend/src/Store/Selectors/createTagsSelector.js +++ b/frontend/src/Store/Selectors/createTagsSelector.ts @@ -1,8 +1,9 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createTagsSelector() { return createSelector( - (state) => state.tags.items, + (state: AppState) => state.tags.items, (tags) => { return tags; } diff --git a/frontend/src/Store/Selectors/createUISettingsSelector.js b/frontend/src/Store/Selectors/createUISettingsSelector.ts similarity index 69% rename from frontend/src/Store/Selectors/createUISettingsSelector.js rename to frontend/src/Store/Selectors/createUISettingsSelector.ts index b256d0e98..ff539679b 100644 --- a/frontend/src/Store/Selectors/createUISettingsSelector.js +++ b/frontend/src/Store/Selectors/createUISettingsSelector.ts @@ -1,8 +1,9 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createUISettingsSelector() { return createSelector( - (state) => state.settings.ui, + (state: AppState) => state.settings.ui, (ui) => { return ui.item; } diff --git a/frontend/src/Store/scrollPositions.js b/frontend/src/Store/scrollPositions.js deleted file mode 100644 index 6aeed381f..000000000 --- a/frontend/src/Store/scrollPositions.js +++ /dev/null @@ -1,5 +0,0 @@ -const scrollPositions = { - indexerIndex: 0 -}; - -export default scrollPositions; diff --git a/frontend/src/Store/scrollPositions.ts b/frontend/src/Store/scrollPositions.ts new file mode 100644 index 000000000..48fc68535 --- /dev/null +++ b/frontend/src/Store/scrollPositions.ts @@ -0,0 +1,5 @@ +const scrollPositions: Record = { + indexerIndex: 0, +}; + +export default scrollPositions; diff --git a/frontend/src/Store/thunks.js b/frontend/src/Store/thunks.js deleted file mode 100644 index ebcf10917..000000000 --- a/frontend/src/Store/thunks.js +++ /dev/null @@ -1,28 +0,0 @@ -const thunks = {}; - -function identity(payload) { - return payload; -} - -export function createThunk(type, identityFunction = identity) { - return function(payload = {}) { - return function(dispatch, getState) { - const thunk = thunks[type]; - - if (thunk) { - return thunk(getState, identityFunction(payload), dispatch); - } - - throw Error(`Thunk handler has not been registered for ${type}`); - }; - }; -} - -export function handleThunks(handlers) { - const types = Object.keys(handlers); - - types.forEach((type) => { - thunks[type] = handlers[type]; - }); -} - diff --git a/frontend/src/Store/thunks.ts b/frontend/src/Store/thunks.ts new file mode 100644 index 000000000..fd277211e --- /dev/null +++ b/frontend/src/Store/thunks.ts @@ -0,0 +1,39 @@ +import { Dispatch } from 'redux'; +import AppState from 'App/State/AppState'; + +type GetState = () => AppState; +type Thunk = ( + getState: GetState, + identityFn: never, + dispatch: Dispatch +) => unknown; + +const thunks: Record = {}; + +function identity(payload: T): TResult { + return payload as unknown as TResult; +} + +export function createThunk(type: string, identityFunction = identity) { + return function (payload?: T) { + return function (dispatch: Dispatch, getState: GetState) { + const thunk = thunks[type]; + + if (thunk) { + const finalPayload = payload ?? {}; + + return thunk(getState, identityFunction(finalPayload), dispatch); + } + + throw Error(`Thunk handler has not been registered for ${type}`); + }; + }; +} + +export function handleThunks(handlers: Record) { + const types = Object.keys(handlers); + + types.forEach((type) => { + thunks[type] = handlers[type]; + }); +} diff --git a/frontend/src/Styles/Themes/dark.js b/frontend/src/Styles/Themes/dark.js index 657568982..a7cbb6de0 100644 --- a/frontend/src/Styles/Themes/dark.js +++ b/frontend/src/Styles/Themes/dark.js @@ -37,8 +37,8 @@ module.exports = { // Links defaultLinkHoverColor: '#fff', - linkColor: '#rgb(230, 96, 0)', - linkHoverColor: '#rgb(230, 96, 0, .8)', + linkColor: '#5d9cec', + linkHoverColor: '#5d9cec', // Header pageHeaderBackgroundColor: '#2a2a2a', @@ -187,7 +187,8 @@ module.exports = { // // Charts - failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'], - chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'], - chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26'] + chartBackgroundColor: '#262626', + failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'].join(','), + chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'].join(','), + chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26'].join(',') }; diff --git a/frontend/src/Styles/Themes/index.js b/frontend/src/Styles/Themes/index.js index d93c5dd8c..4dec39164 100644 --- a/frontend/src/Styles/Themes/index.js +++ b/frontend/src/Styles/Themes/index.js @@ -2,7 +2,7 @@ import * as dark from './dark'; import * as light from './light'; const defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches; -const auto = defaultDark ? { ...dark } : { ...light }; +const auto = defaultDark ? dark : light; export default { auto, diff --git a/frontend/src/Styles/Themes/light.js b/frontend/src/Styles/Themes/light.js index 5ff84460c..f88070a0f 100644 --- a/frontend/src/Styles/Themes/light.js +++ b/frontend/src/Styles/Themes/light.js @@ -187,7 +187,8 @@ module.exports = { // // Charts - failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'], - chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'], - chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26'] + chartBackgroundColor: '#fff', + failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'].join(','), + chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'].join(','), + chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26'].join(',') }; diff --git a/frontend/src/Styles/Variables/fonts.js b/frontend/src/Styles/Variables/fonts.js index 3b0077c5a..def48f28e 100644 --- a/frontend/src/Styles/Variables/fonts.js +++ b/frontend/src/Styles/Variables/fonts.js @@ -2,7 +2,6 @@ module.exports = { // Families defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif', monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;', - passwordFamily: 'text-security-disc', // Sizes extraSmallFontSize: '11px', diff --git a/frontend/src/Styles/Variables/zIndexes.js b/frontend/src/Styles/Variables/zIndexes.js index 986ceb548..4d10253a7 100644 --- a/frontend/src/Styles/Variables/zIndexes.js +++ b/frontend/src/Styles/Variables/zIndexes.js @@ -1,4 +1,5 @@ module.exports = { + pageJumpBarZIndex: 10, modalZIndex: 1000, popperZIndex: 2000 }; diff --git a/frontend/src/System/Backup/BackupRow.js b/frontend/src/System/Backup/BackupRow.js index 089f6bcb9..39f7f1123 100644 --- a/frontend/src/System/Backup/BackupRow.js +++ b/frontend/src/System/Backup/BackupRow.js @@ -4,7 +4,7 @@ import Icon from 'Components/Icon'; import IconButton from 'Components/Link/IconButton'; import Link from 'Components/Link/Link'; import ConfirmModal from 'Components/Modal/ConfirmModal'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRow from 'Components/Table/TableRow'; import { icons, kinds } from 'Helpers/Props'; @@ -110,12 +110,13 @@ class BackupRow extends Component { {formatBytes(size)} - @@ -138,7 +139,9 @@ class BackupRow extends Component { isOpen={isConfirmDeleteModalOpen} kind={kinds.DANGER} title={translate('DeleteBackup')} - message={translate('DeleteBackupMessageText', [name])} + message={translate('DeleteBackupMessageText', { + name + })} confirmLabel={translate('Delete')} onConfirm={this.onConfirmDeletePress} onCancel={this.onConfirmDeleteModalClose} diff --git a/frontend/src/System/Backup/Backups.js b/frontend/src/System/Backup/Backups.js index 4f9cd0483..ede2f97f6 100644 --- a/frontend/src/System/Backup/Backups.js +++ b/frontend/src/System/Backup/Backups.js @@ -21,17 +21,17 @@ const columns = [ }, { name: 'name', - label: translate('Name'), + label: () => translate('Name'), isVisible: true }, { name: 'size', - label: translate('Size'), + label: () => translate('Size'), isVisible: true }, { name: 'time', - label: translate('Time'), + label: () => translate('Time'), isVisible: true }, { @@ -109,7 +109,7 @@ class Backups extends Component { { !isFetching && !!error && - {translate('UnableToLoadBackups')} + {translate('BackupsLoadError')} } diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.js b/frontend/src/System/Backup/RestoreBackupModalContent.js index 150c46ad6..9b5daa9f4 100644 --- a/frontend/src/System/Backup/RestoreBackupModalContent.js +++ b/frontend/src/System/Backup/RestoreBackupModalContent.js @@ -14,7 +14,7 @@ import styles from './RestoreBackupModalContent.css'; function getErrorMessage(error) { if (!error || !error.responseJSON || !error.responseJSON.message) { - return 'Error restoring backup'; + return translate('ErrorRestoringBackup'); } return error.responseJSON.message; @@ -146,7 +146,9 @@ class RestoreBackupModalContent extends Component { { - !!id && `Would you like to restore the backup '${name}'?` + !!id && translate('WouldYouLikeToRestoreBackup', { + name + }) } { @@ -203,7 +205,7 @@ class RestoreBackupModalContent extends Component {
- Note: Prowlarr will automatically restart and reload the UI during the restore process. + {translate('RestartReloadNote')}
- } - > - { - isFetching && !isPopulated && - - } - - { - !healthIssues && -
- {translate('HealthNoIssues')} -
- } - - { - healthIssues && - - - { - items.map((item) => { - const internalLink = getInternalLink(item.source); - const testLink = getTestLink(item.source, this.props); - - let kind = kinds.WARNING; - switch (item.type.toLowerCase()) { - case 'error': - kind = kinds.DANGER; - break; - default: - case 'warning': - kind = kinds.WARNING; - break; - case 'notice': - kind = kinds.INFO; - break; - } - - return ( - - - - - - {item.message} - - - - - { - internalLink - } - - { - !!testLink && - testLink - } - - - ); - }) - } - -
- } - - ); - } - -} - -Health.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - items: PropTypes.array.isRequired, - isTestingAllIndexers: PropTypes.bool.isRequired, - dispatchTestAllIndexers: PropTypes.func.isRequired -}; - -export default Health; diff --git a/frontend/src/System/Status/Health/Health.tsx b/frontend/src/System/Status/Health/Health.tsx new file mode 100644 index 000000000..e0636961b --- /dev/null +++ b/frontend/src/System/Status/Health/Health.tsx @@ -0,0 +1,191 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import Alert from 'Components/Alert'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Column from 'Components/Table/Column'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableRow from 'Components/Table/TableRow'; +import { icons, kinds } from 'Helpers/Props'; +import { testAllIndexers } from 'Store/Actions/indexerActions'; +import { + testAllApplications, + testAllDownloadClients, +} from 'Store/Actions/settingsActions'; +import { fetchHealth } from 'Store/Actions/systemActions'; +import titleCase from 'Utilities/String/titleCase'; +import translate from 'Utilities/String/translate'; +import createHealthSelector from './createHealthSelector'; +import HealthItemLink from './HealthItemLink'; +import styles from './Health.css'; + +const columns: Column[] = [ + { + className: styles.status, + name: 'type', + label: '', + isVisible: true, + }, + { + name: 'message', + label: () => translate('Message'), + isVisible: true, + }, + { + name: 'actions', + label: () => translate('Actions'), + isVisible: true, + }, +]; + +function Health() { + const dispatch = useDispatch(); + const { isFetching, isPopulated, items } = useSelector( + createHealthSelector() + ); + const isTestingAllApplications = useSelector( + (state: AppState) => state.settings.applications.isTestingAll + ); + const isTestingAllDownloadClients = useSelector( + (state: AppState) => state.settings.downloadClients.isTestingAll + ); + const isTestingAllIndexers = useSelector( + (state: AppState) => state.indexers.isTestingAll + ); + + const healthIssues = !!items.length; + + const handleTestAllApplicationsPress = useCallback(() => { + dispatch(testAllApplications()); + }, [dispatch]); + + const handleTestAllDownloadClientsPress = useCallback(() => { + dispatch(testAllDownloadClients()); + }, [dispatch]); + + const handleTestAllIndexersPress = useCallback(() => { + dispatch(testAllIndexers()); + }, [dispatch]); + + useEffect(() => { + dispatch(fetchHealth()); + }, [dispatch]); + + return ( +
+ {translate('Health')} + + {isFetching && isPopulated ? ( + + ) : null} + + } + > + {isFetching && !isPopulated ? : null} + + {isPopulated && !healthIssues ? ( +
+ {translate('NoIssuesWithYourConfiguration')} +
+ ) : null} + + {healthIssues ? ( + <> + + + {items.map((item) => { + const source = item.source; + + let kind = kinds.WARNING; + switch (item.type.toLowerCase()) { + case 'error': + kind = kinds.DANGER; + break; + default: + case 'warning': + kind = kinds.WARNING; + break; + case 'notice': + kind = kinds.INFO; + break; + } + + return ( + + + + + + {item.message} + + + + + + + {source === 'ApplicationStatusCheck' || + source === 'ApplicationLongTermStatusCheck' ? ( + + ) : null} + + {source === 'IndexerStatusCheck' || + source === 'IndexerLongTermStatusCheck' ? ( + + ) : null} + + {source === 'DownloadClientStatusCheck' ? ( + + ) : null} + + + ); + })} + +
+ + + + + + ) : null} +
+ ); +} + +export default Health; diff --git a/frontend/src/System/Status/Health/HealthConnector.js b/frontend/src/System/Status/Health/HealthConnector.js deleted file mode 100644 index 885faa424..000000000 --- a/frontend/src/System/Status/Health/HealthConnector.js +++ /dev/null @@ -1,66 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { testAllIndexers } from 'Store/Actions/indexerActions'; -import { fetchHealth } from 'Store/Actions/systemActions'; -import createHealthCheckSelector from 'Store/Selectors/createHealthCheckSelector'; -import Health from './Health'; - -function createMapStateToProps() { - return createSelector( - createHealthCheckSelector(), - (state) => state.system.health, - (state) => state.indexers.isTestingAll, - (items, health, isTestingAllIndexers) => { - const { - isFetching, - isPopulated - } = health; - - return { - isFetching, - isPopulated, - items, - isTestingAllIndexers - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchHealth: fetchHealth, - dispatchTestAllIndexers: testAllIndexers -}; - -class HealthConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.dispatchFetchHealth(); - } - - // - // Render - - render() { - const { - dispatchFetchHealth, - ...otherProps - } = this.props; - - return ( - - ); - } -} - -HealthConnector.propTypes = { - dispatchFetchHealth: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(HealthConnector); diff --git a/frontend/src/System/Status/Health/HealthItemLink.tsx b/frontend/src/System/Status/Health/HealthItemLink.tsx new file mode 100644 index 000000000..b7a90c783 --- /dev/null +++ b/frontend/src/System/Status/Health/HealthItemLink.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import IconButton from 'Components/Link/IconButton'; +import { icons } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; + +interface HealthItemLinkProps { + source: string; +} + +function HealthItemLink(props: HealthItemLinkProps) { + const { source } = props; + + switch (source) { + case 'ApplicationStatusCheck': + case 'ApplicationLongTermStatusCheck': + return ( + + ); + case 'DownloadClientStatusCheck': + return ( + + ); + case 'NotificationStatusCheck': + return ( + + ); + case 'IndexerProxyStatusCheck': + return ( + + ); + case 'IndexerRssCheck': + case 'IndexerSearchCheck': + case 'IndexerStatusCheck': + case 'IndexerLongTermStatusCheck': + return ( + + ); + case 'UpdateCheck': + return ( + + ); + default: + return null; + } +} + +export default HealthItemLink; diff --git a/frontend/src/System/Status/Health/HealthStatus.tsx b/frontend/src/System/Status/Health/HealthStatus.tsx new file mode 100644 index 000000000..b12fd3ebb --- /dev/null +++ b/frontend/src/System/Status/Health/HealthStatus.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { fetchHealth } from 'Store/Actions/systemActions'; +import createHealthSelector from './createHealthSelector'; + +function HealthStatus() { + const dispatch = useDispatch(); + const { isConnected, isReconnecting } = useSelector( + (state: AppState) => state.app + ); + const { isPopulated, items } = useSelector(createHealthSelector()); + + const wasReconnecting = usePrevious(isReconnecting); + + const { count, errors, warnings } = useMemo(() => { + let errors = false; + let warnings = false; + + items.forEach((item) => { + if (item.type === 'error') { + errors = true; + } + + if (item.type === 'warning') { + warnings = true; + } + }); + + return { + count: items.length, + errors, + warnings, + }; + }, [items]); + + useEffect(() => { + if (!isPopulated) { + dispatch(fetchHealth()); + } + }, [isPopulated, dispatch]); + + useEffect(() => { + if (isConnected && wasReconnecting) { + dispatch(fetchHealth()); + } + }, [isConnected, wasReconnecting, dispatch]); + + return ( + + ); +} + +export default HealthStatus; diff --git a/frontend/src/System/Status/Health/HealthStatusConnector.js b/frontend/src/System/Status/Health/HealthStatusConnector.js deleted file mode 100644 index e609dd712..000000000 --- a/frontend/src/System/Status/Health/HealthStatusConnector.js +++ /dev/null @@ -1,81 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; -import { fetchHealth } from 'Store/Actions/systemActions'; -import createHealthCheckSelector from 'Store/Selectors/createHealthCheckSelector'; - -function createMapStateToProps() { - return createSelector( - (state) => state.app, - createHealthCheckSelector(), - (state) => state.system.health, - (app, items, health) => { - const count = items.length; - let errors = false; - let warnings = false; - - items.forEach((item) => { - if (item.type === 'error') { - errors = true; - } - - if (item.type === 'warning') { - warnings = true; - } - }); - - return { - isConnected: app.isConnected, - isReconnecting: app.isReconnecting, - isPopulated: health.isPopulated, - count, - errors, - warnings - }; - } - ); -} - -const mapDispatchToProps = { - fetchHealth -}; - -class HealthStatusConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - if (!this.props.isPopulated) { - this.props.fetchHealth(); - } - } - - componentDidUpdate(prevProps) { - if (this.props.isConnected && prevProps.isReconnecting) { - this.props.fetchHealth(); - } - } - - // - // Render - - render() { - return ( - - ); - } -} - -HealthStatusConnector.propTypes = { - isConnected: PropTypes.bool.isRequired, - isReconnecting: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - fetchHealth: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(HealthStatusConnector); diff --git a/frontend/src/System/Status/Health/createHealthSelector.ts b/frontend/src/System/Status/Health/createHealthSelector.ts new file mode 100644 index 000000000..f38e3fe88 --- /dev/null +++ b/frontend/src/System/Status/Health/createHealthSelector.ts @@ -0,0 +1,13 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +function createHealthSelector() { + return createSelector( + (state: AppState) => state.system.health, + (health) => { + return health; + } + ); +} + +export default createHealthSelector; diff --git a/frontend/src/System/Status/MoreInfo/MoreInfo.js b/frontend/src/System/Status/MoreInfo/MoreInfo.js deleted file mode 100644 index dfb23a996..000000000 --- a/frontend/src/System/Status/MoreInfo/MoreInfo.js +++ /dev/null @@ -1,58 +0,0 @@ -import React, { Component } from 'react'; -import DescriptionList from 'Components/DescriptionList/DescriptionList'; -import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; -import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; -import FieldSet from 'Components/FieldSet'; -import Link from 'Components/Link/Link'; -import translate from 'Utilities/String/translate'; - -class MoreInfo extends Component { - - // - // Render - - render() { - return ( -
- - {translate('HomePage')} - - prowlarr.com - - - {translate('Wiki')} - - wiki.servarr.com/prowlarr - - - {translate('Reddit')} - - r/prowlarr - - - {translate('Discord')} - - prowlarr.com/discord - - - {translate('Source')} - - github.com/Prowlarr/Prowlarr - - - {translate('FeatureRequests')} - - github.com/Prowlarr/Prowlarr/issues - - - -
- ); - } -} - -MoreInfo.propTypes = { - -}; - -export default MoreInfo; diff --git a/frontend/src/System/Status/MoreInfo/MoreInfo.tsx b/frontend/src/System/Status/MoreInfo/MoreInfo.tsx new file mode 100644 index 000000000..928449aed --- /dev/null +++ b/frontend/src/System/Status/MoreInfo/MoreInfo.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; +import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; +import FieldSet from 'Components/FieldSet'; +import Link from 'Components/Link/Link'; +import translate from 'Utilities/String/translate'; + +function MoreInfo() { + return ( +
+ + + {translate('HomePage')} + + + prowlarr.com + + + {translate('Wiki')} + + + wiki.servarr.com/prowlarr + + + + + {translate('Reddit')} + + + r/prowlarr + + + + {translate('Discord')} + + + prowlarr.com/discord + + + + {translate('Source')} + + + + github.com/Prowlarr/Prowlarr + + + + + {translate('FeatureRequests')} + + + + github.com/Prowlarr/Prowlarr/issues + + + +
+ ); +} + +export default MoreInfo; diff --git a/frontend/src/System/Status/Status.js b/frontend/src/System/Status/Status.js deleted file mode 100644 index 46e2d0951..000000000 --- a/frontend/src/System/Status/Status.js +++ /dev/null @@ -1,30 +0,0 @@ -import React, { Component } from 'react'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import translate from 'Utilities/String/translate'; -import AboutConnector from './About/AboutConnector'; -import Donations from './Donations/Donations'; -import HealthConnector from './Health/HealthConnector'; -import MoreInfo from './MoreInfo/MoreInfo'; - -class Status extends Component { - - // - // Render - - render() { - return ( - - - - - - - - - ); - } - -} - -export default Status; diff --git a/frontend/src/System/Status/Status.tsx b/frontend/src/System/Status/Status.tsx new file mode 100644 index 000000000..6ae088160 --- /dev/null +++ b/frontend/src/System/Status/Status.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import translate from 'Utilities/String/translate'; +import About from './About/About'; +import Donations from './Donations/Donations'; +import Health from './Health/Health'; +import MoreInfo from './MoreInfo/MoreInfo'; + +function Status() { + return ( + + + + + + + + + ); +} + +export default Status; diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.css b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css index 034804711..6e38929c9 100644 --- a/frontend/src/System/Tasks/Queued/QueuedTaskRow.css +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css @@ -10,15 +10,6 @@ width: 100%; } -.commandName { - display: inline-block; - min-width: 220px; -} - -.userAgent { - color: #b0b0b0; -} - .queued, .started, .ended { diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.css.d.ts b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css.d.ts index 3bc00b738..2c6010533 100644 --- a/frontend/src/System/Tasks/Queued/QueuedTaskRow.css.d.ts +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css.d.ts @@ -2,14 +2,12 @@ // Please do not change this file! interface CssExports { 'actions': string; - 'commandName': string; 'duration': string; 'ended': string; 'queued': string; 'started': string; 'trigger': string; 'triggerContent': string; - 'userAgent': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.js b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js deleted file mode 100644 index 917bfa11a..000000000 --- a/frontend/src/System/Tasks/Queued/QueuedTaskRow.js +++ /dev/null @@ -1,279 +0,0 @@ -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import IconButton from 'Components/Link/IconButton'; -import ConfirmModal from 'Components/Modal/ConfirmModal'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableRow from 'Components/Table/TableRow'; -import { icons, kinds } from 'Helpers/Props'; -import formatDate from 'Utilities/Date/formatDate'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; -import titleCase from 'Utilities/String/titleCase'; -import translate from 'Utilities/String/translate'; -import styles from './QueuedTaskRow.css'; - -function getStatusIconProps(status, message) { - const title = titleCase(status); - - switch (status) { - case 'queued': - return { - name: icons.PENDING, - title - }; - - case 'started': - return { - name: icons.REFRESH, - isSpinning: true, - title - }; - - case 'completed': - return { - name: icons.CHECK, - kind: kinds.SUCCESS, - title: message === 'Completed' ? title : `${title}: ${message}` - }; - - case 'failed': - return { - name: icons.FATAL, - kind: kinds.DANGER, - title: `${title}: ${message}` - }; - - default: - return { - name: icons.UNKNOWN, - title - }; - } -} - -function getFormattedDates(props) { - const { - queued, - started, - ended, - showRelativeDates, - shortDateFormat - } = props; - - if (showRelativeDates) { - return { - queuedAt: moment(queued).fromNow(), - startedAt: started ? moment(started).fromNow() : '-', - endedAt: ended ? moment(ended).fromNow() : '-' - }; - } - - return { - queuedAt: formatDate(queued, shortDateFormat), - startedAt: started ? formatDate(started, shortDateFormat) : '-', - endedAt: ended ? formatDate(ended, shortDateFormat) : '-' - }; -} - -class QueuedTaskRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - ...getFormattedDates(props), - isCancelConfirmModalOpen: false - }; - - this._updateTimeoutId = null; - } - - componentDidMount() { - this.setUpdateTimer(); - } - - componentDidUpdate(prevProps) { - const { - queued, - started, - ended - } = this.props; - - if ( - queued !== prevProps.queued || - started !== prevProps.started || - ended !== prevProps.ended - ) { - this.setState(getFormattedDates(this.props)); - } - } - - componentWillUnmount() { - if (this._updateTimeoutId) { - this._updateTimeoutId = clearTimeout(this._updateTimeoutId); - } - } - - // - // Control - - setUpdateTimer() { - this._updateTimeoutId = setTimeout(() => { - this.setState(getFormattedDates(this.props)); - this.setUpdateTimer(); - }, 30000); - } - - // - // Listeners - - onCancelPress = () => { - this.setState({ - isCancelConfirmModalOpen: true - }); - }; - - onAbortCancel = () => { - this.setState({ - isCancelConfirmModalOpen: false - }); - }; - - // - // Render - - render() { - const { - trigger, - commandName, - queued, - started, - ended, - status, - duration, - message, - clientUserAgent, - longDateFormat, - timeFormat, - onCancelPress - } = this.props; - - const { - queuedAt, - startedAt, - endedAt, - isCancelConfirmModalOpen - } = this.state; - - let triggerIcon = icons.QUICK; - - if (trigger === 'manual') { - triggerIcon = icons.INTERACTIVE; - } else if (trigger === 'scheduled') { - triggerIcon = icons.SCHEDULED; - } - - return ( - - - - - - - - - - - - {commandName} - - { - clientUserAgent ? - - from: {clientUserAgent} - : - null - } - - - - {queuedAt} - - - - {startedAt} - - - - {endedAt} - - - - {formatTimeSpan(duration)} - - - - { - status === 'queued' && - - } - - - - - ); - } -} - -QueuedTaskRow.propTypes = { - trigger: PropTypes.string.isRequired, - commandName: PropTypes.string.isRequired, - queued: PropTypes.string.isRequired, - started: PropTypes.string, - ended: PropTypes.string, - status: PropTypes.string.isRequired, - duration: PropTypes.string, - message: PropTypes.string, - clientUserAgent: PropTypes.string, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onCancelPress: PropTypes.func.isRequired -}; - -export default QueuedTaskRow; diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx b/frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx new file mode 100644 index 000000000..4511bcbf4 --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx @@ -0,0 +1,238 @@ +import moment from 'moment'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { CommandBody } from 'Commands/Command'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRow from 'Components/Table/TableRow'; +import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; +import { icons, kinds } from 'Helpers/Props'; +import { cancelCommand } from 'Store/Actions/commandActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import titleCase from 'Utilities/String/titleCase'; +import translate from 'Utilities/String/translate'; +import QueuedTaskRowNameCell from './QueuedTaskRowNameCell'; +import styles from './QueuedTaskRow.css'; + +function getStatusIconProps(status: string, message: string | undefined) { + const title = titleCase(status); + + switch (status) { + case 'queued': + return { + name: icons.PENDING, + title, + }; + + case 'started': + return { + name: icons.REFRESH, + isSpinning: true, + title, + }; + + case 'completed': + return { + name: icons.CHECK, + kind: kinds.SUCCESS, + title: message === 'Completed' ? title : `${title}: ${message}`, + }; + + case 'failed': + return { + name: icons.FATAL, + kind: kinds.DANGER, + title: `${title}: ${message}`, + }; + + default: + return { + name: icons.UNKNOWN, + title, + }; + } +} + +function getFormattedDates( + queued: string, + started: string | undefined, + ended: string | undefined, + showRelativeDates: boolean, + shortDateFormat: string +) { + if (showRelativeDates) { + return { + queuedAt: moment(queued).fromNow(), + startedAt: started ? moment(started).fromNow() : '-', + endedAt: ended ? moment(ended).fromNow() : '-', + }; + } + + return { + queuedAt: formatDate(queued, shortDateFormat), + startedAt: started ? formatDate(started, shortDateFormat) : '-', + endedAt: ended ? formatDate(ended, shortDateFormat) : '-', + }; +} + +interface QueuedTimes { + queuedAt: string; + startedAt: string; + endedAt: string; +} + +export interface QueuedTaskRowProps { + id: number; + trigger: string; + commandName: string; + queued: string; + started?: string; + ended?: string; + status: string; + duration?: string; + message?: string; + body: CommandBody; + clientUserAgent?: string; +} + +export default function QueuedTaskRow(props: QueuedTaskRowProps) { + const { + id, + trigger, + commandName, + queued, + started, + ended, + status, + duration, + message, + body, + clientUserAgent, + } = props; + + const dispatch = useDispatch(); + const { longDateFormat, shortDateFormat, showRelativeDates, timeFormat } = + useSelector(createUISettingsSelector()); + + const updateTimeTimeoutId = useRef | null>( + null + ); + const [times, setTimes] = useState( + getFormattedDates( + queued, + started, + ended, + showRelativeDates, + shortDateFormat + ) + ); + + const [ + isCancelConfirmModalOpen, + openCancelConfirmModal, + closeCancelConfirmModal, + ] = useModalOpenState(false); + + const handleCancelPress = useCallback(() => { + dispatch(cancelCommand({ id })); + }, [id, dispatch]); + + useEffect(() => { + updateTimeTimeoutId.current = setTimeout(() => { + setTimes( + getFormattedDates( + queued, + started, + ended, + showRelativeDates, + shortDateFormat + ) + ); + }, 30000); + + return () => { + if (updateTimeTimeoutId.current) { + clearTimeout(updateTimeTimeoutId.current); + } + }; + }, [queued, started, ended, showRelativeDates, shortDateFormat, setTimes]); + + const { queuedAt, startedAt, endedAt } = times; + + let triggerIcon = icons.QUICK; + + if (trigger === 'manual') { + triggerIcon = icons.INTERACTIVE; + } else if (trigger === 'scheduled') { + triggerIcon = icons.SCHEDULED; + } + + return ( + + + + + + + + + + + + + {queuedAt} + + + + {startedAt} + + + + {endedAt} + + + + {formatTimeSpan(duration)} + + + + {status === 'queued' && ( + + )} + + + + + ); +} diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js b/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js deleted file mode 100644 index f55ab985a..000000000 --- a/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js +++ /dev/null @@ -1,31 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { cancelCommand } from 'Store/Actions/commandActions'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import QueuedTaskRow from './QueuedTaskRow'; - -function createMapStateToProps() { - return createSelector( - createUISettingsSelector(), - (uiSettings) => { - return { - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onCancelPress() { - dispatch(cancelCommand({ - id: props.id - })); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(QueuedTaskRow); diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css new file mode 100644 index 000000000..41acb33f8 --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css @@ -0,0 +1,8 @@ +.commandName { + display: inline-block; + min-width: 220px; +} + +.userAgent { + color: #b0b0b0; +} diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css.d.ts b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css.d.ts new file mode 100644 index 000000000..fc9081492 --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'commandName': string; + 'userAgent': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx new file mode 100644 index 000000000..601a57242 --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { CommandBody } from 'Commands/Command'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import translate from 'Utilities/String/translate'; +import styles from './QueuedTaskRowNameCell.css'; + +export interface QueuedTaskRowNameCellProps { + commandName: string; + body: CommandBody; + clientUserAgent?: string; +} + +export default function QueuedTaskRowNameCell( + props: QueuedTaskRowNameCellProps +) { + const { commandName, clientUserAgent } = props; + + return ( + + {commandName} + + {clientUserAgent ? ( + + {translate('From')}: {clientUserAgent} + + ) : null} + + ); +} diff --git a/frontend/src/System/Tasks/Queued/QueuedTasks.js b/frontend/src/System/Tasks/Queued/QueuedTasks.js deleted file mode 100644 index 5dc901ae4..000000000 --- a/frontend/src/System/Tasks/Queued/QueuedTasks.js +++ /dev/null @@ -1,90 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import FieldSet from 'Components/FieldSet'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import translate from 'Utilities/String/translate'; -import QueuedTaskRowConnector from './QueuedTaskRowConnector'; - -const columns = [ - { - name: 'trigger', - label: '', - isVisible: true - }, - { - name: 'commandName', - label: translate('Name'), - isVisible: true - }, - { - name: 'queued', - label: translate('Queued'), - isVisible: true - }, - { - name: 'started', - label: translate('Started'), - isVisible: true - }, - { - name: 'ended', - label: translate('Ended'), - isVisible: true - }, - { - name: 'duration', - label: translate('Duration'), - isVisible: true - }, - { - name: 'actions', - isVisible: true - } -]; - -function QueuedTasks(props) { - const { - isFetching, - isPopulated, - items - } = props; - - return ( -
- { - isFetching && !isPopulated && - - } - - { - isPopulated && - - - { - items.map((item) => { - return ( - - ); - }) - } - -
- } -
- ); -} - -QueuedTasks.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - items: PropTypes.array.isRequired -}; - -export default QueuedTasks; diff --git a/frontend/src/System/Tasks/Queued/QueuedTasks.tsx b/frontend/src/System/Tasks/Queued/QueuedTasks.tsx new file mode 100644 index 000000000..e79deed7c --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTasks.tsx @@ -0,0 +1,74 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import { fetchCommands } from 'Store/Actions/commandActions'; +import translate from 'Utilities/String/translate'; +import QueuedTaskRow from './QueuedTaskRow'; + +const columns = [ + { + name: 'trigger', + label: '', + isVisible: true, + }, + { + name: 'commandName', + label: () => translate('Name'), + isVisible: true, + }, + { + name: 'queued', + label: () => translate('Queued'), + isVisible: true, + }, + { + name: 'started', + label: () => translate('Started'), + isVisible: true, + }, + { + name: 'ended', + label: () => translate('Ended'), + isVisible: true, + }, + { + name: 'duration', + label: () => translate('Duration'), + isVisible: true, + }, + { + name: 'actions', + isVisible: true, + }, +]; + +export default function QueuedTasks() { + const dispatch = useDispatch(); + const { isFetching, isPopulated, items } = useSelector( + (state: AppState) => state.commands + ); + + useEffect(() => { + dispatch(fetchCommands()); + }, [dispatch]); + + return ( +
+ {isFetching && !isPopulated && } + + {isPopulated && ( + + + {items.map((item) => { + return ; + })} + +
+ )} +
+ ); +} diff --git a/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js deleted file mode 100644 index 5fa4d9ead..000000000 --- a/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js +++ /dev/null @@ -1,46 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchCommands } from 'Store/Actions/commandActions'; -import QueuedTasks from './QueuedTasks'; - -function createMapStateToProps() { - return createSelector( - (state) => state.commands, - (commands) => { - return commands; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchCommands: fetchCommands -}; - -class QueuedTasksConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.dispatchFetchCommands(); - } - - // - // Render - - render() { - return ( - - ); - } -} - -QueuedTasksConnector.propTypes = { - dispatchFetchCommands: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(QueuedTasksConnector); diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js deleted file mode 100644 index acb8c8d36..000000000 --- a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js +++ /dev/null @@ -1,203 +0,0 @@ -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableRow from 'Components/Table/TableRow'; -import { icons } from 'Helpers/Props'; -import formatDate from 'Utilities/Date/formatDate'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; -import styles from './ScheduledTaskRow.css'; - -function getFormattedDates(props) { - const { - lastExecution, - nextExecution, - interval, - showRelativeDates, - shortDateFormat - } = props; - - const isDisabled = interval === 0; - - if (showRelativeDates) { - return { - lastExecutionTime: moment(lastExecution).fromNow(), - nextExecutionTime: isDisabled ? '-' : moment(nextExecution).fromNow() - }; - } - - return { - lastExecutionTime: formatDate(lastExecution, shortDateFormat), - nextExecutionTime: isDisabled ? '-' : formatDate(nextExecution, shortDateFormat) - }; -} - -class ScheduledTaskRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = getFormattedDates(props); - - this._updateTimeoutId = null; - } - - componentDidMount() { - this.setUpdateTimer(); - } - - componentDidUpdate(prevProps) { - const { - lastExecution, - nextExecution - } = this.props; - - if ( - lastExecution !== prevProps.lastExecution || - nextExecution !== prevProps.nextExecution - ) { - this.setState(getFormattedDates(this.props)); - } - } - - componentWillUnmount() { - if (this._updateTimeoutId) { - this._updateTimeoutId = clearTimeout(this._updateTimeoutId); - } - } - - // - // Listeners - - setUpdateTimer() { - const { interval } = this.props; - const timeout = interval < 60 ? 10000 : 60000; - - this._updateTimeoutId = setTimeout(() => { - this.setState(getFormattedDates(this.props)); - this.setUpdateTimer(); - }, timeout); - } - - // - // Render - - render() { - const { - name, - interval, - lastExecution, - lastStartTime, - lastDuration, - nextExecution, - isQueued, - isExecuting, - longDateFormat, - timeFormat, - onExecutePress - } = this.props; - - const { - lastExecutionTime, - nextExecutionTime - } = this.state; - - const isDisabled = interval === 0; - const executeNow = !isDisabled && moment().isAfter(nextExecution); - const hasNextExecutionTime = !isDisabled && !executeNow; - const duration = moment.duration(interval, 'minutes').humanize().replace(/an?(?=\s)/, '1'); - const hasLastStartTime = moment(lastStartTime).isAfter('2010-01-01'); - - return ( - - {name} - - {isDisabled ? 'disabled' : duration} - - - - {lastExecutionTime} - - - { - !hasLastStartTime && - - - } - - { - hasLastStartTime && - - {formatTimeSpan(lastDuration)} - - } - - { - isDisabled && - - - } - - { - executeNow && isQueued && - queued - } - - { - executeNow && !isQueued && - now - } - - { - hasNextExecutionTime && - - {nextExecutionTime} - - } - - - - - - ); - } -} - -ScheduledTaskRow.propTypes = { - name: PropTypes.string.isRequired, - interval: PropTypes.number.isRequired, - lastExecution: PropTypes.string.isRequired, - lastStartTime: PropTypes.string.isRequired, - lastDuration: PropTypes.string.isRequired, - nextExecution: PropTypes.string.isRequired, - isQueued: PropTypes.bool.isRequired, - isExecuting: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onExecutePress: PropTypes.func.isRequired -}; - -export default ScheduledTaskRow; diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.tsx b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.tsx new file mode 100644 index 000000000..3a3cd02de --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.tsx @@ -0,0 +1,170 @@ +import moment from 'moment'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRow from 'Components/Table/TableRow'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { icons } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchTask } from 'Store/Actions/systemActions'; +import createCommandSelector from 'Store/Selectors/createCommandSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { isCommandExecuting } from 'Utilities/Command'; +import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import styles from './ScheduledTaskRow.css'; + +interface ScheduledTaskRowProps { + id: number; + taskName: string; + name: string; + interval: number; + lastExecution: string; + lastStartTime: string; + lastDuration: string; + nextExecution: string; +} + +function ScheduledTaskRow(props: ScheduledTaskRowProps) { + const { + id, + taskName, + name, + interval, + lastExecution, + lastStartTime, + lastDuration, + nextExecution, + } = props; + + const dispatch = useDispatch(); + + const { showRelativeDates, longDateFormat, shortDateFormat, timeFormat } = + useSelector(createUISettingsSelector()); + const command = useSelector(createCommandSelector(taskName)); + + const [time, setTime] = useState(Date.now()); + + const isQueued = !!(command && command.status === 'queued'); + const isExecuting = isCommandExecuting(command); + const wasExecuting = usePrevious(isExecuting); + const isDisabled = interval === 0; + const executeNow = !isDisabled && moment().isAfter(nextExecution); + const hasNextExecutionTime = !isDisabled && !executeNow; + const hasLastStartTime = moment(lastStartTime).isAfter('2010-01-01'); + + const duration = useMemo(() => { + return moment + .duration(interval, 'minutes') + .humanize() + .replace(/an?(?=\s)/, '1'); + }, [interval]); + + const { lastExecutionTime, nextExecutionTime } = useMemo(() => { + const isDisabled = interval === 0; + + if (showRelativeDates && time) { + return { + lastExecutionTime: moment(lastExecution).fromNow(), + nextExecutionTime: isDisabled ? '-' : moment(nextExecution).fromNow(), + }; + } + + return { + lastExecutionTime: formatDate(lastExecution, shortDateFormat), + nextExecutionTime: isDisabled + ? '-' + : formatDate(nextExecution, shortDateFormat), + }; + }, [ + time, + interval, + lastExecution, + nextExecution, + showRelativeDates, + shortDateFormat, + ]); + + const handleExecutePress = useCallback(() => { + dispatch( + executeCommand({ + name: taskName, + }) + ); + }, [taskName, dispatch]); + + useEffect(() => { + if (!isExecuting && wasExecuting) { + setTimeout(() => { + dispatch(fetchTask({ id })); + }, 1000); + } + }, [id, isExecuting, wasExecuting, dispatch]); + + useEffect(() => { + const interval = setInterval(() => setTime(Date.now()), 1000); + return () => { + clearInterval(interval); + }; + }, [setTime]); + + return ( + + {name} + + {isDisabled ? 'disabled' : duration} + + + + {lastExecutionTime} + + + {hasLastStartTime ? ( + + {formatTimeSpan(lastDuration)} + + ) : ( + - + )} + + {isDisabled ? ( + - + ) : null} + + {executeNow && isQueued ? ( + queued + ) : null} + + {executeNow && !isQueued ? ( + now + ) : null} + + {hasNextExecutionTime ? ( + + {nextExecutionTime} + + ) : null} + + + + + + ); +} + +export default ScheduledTaskRow; diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js deleted file mode 100644 index dae790d68..000000000 --- a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js +++ /dev/null @@ -1,92 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { executeCommand } from 'Store/Actions/commandActions'; -import { fetchTask } from 'Store/Actions/systemActions'; -import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import { findCommand, isCommandExecuting } from 'Utilities/Command'; -import ScheduledTaskRow from './ScheduledTaskRow'; - -function createMapStateToProps() { - return createSelector( - (state, { taskName }) => taskName, - createCommandsSelector(), - createUISettingsSelector(), - (taskName, commands, uiSettings) => { - const command = findCommand(commands, { name: taskName }); - - return { - isQueued: !!(command && command.state === 'queued'), - isExecuting: isCommandExecuting(command), - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - const taskName = props.taskName; - - return { - dispatchFetchTask() { - dispatch(fetchTask({ - id: props.id - })); - }, - - onExecutePress() { - dispatch(executeCommand({ - name: taskName - })); - } - }; -} - -class ScheduledTaskRowConnector extends Component { - - // - // Lifecycle - - componentDidUpdate(prevProps) { - const { - isExecuting, - dispatchFetchTask - } = this.props; - - if (!isExecuting && prevProps.isExecuting) { - // Give the host a moment to update after the command completes - setTimeout(() => { - dispatchFetchTask(); - }, 1000); - } - } - - // - // Render - - render() { - const { - dispatchFetchTask, - ...otherProps - } = this.props; - - return ( - - ); - } -} - -ScheduledTaskRowConnector.propTypes = { - id: PropTypes.number.isRequired, - isExecuting: PropTypes.bool.isRequired, - dispatchFetchTask: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, createMapDispatchToProps)(ScheduledTaskRowConnector); diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js deleted file mode 100644 index 8dbe5c08b..000000000 --- a/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js +++ /dev/null @@ -1,85 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import FieldSet from 'Components/FieldSet'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import translate from 'Utilities/String/translate'; -import ScheduledTaskRowConnector from './ScheduledTaskRowConnector'; - -const columns = [ - { - name: 'name', - label: translate('Name'), - isVisible: true - }, - { - name: 'interval', - label: translate('Interval'), - isVisible: true - }, - { - name: 'lastExecution', - label: translate('LastExecution'), - isVisible: true - }, - { - name: 'lastDuration', - label: translate('LastDuration'), - isVisible: true - }, - { - name: 'nextExecution', - label: translate('NextExecution'), - isVisible: true - }, - { - name: 'actions', - isVisible: true - } -]; - -function ScheduledTasks(props) { - const { - isFetching, - isPopulated, - items - } = props; - - return ( -
- { - isFetching && !isPopulated && - - } - - { - isPopulated && - - - { - items.map((item) => { - return ( - - ); - }) - } - -
- } -
- ); -} - -ScheduledTasks.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - items: PropTypes.array.isRequired -}; - -export default ScheduledTasks; diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasks.tsx b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.tsx new file mode 100644 index 000000000..fcf5764bb --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.tsx @@ -0,0 +1,73 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Column from 'Components/Table/Column'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import { fetchTasks } from 'Store/Actions/systemActions'; +import translate from 'Utilities/String/translate'; +import ScheduledTaskRow from './ScheduledTaskRow'; + +const columns: Column[] = [ + { + name: 'name', + label: () => translate('Name'), + isVisible: true, + }, + { + name: 'interval', + label: () => translate('Interval'), + isVisible: true, + }, + { + name: 'lastExecution', + label: () => translate('LastExecution'), + isVisible: true, + }, + { + name: 'lastDuration', + label: () => translate('LastDuration'), + isVisible: true, + }, + { + name: 'nextExecution', + label: () => translate('NextExecution'), + isVisible: true, + }, + { + name: 'actions', + label: '', + isVisible: true, + }, +]; + +function ScheduledTasks() { + const dispatch = useDispatch(); + const { isFetching, isPopulated, items } = useSelector( + (state: AppState) => state.system.tasks + ); + + useEffect(() => { + dispatch(fetchTasks()); + }, [dispatch]); + + return ( +
+ {isFetching && !isPopulated && } + + {isPopulated && ( + + + {items.map((item) => { + return ; + })} + +
+ )} +
+ ); +} + +export default ScheduledTasks; diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js deleted file mode 100644 index 8f418d3bb..000000000 --- a/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js +++ /dev/null @@ -1,46 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchTasks } from 'Store/Actions/systemActions'; -import ScheduledTasks from './ScheduledTasks'; - -function createMapStateToProps() { - return createSelector( - (state) => state.system.tasks, - (tasks) => { - return tasks; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchTasks: fetchTasks -}; - -class ScheduledTasksConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.dispatchFetchTasks(); - } - - // - // Render - - render() { - return ( - - ); - } -} - -ScheduledTasksConnector.propTypes = { - dispatchFetchTasks: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ScheduledTasksConnector); diff --git a/frontend/src/System/Tasks/Tasks.js b/frontend/src/System/Tasks/Tasks.tsx similarity index 63% rename from frontend/src/System/Tasks/Tasks.js rename to frontend/src/System/Tasks/Tasks.tsx index 032dbede8..26473d7ba 100644 --- a/frontend/src/System/Tasks/Tasks.js +++ b/frontend/src/System/Tasks/Tasks.tsx @@ -2,15 +2,15 @@ import React from 'react'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import translate from 'Utilities/String/translate'; -import QueuedTasksConnector from './Queued/QueuedTasksConnector'; -import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector'; +import QueuedTasks from './Queued/QueuedTasks'; +import ScheduledTasks from './Scheduled/ScheduledTasks'; function Tasks() { return ( - - + + ); diff --git a/frontend/src/System/Updates/UpdateChanges.js b/frontend/src/System/Updates/UpdateChanges.js deleted file mode 100644 index 9d6b9decc..000000000 --- a/frontend/src/System/Updates/UpdateChanges.js +++ /dev/null @@ -1,50 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; -import styles from './UpdateChanges.css'; - -class UpdateChanges extends Component { - - // - // Render - - render() { - const { - title, - changes - } = this.props; - - if (changes.length === 0) { - return null; - } - - return ( -
-
{title}
-
    - { - changes.map((change, index) => { - const checkChange = change.replace(/#\d{3,5}\b/g, (match, contents) => { - return `[${match}](https://github.com/Prowlarr/Prowlarr/issues/${match.substring(1)})`; - }); - - return ( -
  • - -
  • - ); - }) - } -
-
- ); - } - -} - -UpdateChanges.propTypes = { - title: PropTypes.string.isRequired, - changes: PropTypes.arrayOf(PropTypes.string) -}; - -export default UpdateChanges; diff --git a/frontend/src/System/Updates/UpdateChanges.tsx b/frontend/src/System/Updates/UpdateChanges.tsx new file mode 100644 index 000000000..460814cbe --- /dev/null +++ b/frontend/src/System/Updates/UpdateChanges.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; +import styles from './UpdateChanges.css'; + +interface UpdateChangesProps { + title: string; + changes: string[]; +} + +function UpdateChanges(props: UpdateChangesProps) { + const { title, changes } = props; + + if (changes.length === 0) { + return null; + } + + const uniqueChanges = [...new Set(changes)]; + + return ( +
+
{title}
+
    + {uniqueChanges.map((change, index) => { + const checkChange = change.replace( + /#\d{3,5}\b/g, + (match) => + `[${match}](https://github.com/Prowlarr/Prowlarr/issues/${match.substring( + 1 + )})` + ); + + return ( +
  • + +
  • + ); + })} +
+
+ ); +} + +export default UpdateChanges; diff --git a/frontend/src/System/Updates/Updates.js b/frontend/src/System/Updates/Updates.js deleted file mode 100644 index 6a933cf2f..000000000 --- a/frontend/src/System/Updates/Updates.js +++ /dev/null @@ -1,252 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component, Fragment } from 'react'; -import Alert from 'Components/Alert'; -import Icon from 'Components/Icon'; -import Label from 'Components/Label'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import { icons, kinds } from 'Helpers/Props'; -import formatDate from 'Utilities/Date/formatDate'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import translate from 'Utilities/String/translate'; -import UpdateChanges from './UpdateChanges'; -import styles from './Updates.css'; - -class Updates extends Component { - - // - // Render - - render() { - const { - currentVersion, - isFetching, - isPopulated, - updatesError, - generalSettingsError, - items, - isInstallingUpdate, - updateMechanism, - isDocker, - updateMechanismMessage, - shortDateFormat, - longDateFormat, - timeFormat, - onInstallLatestPress - } = this.props; - - const hasError = !!(updatesError || generalSettingsError); - const hasUpdates = isPopulated && !hasError && items.length > 0; - const noUpdates = isPopulated && !hasError && !items.length; - const hasUpdateToInstall = hasUpdates && _.some(items, { installable: true, latest: true }); - const noUpdateToInstall = hasUpdates && !hasUpdateToInstall; - - const externalUpdaterPrefix = 'Unable to update Prowlarr directly,'; - const externalUpdaterMessages = { - external: 'Prowlarr is configured to use an external update mechanism', - apt: 'use apt to install the update', - docker: 'update the docker container to receive the update' - }; - - return ( - - - { - !isPopulated && !hasError && - - } - - { - noUpdates && - - {translate('NoUpdatesAreAvailable')} - - } - - { - hasUpdateToInstall && -
- { - (updateMechanism === 'builtIn' || updateMechanism === 'script') && !isDocker ? - - Install Latest - : - - - - -
- {externalUpdaterPrefix} -
-
- } - - { - isFetching && - - } -
- } - - { - noUpdateToInstall && -
- - -
- {translate('TheLatestVersionIsAlreadyInstalled', ['Prowlarr'])} -
- - { - isFetching && - - } -
- } - - { - hasUpdates && -
- { - items.map((update) => { - const hasChanges = !!update.changes; - - return ( -
-
-
{update.version}
-
-
- {formatDate(update.releaseDate, shortDateFormat)} -
- - { - update.branch === 'master' ? - null: - - } - - { - update.version === currentVersion ? - : - null - } - - { - update.version !== currentVersion && update.installedOn ? - : - null - } -
- - { - !hasChanges && -
- {translate('MaintenanceRelease')} -
- } - - { - hasChanges && -
- - - -
- } -
- ); - }) - } -
- } - - { - !!updatesError && -
- Failed to fetch updates -
- } - - { - !!generalSettingsError && -
- Failed to update settings -
- } -
-
- ); - } - -} - -Updates.propTypes = { - currentVersion: PropTypes.string.isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - updatesError: PropTypes.object, - generalSettingsError: PropTypes.object, - items: PropTypes.array.isRequired, - isInstallingUpdate: PropTypes.bool.isRequired, - isDocker: PropTypes.bool.isRequired, - updateMechanism: PropTypes.string, - updateMechanismMessage: PropTypes.string, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onInstallLatestPress: PropTypes.func.isRequired -}; - -export default Updates; diff --git a/frontend/src/System/Updates/Updates.tsx b/frontend/src/System/Updates/Updates.tsx new file mode 100644 index 000000000..ea309a1cc --- /dev/null +++ b/frontend/src/System/Updates/Updates.tsx @@ -0,0 +1,303 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import * as commandNames from 'Commands/commandNames'; +import Alert from 'Components/Alert'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import { icons, kinds } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchGeneralSettings } from 'Store/Actions/settingsActions'; +import { fetchUpdates } from 'Store/Actions/systemActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { UpdateMechanism } from 'typings/Settings/General'; +import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import translate from 'Utilities/String/translate'; +import UpdateChanges from './UpdateChanges'; +import styles from './Updates.css'; + +const VERSION_REGEX = /\d+\.\d+\.\d+\.\d+/i; + +function createUpdatesSelector() { + return createSelector( + (state: AppState) => state.system.updates, + (state: AppState) => state.settings.general, + (updates, generalSettings) => { + const { error: updatesError, items } = updates; + + const isFetching = updates.isFetching || generalSettings.isFetching; + const isPopulated = updates.isPopulated && generalSettings.isPopulated; + + return { + isFetching, + isPopulated, + updatesError, + generalSettingsError: generalSettings.error, + items, + updateMechanism: generalSettings.item.updateMechanism, + }; + } + ); +} + +function Updates() { + const currentVersion = useSelector((state: AppState) => state.app.version); + const { packageUpdateMechanismMessage } = useSelector( + createSystemStatusSelector() + ); + const { shortDateFormat, longDateFormat, timeFormat } = useSelector( + createUISettingsSelector() + ); + const isInstallingUpdate = useSelector( + createCommandExecutingSelector(commandNames.APPLICATION_UPDATE) + ); + + const { + isFetching, + isPopulated, + updatesError, + generalSettingsError, + items, + updateMechanism, + } = useSelector(createUpdatesSelector()); + + const dispatch = useDispatch(); + const [isMajorUpdateModalOpen, setIsMajorUpdateModalOpen] = useState(false); + const hasError = !!(updatesError || generalSettingsError); + const hasUpdates = isPopulated && !hasError && items.length > 0; + const noUpdates = isPopulated && !hasError && !items.length; + + const externalUpdaterPrefix = translate('UpdateAppDirectlyLoadError'); + const externalUpdaterMessages: Partial> = { + external: translate('ExternalUpdater'), + apt: translate('AptUpdater'), + docker: translate('DockerUpdater'), + }; + + const { isMajorUpdate, hasUpdateToInstall } = useMemo(() => { + const majorVersion = parseInt( + currentVersion.match(VERSION_REGEX)?.[0] ?? '0' + ); + + const latestVersion = items[0]?.version; + const latestMajorVersion = parseInt( + latestVersion?.match(VERSION_REGEX)?.[0] ?? '0' + ); + + return { + isMajorUpdate: latestMajorVersion > majorVersion, + hasUpdateToInstall: items.some( + (update) => update.installable && update.latest + ), + }; + }, [currentVersion, items]); + + const noUpdateToInstall = hasUpdates && !hasUpdateToInstall; + + const handleInstallLatestPress = useCallback(() => { + if (isMajorUpdate) { + setIsMajorUpdateModalOpen(true); + } else { + dispatch(executeCommand({ name: commandNames.APPLICATION_UPDATE })); + } + }, [isMajorUpdate, setIsMajorUpdateModalOpen, dispatch]); + + const handleInstallLatestMajorVersionPress = useCallback(() => { + setIsMajorUpdateModalOpen(false); + + dispatch( + executeCommand({ + name: commandNames.APPLICATION_UPDATE, + installMajorUpdate: true, + }) + ); + }, [setIsMajorUpdateModalOpen, dispatch]); + + const handleCancelMajorVersionPress = useCallback(() => { + setIsMajorUpdateModalOpen(false); + }, [setIsMajorUpdateModalOpen]); + + useEffect(() => { + dispatch(fetchUpdates()); + dispatch(fetchGeneralSettings()); + }, [dispatch]); + + return ( + + + {isPopulated || hasError ? null : } + + {noUpdates ? ( + {translate('NoUpdatesAreAvailable')} + ) : null} + + {hasUpdateToInstall ? ( +
+ {updateMechanism === 'builtIn' || updateMechanism === 'script' ? ( + + {translate('InstallLatest')} + + ) : ( + <> + + +
+ {externalUpdaterPrefix}{' '} + +
+ + )} + + {isFetching ? ( + + ) : null} +
+ ) : null} + + {noUpdateToInstall && ( +
+ +
{translate('OnLatestVersion')}
+ + {isFetching && ( + + )} +
+ )} + + {hasUpdates && ( +
+ {items.map((update) => { + return ( +
+
+
{update.version}
+
+
+ {formatDate(update.releaseDate, shortDateFormat)} +
+ + {update.branch === 'master' ? null : ( + + )} + + {update.version === currentVersion ? ( + + ) : null} + + {update.version !== currentVersion && update.installedOn ? ( + + ) : null} +
+ + {update.changes ? ( +
+ + + +
+ ) : ( +
{translate('MaintenanceRelease')}
+ )} +
+ ); + })} +
+ )} + + {updatesError ? ( + + {translate('FailedToFetchUpdates')} + + ) : null} + + {generalSettingsError ? ( + + {translate('FailedToFetchSettings')} + + ) : null} + + +
{translate('InstallMajorVersionUpdateMessage')}
+
+ +
+ + } + confirmLabel={translate('Install')} + onConfirm={handleInstallLatestMajorVersionPress} + onCancel={handleCancelMajorVersionPress} + /> +
+
+ ); +} + +export default Updates; diff --git a/frontend/src/System/Updates/UpdatesConnector.js b/frontend/src/System/Updates/UpdatesConnector.js deleted file mode 100644 index 38873a990..000000000 --- a/frontend/src/System/Updates/UpdatesConnector.js +++ /dev/null @@ -1,101 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import { executeCommand } from 'Store/Actions/commandActions'; -import { fetchGeneralSettings } from 'Store/Actions/settingsActions'; -import { fetchUpdates } from 'Store/Actions/systemActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import Updates from './Updates'; - -function createMapStateToProps() { - return createSelector( - (state) => state.app.version, - createSystemStatusSelector(), - (state) => state.system.updates, - (state) => state.settings.general, - createUISettingsSelector(), - createSystemStatusSelector(), - createCommandExecutingSelector(commandNames.APPLICATION_UPDATE), - ( - currentVersion, - status, - updates, - generalSettings, - uiSettings, - systemStatus, - isInstallingUpdate - ) => { - const { - error: updatesError, - items - } = updates; - - const isFetching = updates.isFetching || generalSettings.isFetching; - const isPopulated = updates.isPopulated && generalSettings.isPopulated; - - return { - currentVersion, - isFetching, - isPopulated, - updatesError, - generalSettingsError: generalSettings.error, - items, - isInstallingUpdate, - isDocker: systemStatus.isDocker, - updateMechanism: generalSettings.item.updateMechanism, - updateMechanismMessage: status.packageUpdateMechanismMessage, - shortDateFormat: uiSettings.shortDateFormat, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchUpdates: fetchUpdates, - dispatchFetchGeneralSettings: fetchGeneralSettings, - dispatchExecuteCommand: executeCommand -}; - -class UpdatesConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.dispatchFetchUpdates(); - this.props.dispatchFetchGeneralSettings(); - } - - // - // Listeners - - onInstallLatestPress = () => { - this.props.dispatchExecuteCommand({ name: commandNames.APPLICATION_UPDATE }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -UpdatesConnector.propTypes = { - dispatchFetchUpdates: PropTypes.func.isRequired, - dispatchFetchGeneralSettings: PropTypes.func.isRequired, - dispatchExecuteCommand: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(UpdatesConnector); diff --git a/frontend/src/Utilities/Array/sortByName.js b/frontend/src/Utilities/Array/sortByName.js deleted file mode 100644 index 1956d3bac..000000000 --- a/frontend/src/Utilities/Array/sortByName.js +++ /dev/null @@ -1,5 +0,0 @@ -function sortByName(a, b) { - return a.name.localeCompare(b.name); -} - -export default sortByName; diff --git a/frontend/src/Utilities/Array/sortByProp.ts b/frontend/src/Utilities/Array/sortByProp.ts new file mode 100644 index 000000000..8fbde08c9 --- /dev/null +++ b/frontend/src/Utilities/Array/sortByProp.ts @@ -0,0 +1,13 @@ +import { StringKey } from 'typings/Helpers/KeysMatching'; + +export function sortByProp< + // eslint-disable-next-line no-use-before-define + T extends Record, + K extends StringKey +>(sortKey: K) { + return (a: T, b: T) => { + return a[sortKey].localeCompare(b[sortKey], undefined, { numeric: true }); + }; +} + +export default sortByProp; diff --git a/frontend/src/Utilities/Number/abbreviateNumber.js b/frontend/src/Utilities/Number/abbreviateNumber.js new file mode 100644 index 000000000..f6c86a9d7 --- /dev/null +++ b/frontend/src/Utilities/Number/abbreviateNumber.js @@ -0,0 +1,19 @@ +export default function abbreviateNumber(num, decimalPlaces) { + if (num === null) { + return null; + } + + if (num === 0) { + return '0'; + } + + decimalPlaces = (!decimalPlaces || decimalPlaces < 0) ? 0 : decimalPlaces; + + const b = (num).toPrecision(2).split('e'); + const k = b.length === 1 ? 0 : Math.floor(Math.min(b[1].slice(1), 14) / 3); + const c = k < 1 ? num.toFixed(0 + decimalPlaces) : (num / Math.pow(10, k * 3) ).toFixed(1 + decimalPlaces); + const d = c < 0 ? c : Math.abs(c); + const e = d + ['', 'K', 'M', 'B', 'T'][k]; + + return e; +} diff --git a/frontend/src/Utilities/Number/formatBytes.js b/frontend/src/Utilities/Number/formatBytes.ts similarity index 61% rename from frontend/src/Utilities/Number/formatBytes.js rename to frontend/src/Utilities/Number/formatBytes.ts index 2fb3eebe6..a0ae8a985 100644 --- a/frontend/src/Utilities/Number/formatBytes.js +++ b/frontend/src/Utilities/Number/formatBytes.ts @@ -1,16 +1,16 @@ import { filesize } from 'filesize'; -function formatBytes(input) { +function formatBytes(input: string | number) { const size = Number(input); if (isNaN(size)) { return ''; } - return filesize(size, { + return `${filesize(size, { base: 2, - round: 1 - }); + round: 1, + })}`; } export default formatBytes; diff --git a/frontend/src/Utilities/String/translate.js b/frontend/src/Utilities/String/translate.js deleted file mode 100644 index 7483b27aa..000000000 --- a/frontend/src/Utilities/String/translate.js +++ /dev/null @@ -1,31 +0,0 @@ -import createAjaxRequest from 'Utilities/createAjaxRequest'; - -function getTranslations() { - let localization = null; - const ajaxOptions = { - async: false, - dataType: 'json', - url: '/localization', - success: function(data) { - localization = data.Strings; - } - }; - - createAjaxRequest(ajaxOptions); - - return localization; -} - -const translations = getTranslations(); - -export default function translate(key, args = []) { - const translation = translations[key] || key; - - if (args) { - return translation.replace(/\{(\d+)\}/g, (match, index) => { - return args[index]; - }); - } - - return translation; -} diff --git a/frontend/src/Utilities/String/translate.ts b/frontend/src/Utilities/String/translate.ts new file mode 100644 index 000000000..72d3adf40 --- /dev/null +++ b/frontend/src/Utilities/String/translate.ts @@ -0,0 +1,42 @@ +import createAjaxRequest from 'Utilities/createAjaxRequest'; + +function getTranslations() { + return createAjaxRequest({ + global: false, + dataType: 'json', + url: '/localization', + }).request; +} + +let translations: Record = {}; + +export async function fetchTranslations(): Promise { + return new Promise(async (resolve) => { + try { + const data = await getTranslations(); + translations = data.Strings; + + resolve(true); + } catch { + resolve(false); + } + }); +} + +export default function translate( + key: string, + tokens: Record = {} +) { + const translation = translations[key] || key; + + tokens.appName = 'Prowlarr'; + + // Fallback to the old behaviour for translations not yet updated to use named tokens + Object.values(tokens).forEach((value, index) => { + tokens[index] = value; + }); + + return translation.replace(/\{([a-z0-9]+?)\}/gi, (match, tokenMatch) => + String(tokens[tokenMatch] ?? match) + ); +} diff --git a/frontend/src/Utilities/Table/getSelectedIds.js b/frontend/src/Utilities/Table/getSelectedIds.js deleted file mode 100644 index 705f13a5d..000000000 --- a/frontend/src/Utilities/Table/getSelectedIds.js +++ /dev/null @@ -1,15 +0,0 @@ -import _ from 'lodash'; - -function getSelectedIds(selectedState, { parseIds = true } = {}) { - return _.reduce(selectedState, (result, value, id) => { - if (value) { - const parsedId = parseIds ? parseInt(id) : id; - - result.push(parsedId); - } - - return result; - }, []); -} - -export default getSelectedIds; diff --git a/frontend/src/Utilities/Table/getSelectedIds.ts b/frontend/src/Utilities/Table/getSelectedIds.ts new file mode 100644 index 000000000..b84db6245 --- /dev/null +++ b/frontend/src/Utilities/Table/getSelectedIds.ts @@ -0,0 +1,18 @@ +import { reduce } from 'lodash'; +import { SelectedState } from 'Helpers/Hooks/useSelectState'; + +function getSelectedIds(selectedState: SelectedState): number[] { + return reduce( + selectedState, + (result: number[], value, id) => { + if (value) { + result.push(parseInt(id)); + } + + return result; + }, + [] + ); +} + +export default getSelectedIds; diff --git a/frontend/src/Utilities/getPathWithUrlBase.js b/frontend/src/Utilities/getPathWithUrlBase.js deleted file mode 100644 index b687f2682..000000000 --- a/frontend/src/Utilities/getPathWithUrlBase.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function getPathWithUrlBase(path) { - return `${window.Prowlarr.urlBase}${path}`; -} diff --git a/frontend/src/Utilities/getPathWithUrlBase.ts b/frontend/src/Utilities/getPathWithUrlBase.ts new file mode 100644 index 000000000..948456728 --- /dev/null +++ b/frontend/src/Utilities/getPathWithUrlBase.ts @@ -0,0 +1,3 @@ +export default function getPathWithUrlBase(path: string) { + return `${window.Prowlarr.urlBase}${path}`; +} diff --git a/frontend/src/Utilities/getUniqueElementId.js b/frontend/src/Utilities/getUniqueElementId.js index dae5150b7..1b380851d 100644 --- a/frontend/src/Utilities/getUniqueElementId.js +++ b/frontend/src/Utilities/getUniqueElementId.js @@ -1,7 +1,9 @@ let i = 0; -// returns a HTML 4.0 compliant element IDs (http://stackoverflow.com/a/79022) - +/** + * @deprecated Use React's useId() instead + * @returns An HTML 4.0 compliant element IDs (http://stackoverflow.com/a/79022) + */ export default function getUniqueElementId() { return `id-${i++}`; } diff --git a/frontend/src/bootstrap.tsx b/frontend/src/bootstrap.tsx new file mode 100644 index 000000000..5e9985ba3 --- /dev/null +++ b/frontend/src/bootstrap.tsx @@ -0,0 +1,15 @@ +import { createBrowserHistory } from 'history'; +import React from 'react'; +import { render } from 'react-dom'; +import createAppStore from 'Store/createAppStore'; +import App from './App/App'; + +export async function bootstrap() { + const history = createBrowserHistory(); + const store = createAppStore(history); + + render( + , + document.getElementById('root') + ); +} diff --git a/frontend/src/index.ejs b/frontend/src/index.ejs index a2f8bcabc..5efc89448 100644 --- a/frontend/src/index.ejs +++ b/frontend/src/index.ejs @@ -3,13 +3,16 @@ - - - + + + + + + @@ -48,7 +51,15 @@ /> - + + + + <% for (key in htmlWebpackPlugin.files.js) { %><% } %> + <% for (key in htmlWebpackPlugin.files.css) { %><% } %> Prowlarr @@ -77,7 +88,4 @@
- - - diff --git a/frontend/src/index.js b/frontend/src/index.js deleted file mode 100644 index 59911154e..000000000 --- a/frontend/src/index.js +++ /dev/null @@ -1,21 +0,0 @@ -import { createBrowserHistory } from 'history'; -import React from 'react'; -import { render } from 'react-dom'; -import createAppStore from 'Store/createAppStore'; -import App from './App/App'; - -import './preload'; -import './polyfills'; -import 'Styles/globals.css'; -import './index.css'; - -const history = createBrowserHistory(); -const store = createAppStore(history); - -render( - , - document.getElementById('root') -); diff --git a/frontend/src/index.ts b/frontend/src/index.ts new file mode 100644 index 000000000..5c58019a2 --- /dev/null +++ b/frontend/src/index.ts @@ -0,0 +1,19 @@ +import './polyfills'; +import 'Styles/globals.css'; +import './index.css'; + +const initializeUrl = `${ + window.Prowlarr.urlBase +}/initialize.json?t=${Date.now()}`; +const response = await fetch(initializeUrl); + +window.Prowlarr = await response.json(); + +/* eslint-disable no-undef, @typescript-eslint/ban-ts-comment */ +// @ts-ignore 2304 +__webpack_public_path__ = `${window.Prowlarr.urlBase}/`; +/* eslint-enable no-undef, @typescript-eslint/ban-ts-comment */ + +const { bootstrap } = await import('./bootstrap'); + +await bootstrap(); diff --git a/frontend/src/login.html b/frontend/src/login.html index dcfb23140..d8af7f73f 100644 --- a/frontend/src/login.html +++ b/frontend/src/login.html @@ -3,15 +3,18 @@ - - + + + + + - + body { - background-color: #f5f7fa; - color: #656565; + background-color: var(--pageBackground); + color: var(--textColor); font-family: "Roboto", "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif; } @@ -85,14 +88,14 @@ padding: 10px; border-top-left-radius: 4px; border-top-right-radius: 4px; - background-color: #464b51; + background-color: var(--themeDarkColor); } .panel-body { padding: 20px; border-bottom-right-radius: 4px; border-bottom-left-radius: 4px; - background-color: #fff; + background-color: var(--panelBackground); } .sign-in { @@ -109,16 +112,18 @@ padding: 6px 16px; width: 100%; height: 35px; - border: 1px solid #dde6e9; + background-color: var(--inputBackgroundColor); + border: 1px solid var(--inputBorderColor); border-radius: 4px; - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + box-shadow: inset 0 1px 1px var(--inputBoxShadowColor); + color: var(--textColor); } .form-input:focus { outline: 0; - border-color: #66afe9; - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), - 0 0 8px rgba(102, 175, 233, 0.6); + border-color: var(--inputFocusBorderColor); + box-shadow: inset 0 1px 1px var(--inputBoxShadowColor), + 0 0 8px var(--inputFocusBoxShadowColor); } .button { @@ -127,10 +132,10 @@ padding: 10px 0; width: 100%; border: 1px solid; - border-color: #5899eb; + border-color: var(--primaryBorderColor); border-radius: 4px; - background-color: #5d9cec; - color: #fff; + background-color: var(--primaryBackgroundColor); + color: var(--white); vertical-align: middle; text-align: center; white-space: nowrap; @@ -138,9 +143,9 @@ } .button:hover { - border-color: #3483e7; - background-color: #4b91ea; - color: #fff; + border-color: var(--primaryHoverBorderColor); + background-color: var(--primaryHoverBackgroundColor); + color: var(--white); text-decoration: none; } @@ -162,24 +167,24 @@ .forgot-password { margin-left: auto; - color: #909fa7; + color: var(--forgotPasswordColor); text-decoration: none; font-size: 13px; } .forgot-password:focus, .forgot-password:hover { - color: #748690; + color: var(--forgotPasswordAltColor); text-decoration: underline; } .forgot-password:visited { - color: #748690; + color: var(--forgotPasswordAltColor); } .login-failed { margin-top: 20px; - color: #f05050; + color: var(--failedColor); font-size: 14px; } @@ -288,5 +293,59 @@ loginFailedDiv.classList.remove("hidden"); } + + var light = { + white: '#fff', + pageBackground: '#f5f7fa', + textColor: '#515253', + themeDarkColor: '#464b51', + panelBackground: '#fff', + inputBackgroundColor: '#fff', + inputBorderColor: '#dde6e9', + inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)', + inputFocusBorderColor: '#66afe9', + inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)', + primaryBackgroundColor: '#5d9cec', + primaryBorderColor: '#5899eb', + primaryHoverBackgroundColor: '#4b91ea', + primaryHoverBorderColor: '#3483e7', + failedColor: '#f05050', + forgotPasswordColor: '#909fa7', + forgotPasswordAltColor: '#748690' + }; + + var dark = { + white: '#fff', + pageBackground: '#202020', + textColor: '#ccc', + themeDarkColor: '#494949', + panelBackground: '#111', + inputBackgroundColor: '#333', + inputBorderColor: '#dde6e9', + inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)', + inputFocusBorderColor: '#66afe9', + inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)', + primaryBackgroundColor: '#5d9cec', + primaryBorderColor: '#5899eb', + primaryHoverBackgroundColor: '#4b91ea', + primaryHoverBorderColor: '#3483e7', + failedColor: '#f05050', + forgotPasswordColor: '#737d83', + forgotPasswordAltColor: '#546067' + }; + + var theme = "_THEME_"; + var defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + var finalTheme = theme === 'dark' || (theme === 'auto' && defaultDark) ? + dark : + light; + + Object.entries(finalTheme).forEach(([key, value]) => { + document.documentElement.style.setProperty( + `--${key}`, + value + ); + }); + diff --git a/frontend/src/typings/Health.ts b/frontend/src/typings/Health.ts new file mode 100644 index 000000000..66f385bbb --- /dev/null +++ b/frontend/src/typings/Health.ts @@ -0,0 +1,8 @@ +interface Health { + source: string; + type: string; + message: string; + wikiUrl: string; +} + +export default Health; diff --git a/frontend/src/typings/Helpers/KeysMatching.ts b/frontend/src/typings/Helpers/KeysMatching.ts new file mode 100644 index 000000000..0e20206ef --- /dev/null +++ b/frontend/src/typings/Helpers/KeysMatching.ts @@ -0,0 +1,7 @@ +type KeysMatching = { + [K in keyof T]-?: T[K] extends V ? K : never; +}[keyof T]; + +export type StringKey = KeysMatching; + +export default KeysMatching; diff --git a/frontend/src/typings/History.ts b/frontend/src/typings/History.ts new file mode 100644 index 000000000..3e50355dc --- /dev/null +++ b/frontend/src/typings/History.ts @@ -0,0 +1,28 @@ +import ModelBase from 'App/ModelBase'; + +export type HistoryQueryType = + | 'search' + | 'tvsearch' + | 'movie' + | 'book' + | 'music'; + +export interface HistoryData { + source: string; + host: string; + limit: number; + offset: number; + elapsedTime: number; + query: string; + queryType: HistoryQueryType; +} + +interface History extends ModelBase { + indexerId: number; + date: string; + successful: boolean; + eventType: string; + data: HistoryData; +} + +export default History; diff --git a/frontend/src/typings/Indexer.ts b/frontend/src/typings/Indexer.ts deleted file mode 100644 index 6e31aa0c6..000000000 --- a/frontend/src/typings/Indexer.ts +++ /dev/null @@ -1,27 +0,0 @@ -import ModelBase from 'App/ModelBase'; - -export interface Field { - order: number; - name: string; - label: string; - value: boolean | number | string; - type: string; - advanced: boolean; - privacy: string; -} - -interface Indexer extends ModelBase { - enable: boolean; - appProfileId: number; - protocol: string; - priority: number; - name: string; - fields: Field[]; - implementationName: string; - implementation: string; - configContract: string; - infoLink: string; - tags: number[]; -} - -export default Indexer; diff --git a/frontend/src/typings/IndexerStats.ts b/frontend/src/typings/IndexerStats.ts new file mode 100644 index 000000000..ddbcebaec --- /dev/null +++ b/frontend/src/typings/IndexerStats.ts @@ -0,0 +1,32 @@ +export interface IndexerStatsIndexer { + indexerId: number; + indexerName: string; + averageResponseTime: number; + averageGrabResponseTime: number; + numberOfQueries: number; + numberOfGrabs: number; + numberOfRssQueries: number; + numberOfAuthQueries: number; + numberOfFailedQueries: number; + numberOfFailedGrabs: number; + numberOfFailedRssQueries: number; + numberOfFailedAuthQueries: number; +} + +export interface IndexerStatsUserAgent { + userAgent: string; + numberOfQueries: number; + numberOfGrabs: number; +} + +export interface IndexerStatsHost { + host: string; + numberOfQueries: number; + numberOfGrabs: number; +} + +export interface IndexerStats { + indexers: IndexerStatsIndexer[]; + userAgents: IndexerStatsUserAgent[]; + hosts: IndexerStatsHost[]; +} diff --git a/frontend/src/typings/Notification.ts b/frontend/src/typings/Notification.ts index e2b5ad7eb..63ea906c4 100644 --- a/frontend/src/typings/Notification.ts +++ b/frontend/src/typings/Notification.ts @@ -13,6 +13,15 @@ export interface Field { interface Notification extends ModelBase { enable: boolean; name: string; + onGrab: boolean; + onHealthIssue: boolean; + onHealthRestored: boolean; + includeHealthWarnings: boolean; + onApplicationUpdate: boolean; + supportsOnGrab: boolean; + supportsOnHealthIssue: boolean; + supportsOnHealthRestored: boolean; + supportsOnApplicationUpdate: boolean; fields: Field[]; implementationName: string; implementation: string; diff --git a/frontend/src/typings/Release.ts b/frontend/src/typings/Release.ts new file mode 100644 index 000000000..5b832b1da --- /dev/null +++ b/frontend/src/typings/Release.ts @@ -0,0 +1,28 @@ +import ModelBase from 'App/ModelBase'; +import { IndexerCategory } from 'Indexer/Indexer'; + +interface Release extends ModelBase { + guid: string; + categories: IndexerCategory[]; + protocol: string; + title: string; + sortTitle: string; + fileName: string; + infoUrl: string; + downloadUrl?: string; + magnetUrl?: string; + indexerId: number; + indexer: string; + age: number; + ageHours: number; + ageMinutes: number; + publishDate: string; + size?: number; + files?: number; + grabs?: number; + seeders?: number; + leechers?: number; + indexerFlags: string[]; +} + +export default Release; diff --git a/frontend/src/typings/Settings/General.ts b/frontend/src/typings/Settings/General.ts new file mode 100644 index 000000000..c867bed74 --- /dev/null +++ b/frontend/src/typings/Settings/General.ts @@ -0,0 +1,45 @@ +export type UpdateMechanism = + | 'builtIn' + | 'script' + | 'external' + | 'apt' + | 'docker'; + +export default interface General { + bindAddress: string; + port: number; + sslPort: number; + enableSsl: boolean; + launchBrowser: boolean; + authenticationMethod: string; + authenticationRequired: string; + analyticsEnabled: boolean; + username: string; + password: string; + passwordConfirmation: string; + logLevel: string; + consoleLogLevel: string; + branch: string; + apiKey: string; + sslCertPath: string; + sslCertPassword: string; + urlBase: string; + instanceName: string; + applicationUrl: string; + updateAutomatically: boolean; + updateMechanism: UpdateMechanism; + updateScriptPath: string; + proxyEnabled: boolean; + proxyType: string; + proxyHostname: string; + proxyPort: number; + proxyUsername: string; + proxyPassword: string; + proxyBypassFilter: string; + proxyBypassLocalAddresses: boolean; + certificateValidation: string; + backupFolder: string; + backupInterval: number; + backupRetention: number; + id: number; +} diff --git a/frontend/src/typings/UiSettings.ts b/frontend/src/typings/Settings/UiSettings.ts similarity index 59% rename from frontend/src/typings/UiSettings.ts rename to frontend/src/typings/Settings/UiSettings.ts index 79cb0f333..656c4518b 100644 --- a/frontend/src/typings/UiSettings.ts +++ b/frontend/src/typings/Settings/UiSettings.ts @@ -1,4 +1,5 @@ -export interface UiSettings { +export default interface UiSettings { + theme: 'auto' | 'dark' | 'light'; showRelativeDates: boolean; shortDateFormat: string; longDateFormat: string; diff --git a/frontend/src/typings/SystemStatus.ts b/frontend/src/typings/SystemStatus.ts new file mode 100644 index 000000000..d5eab3ca3 --- /dev/null +++ b/frontend/src/typings/SystemStatus.ts @@ -0,0 +1,36 @@ +interface SystemStatus { + appData: string; + appName: string; + authentication: string; + branch: string; + buildTime: string; + databaseVersion: string; + databaseType: string; + instanceName: string; + isAdmin: boolean; + isDebug: boolean; + isDocker: boolean; + isLinux: boolean; + isNetCore: boolean; + isOsx: boolean; + isProduction: boolean; + isUserInteractive: boolean; + isWindows: boolean; + migrationVersion: number; + mode: string; + osName: string; + osVersion: string; + packageAuthor: string; + packageUpdateMechanism: string; + packageUpdateMechanismMessage: string; + packageVersion: string; + runtimeName: string; + runtimeVersion: string; + sqliteVersion: string; + startTime: string; + startupPath: string; + urlBase: string; + version: string; +} + +export default SystemStatus; diff --git a/frontend/src/typings/Table.ts b/frontend/src/typings/Table.ts new file mode 100644 index 000000000..4f99e2045 --- /dev/null +++ b/frontend/src/typings/Table.ts @@ -0,0 +1,6 @@ +import Column from 'Components/Table/Column'; + +export interface TableOptionsChangePayload { + pageSize?: number; + columns: Column[]; +} diff --git a/frontend/src/typings/Task.ts b/frontend/src/typings/Task.ts new file mode 100644 index 000000000..57895d73e --- /dev/null +++ b/frontend/src/typings/Task.ts @@ -0,0 +1,13 @@ +import ModelBase from 'App/ModelBase'; + +interface Task extends ModelBase { + name: string; + taskName: string; + interval: number; + lastExecution: string; + lastStartTime: string; + nextExecution: string; + lastDuration: string; +} + +export default Task; diff --git a/frontend/src/typings/Update.ts b/frontend/src/typings/Update.ts new file mode 100644 index 000000000..448b1728d --- /dev/null +++ b/frontend/src/typings/Update.ts @@ -0,0 +1,20 @@ +export interface Changes { + new: string[]; + fixed: string[]; +} + +interface Update { + version: string; + branch: string; + releaseDate: string; + fileName: string; + url: string; + installed: boolean; + installedOn: string; + installable: boolean; + latest: boolean; + changes: Changes | null; + hash: string; +} + +export default Update; diff --git a/frontend/src/typings/callbacks.ts b/frontend/src/typings/callbacks.ts new file mode 100644 index 000000000..0114efeb0 --- /dev/null +++ b/frontend/src/typings/callbacks.ts @@ -0,0 +1,6 @@ +import SortDirection from 'Helpers/Props/SortDirection'; + +export type SortCallback = ( + sortKey: string, + sortDirection: SortDirection +) => void; diff --git a/frontend/src/typings/inputs.ts b/frontend/src/typings/inputs.ts new file mode 100644 index 000000000..c0fda305c --- /dev/null +++ b/frontend/src/typings/inputs.ts @@ -0,0 +1,4 @@ +export type CheckInputChanged = { + name: string; + value: boolean; +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index dfddb15a3..611c872ed 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,13 +1,21 @@ { "compilerOptions": { - "target": "es6", + "target": "esnext", "allowJs": true, "checkJs": false, "baseUrl": "src", "jsx": "react", - "module": "commonjs", + "module": "esnext", "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "strict": true, "esModuleInterop": true, "typeRoots": ["node_modules/@types", "typings"], "paths": { diff --git a/package.json b/package.json index 74a1e4694..25960d641 100644 --- a/package.json +++ b/package.json @@ -11,47 +11,46 @@ "lint": "eslint --config frontend/.eslintrc.js --ignore-path frontend/.eslintignore frontend/", "lint-fix": "yarn lint --fix", "stylelint-linux": "stylelint $(find frontend -name '*.css') --config frontend/.stylelintrc", - "stylelint-windows": "stylelint frontend/**/*.css --config frontend/.stylelintrc", + "stylelint-windows": "stylelint \"frontend/**/*.css\" --config frontend/.stylelintrc", "check-modules": "are-you-es5 check . -r" }, "repository": "https://github.com/Prowlarr/Prowlarr", "author": "Team Prowlarr", "license": "GPL-3.0", "readmeFilename": "readme.md", - "main": "index.js", + "main": "index.ts", "browserslist": [ "defaults" ], "dependencies": { - "@fortawesome/fontawesome-free": "6.4.0", - "@fortawesome/fontawesome-svg-core": "6.4.0", - "@fortawesome/free-regular-svg-icons": "6.4.0", - "@fortawesome/free-solid-svg-icons": "6.4.0", - "@fortawesome/react-fontawesome": "0.2.0", + "@fortawesome/fontawesome-free": "6.7.1", + "@fortawesome/fontawesome-svg-core": "6.7.1", + "@fortawesome/free-regular-svg-icons": "6.7.1", + "@fortawesome/free-solid-svg-icons": "6.7.1", + "@fortawesome/react-fontawesome": "0.2.2", "@juggle/resize-observer": "3.4.0", - "@microsoft/signalr": "6.0.16", - "@sentry/browser": "7.51.2", - "@sentry/integrations": "7.51.2", - "@types/node": "18.15.11", - "@types/react": "18.2.6", - "@types/react-dom": "18.2.4", - "chart.js": "4.3.0", - "classnames": "2.3.2", - "clipboard": "2.0.11", + "@microsoft/signalr": "6.0.25", + "@sentry/browser": "7.119.1", + "@sentry/integrations": "7.119.1", + "@types/node": "20.16.11", + "@types/react": "18.2.79", + "@types/react-dom": "18.2.25", + "chart.js": "4.4.4", + "classnames": "2.5.1", "connected-react-router": "6.9.3", + "copy-to-clipboard": "3.3.3", "element-class": "0.2.2", - "filesize": "10.0.7", + "filesize": "10.1.6", "history": "4.10.1", - "https-browserify": "1.0.0", "jdu": "1.0.0", - "jquery": "3.7.0", + "jquery": "3.7.1", "lodash": "4.17.21", "mobile-detect": "1.4.5", - "moment": "2.29.4", + "moment": "2.30.1", "mousetrap": "1.6.5", "normalize.css": "8.0.1", "prop-types": "15.8.1", - "qs": "6.11.1", + "qs": "6.13.0", "react": "17.0.2", "react-addons-shallow-compare": "15.6.3", "react-async-script": "1.2.0", @@ -65,83 +64,81 @@ "react-dom": "17.0.2", "react-focus-lock": "2.9.4", "react-google-recaptcha": "2.1.0", - "react-lazyload": "3.2.0", "react-measure": "1.4.7", "react-popper": "1.3.7", "react-redux": "7.2.4", "react-router": "5.2.0", "react-router-dom": "5.2.0", + "react-tabs": "4.3.0", "react-text-truncate": "0.19.0", "react-use-measure": "2.1.1", "react-virtualized": "9.21.1", - "react-window": "1.8.8", + "react-window": "1.8.10", "redux": "4.2.1", "redux-actions": "2.6.5", "redux-batched-actions": "0.5.0", "redux-localstorage": "0.4.1", "redux-thunk": "2.4.2", - "reselect": "4.1.7", + "reselect": "4.1.8", "stacktrace-js": "2.0.2", - "typescript": "5.0.4" + "typescript": "5.7.2" }, "devDependencies": { - "@babel/core": "7.22.9", - "@babel/eslint-parser": "7.22.9", - "@babel/plugin-proposal-class-properties": "7.18.6", - "@babel/plugin-proposal-export-default-from": "7.22.5", - "@babel/plugin-proposal-export-namespace-from": "7.18.9", - "@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6", - "@babel/plugin-proposal-optional-chaining": "7.21.0", + "@babel/core": "7.26.0", + "@babel/eslint-parser": "7.25.9", + "@babel/plugin-proposal-export-default-from": "7.25.9", "@babel/plugin-syntax-dynamic-import": "7.8.3", - "@babel/preset-env": "7.22.9", - "@babel/preset-react": "7.22.5", - "@babel/preset-typescript": "7.22.5", - "@types/react-window": "1.8.5", - "@types/webpack-livereload-plugin": "2.3.3", - "@typescript-eslint/eslint-plugin": "5.59.5", - "@typescript-eslint/parser": "5.59.5", + "@babel/preset-env": "7.26.0", + "@babel/preset-react": "7.26.3", + "@babel/preset-typescript": "7.26.0", + "@types/lodash": "4.14.195", + "@types/react-document-title": "2.0.10", + "@types/react-router-dom": "5.3.3", + "@types/react-text-truncate": "0.19.0", + "@types/react-window": "1.8.8", + "@types/webpack-livereload-plugin": "2.3.6", + "@typescript-eslint/eslint-plugin": "8.18.1", + "@typescript-eslint/parser": "8.18.1", "are-you-es5": "2.1.2", - "autoprefixer": "10.4.14", - "babel-loader": "9.1.3", + "autoprefixer": "10.4.20", + "babel-loader": "9.2.1", "babel-plugin-inline-classnames": "2.0.1", "babel-plugin-transform-react-remove-prop-types": "0.4.24", - "core-js": "3.31.1", + "core-js": "3.39.0", "css-loader": "6.7.3", "css-modules-typescript-loader": "4.0.1", - "eslint": "8.45.0", - "eslint-config-prettier": "8.8.0", + "eslint": "8.57.1", + "eslint-config-prettier": "8.10.0", "eslint-plugin-filenames": "1.3.2", - "eslint-plugin-import": "2.27.5", + "eslint-plugin-import": "2.31.0", "eslint-plugin-prettier": "4.2.1", - "eslint-plugin-react": "7.32.2", - "eslint-plugin-react-hooks": "4.6.0", - "eslint-plugin-simple-import-sort": "10.0.0", + "eslint-plugin-react": "7.37.1", + "eslint-plugin-react-hooks": "4.6.2", + "eslint-plugin-simple-import-sort": "12.1.1", "file-loader": "6.2.0", "filemanager-webpack-plugin": "8.0.0", "fork-ts-checker-webpack-plugin": "8.0.0", - "html-webpack-plugin": "5.5.1", + "html-webpack-plugin": "5.6.0", "loader-utils": "^3.2.1", - "mini-css-extract-plugin": "2.7.5", - "postcss": "8.4.23", + "mini-css-extract-plugin": "2.9.1", + "postcss": "8.4.47", "postcss-color-function": "4.1.0", "postcss-loader": "7.3.0", "postcss-mixins": "9.0.4", - "postcss-nested": "6.0.1", + "postcss-nested": "6.2.0", "postcss-simple-vars": "7.0.1", "postcss-url": "10.1.3", "prettier": "2.8.8", "require-nocache": "1.0.0", - "rimraf": "4.4.1", - "run-sequence": "2.2.1", - "streamqueue": "1.1.2", + "rimraf": "6.0.1", "style-loader": "3.3.2", "stylelint": "15.6.1", - "stylelint-order": "6.0.3", - "terser-webpack-plugin": "5.3.9", - "ts-loader": "9.4.2", + "stylelint-order": "6.0.4", + "terser-webpack-plugin": "5.3.10", + "ts-loader": "9.5.1", "typescript-plugin-css-modules": "5.0.1", "url-loader": "4.1.1", - "webpack": "5.88.1", + "webpack": "5.95.0", "webpack-cli": "5.1.4", "webpack-livereload-plugin": "3.0.2" } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index a926a45d4..ce3672c38 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -99,20 +99,52 @@ $(MSBuildProjectName.Replace('Prowlarr','NzbDrone')) - - - false + + + + + + + + + + + + + + + + + + true + + + + true + + true - - + + + + + + + + false + + @@ -144,22 +176,52 @@ + + + + + x64 + + + + + x86 + + + + + arm64 + + + + + arm + + + + + + + + + <_UsingDefaultRuntimeIdentifier>true - win-x64 + win-$(Architecture) <_UsingDefaultRuntimeIdentifier>true - linux-x64 + linux-$(Architecture) <_UsingDefaultRuntimeIdentifier>true - osx-x64 + osx-$(Architecture) diff --git a/src/NuGet.config b/src/NuGet.config index 19fea7384..fcbd8bafb 100644 --- a/src/NuGet.config +++ b/src/NuGet.config @@ -7,5 +7,6 @@ + diff --git a/src/NzbDrone.Automation.Test/PageModel/PageBase.cs b/src/NzbDrone.Automation.Test/PageModel/PageBase.cs index a32084602..02078c47e 100644 --- a/src/NzbDrone.Automation.Test/PageModel/PageBase.cs +++ b/src/NzbDrone.Automation.Test/PageModel/PageBase.cs @@ -39,6 +39,10 @@ namespace NzbDrone.Automation.Test.PageModel var element = d.FindElement(By.ClassName("followingBalls")); return !element.Displayed; } + catch (StaleElementReferenceException) + { + return true; + } catch (NoSuchElementException) { return true; diff --git a/src/NzbDrone.Automation.Test/Prowlarr.Automation.Test.csproj b/src/NzbDrone.Automation.Test/Prowlarr.Automation.Test.csproj index bb0b5fcc4..78c8b7d0f 100644 --- a/src/NzbDrone.Automation.Test/Prowlarr.Automation.Test.csproj +++ b/src/NzbDrone.Automation.Test/Prowlarr.Automation.Test.csproj @@ -4,7 +4,7 @@ - + diff --git a/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs b/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs index dd32e187d..e9d4aa3b0 100644 --- a/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs +++ b/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs @@ -1,10 +1,12 @@ using System.Collections.Generic; using FluentAssertions; +using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Options; using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; using NzbDrone.Test.Common; @@ -43,6 +45,26 @@ namespace NzbDrone.Common.Test Mocker.GetMock() .Setup(v => v.WriteAllText(configFile, It.IsAny())) .Callback((p, t) => _configFileContents = t); + + Mocker.GetMock>() + .Setup(v => v.Value) + .Returns(new AuthOptions()); + + Mocker.GetMock>() + .Setup(v => v.Value) + .Returns(new AppOptions()); + + Mocker.GetMock>() + .Setup(v => v.Value) + .Returns(new ServerOptions()); + + Mocker.GetMock>() + .Setup(v => v.Value) + .Returns(new LogOptions()); + + Mocker.GetMock>() + .Setup(v => v.Value) + .Returns(new UpdateOptions()); } [Test] diff --git a/src/NzbDrone.Common.Test/DiskTests/DirectoryLookupServiceFixture.cs b/src/NzbDrone.Common.Test/DiskTests/DirectoryLookupServiceFixture.cs index 1dc4256ee..dd27f6f1b 100644 --- a/src/NzbDrone.Common.Test/DiskTests/DirectoryLookupServiceFixture.cs +++ b/src/NzbDrone.Common.Test/DiskTests/DirectoryLookupServiceFixture.cs @@ -49,7 +49,7 @@ namespace NzbDrone.Common.Test.DiskTests .Setup(s => s.GetDirectoryInfos(It.IsAny())) .Returns(_folders); - Subject.LookupContents(root, false, false).Directories.Should().NotContain(Path.Combine(root, RECYCLING_BIN)); + Subject.LookupContents(root, false, false).Directories.Should().NotContain(dir => dir.Path == Path.Combine(root, RECYCLING_BIN)); } [Test] @@ -62,7 +62,7 @@ namespace NzbDrone.Common.Test.DiskTests .Setup(s => s.GetDirectoryInfos(It.IsAny())) .Returns(_folders); - Subject.LookupContents(root, false, false).Directories.Should().NotContain(Path.Combine(root, SYSTEM_VOLUME_INFORMATION)); + Subject.LookupContents(root, false, false).Directories.Should().NotContain(dir => dir.Path == Path.Combine(root, SYSTEM_VOLUME_INFORMATION)); } [Test] diff --git a/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs b/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs index 4fca6ca40..fac3e20e7 100644 --- a/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs +++ b/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs @@ -564,7 +564,7 @@ namespace NzbDrone.Common.Test.DiskTests var count = Subject.MirrorFolder(source.FullName, destination.FullName); - count.Should().Equals(0); + count.Should().Be(0); destination.GetFileSystemInfos().Should().BeEmpty(); } @@ -584,7 +584,7 @@ namespace NzbDrone.Common.Test.DiskTests var count = Subject.MirrorFolder(source.FullName, destination.FullName); - count.Should().Equals(0); + count.Should().Be(0); destination.GetFileSystemInfos().Should().HaveCount(1); } @@ -601,7 +601,7 @@ namespace NzbDrone.Common.Test.DiskTests var count = Subject.MirrorFolder(source.FullName, destination.FullName); - count.Should().Equals(3); + count.Should().Be(3); VerifyCopyFolder(original.FullName, destination.FullName); } @@ -618,7 +618,7 @@ namespace NzbDrone.Common.Test.DiskTests var count = Subject.MirrorFolder(source.FullName, destination.FullName); - count.Should().Equals(3); + count.Should().Be(3); File.Exists(Path.Combine(destination.FullName, _nfsFile)).Should().BeFalse(); } @@ -638,7 +638,7 @@ namespace NzbDrone.Common.Test.DiskTests var count = Subject.MirrorFolder(source.FullName, destination.FullName); - count.Should().Equals(0); + count.Should().Be(0); VerifyCopyFolder(original.FullName, destination.FullName); } @@ -655,7 +655,7 @@ namespace NzbDrone.Common.Test.DiskTests var count = Subject.MirrorFolder(source.FullName + Path.DirectorySeparatorChar, destination.FullName); - count.Should().Equals(3); + count.Should().Be(3); VerifyCopyFolder(original.FullName, destination.FullName); } @@ -837,7 +837,7 @@ namespace NzbDrone.Common.Test.DiskTests // Note: never returns anything. Mocker.GetMock() - .Setup(v => v.GetFileInfos(It.IsAny(), SearchOption.TopDirectoryOnly)) + .Setup(v => v.GetFileInfos(It.IsAny(), false)) .Returns(new List()); Mocker.GetMock() @@ -875,8 +875,8 @@ namespace NzbDrone.Common.Test.DiskTests .Returns(v => new DirectoryInfo(v).GetDirectories().ToList()); Mocker.GetMock() - .Setup(v => v.GetFileInfos(It.IsAny(), SearchOption.TopDirectoryOnly)) - .Returns((v, _) => new DirectoryInfo(v).GetFiles().ToList()); + .Setup(v => v.GetFileInfos(It.IsAny(), false)) + .Returns((v, _) => new DirectoryInfo(v).GetFiles().ToList()); Mocker.GetMock() .Setup(v => v.GetFileSize(It.IsAny())) diff --git a/src/NzbDrone.Common.Test/ExtensionTests/IPAddressExtensionsFixture.cs b/src/NzbDrone.Common.Test/ExtensionTests/IPAddressExtensionsFixture.cs index ff5d7383e..0f7ad3004 100644 --- a/src/NzbDrone.Common.Test/ExtensionTests/IPAddressExtensionsFixture.cs +++ b/src/NzbDrone.Common.Test/ExtensionTests/IPAddressExtensionsFixture.cs @@ -21,9 +21,28 @@ namespace NzbDrone.Common.Test.ExtensionTests [TestCase("1.2.3.4")] [TestCase("172.55.0.1")] [TestCase("192.55.0.1")] + [TestCase("100.64.0.1")] + [TestCase("100.127.255.254")] public void should_return_false_for_public_ip_address(string ipAddress) { IPAddress.Parse(ipAddress).IsLocalAddress().Should().BeFalse(); } + + [TestCase("100.64.0.1")] + [TestCase("100.127.255.254")] + [TestCase("100.100.100.100")] + public void should_return_true_for_cgnat_ip_address(string ipAddress) + { + IPAddress.Parse(ipAddress).IsCgnatIpAddress().Should().BeTrue(); + } + + [TestCase("1.2.3.4")] + [TestCase("192.168.5.1")] + [TestCase("100.63.255.255")] + [TestCase("100.128.0.0")] + public void should_return_false_for_non_cgnat_ip_address(string ipAddress) + { + IPAddress.Parse(ipAddress).IsCgnatIpAddress().Should().BeFalse(); + } } } diff --git a/src/NzbDrone.Common.Test/ExtensionTests/Int64ExtensionFixture.cs b/src/NzbDrone.Common.Test/ExtensionTests/NumberExtensionFixture.cs similarity index 91% rename from src/NzbDrone.Common.Test/ExtensionTests/Int64ExtensionFixture.cs rename to src/NzbDrone.Common.Test/ExtensionTests/NumberExtensionFixture.cs index 76e28f3f7..c51ab7ad4 100644 --- a/src/NzbDrone.Common.Test/ExtensionTests/Int64ExtensionFixture.cs +++ b/src/NzbDrone.Common.Test/ExtensionTests/NumberExtensionFixture.cs @@ -1,11 +1,11 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; using NzbDrone.Common.Extensions; namespace NzbDrone.Common.Test.ExtensionTests { [TestFixture] - public class Int64ExtensionFixture + public class NumberExtensionFixture { [TestCase(0, "0 B")] [TestCase(1000, "1,000.0 B")] diff --git a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs index 4181057d6..43620edf4 100644 --- a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs +++ b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs @@ -128,6 +128,16 @@ namespace NzbDrone.Common.Test.Http response.Content.Should().NotBeNullOrWhiteSpace(); } + [Test] + public void should_throw_timeout_request() + { + var request = new HttpRequest($"https://{_httpBinHost}/delay/10"); + + request.RequestTimeout = new TimeSpan(0, 0, 5); + + Assert.ThrowsAsync(async () => await Subject.ExecuteAsync(request)); + } + [Test] public void should_execute_https_get() { diff --git a/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs b/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs index d7c3c9106..9e2b31d87 100644 --- a/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs +++ b/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs @@ -29,6 +29,8 @@ namespace NzbDrone.Common.Test.InstrumentationTests [TestCase(@"https://beyond-hd.me/torrent/download/the-next-365-days-2022-2160p-nf-web-dl-dual-ddp-51-dovi-hdr-hevc-apex.225146.2b51db35e1912ffc138825a12b9933d2")] [TestCase(@"https://anthelion.me/api.php?api_key=2b51db35e1910123321025a12b9933d2&o=json&t=movie&q=&tmdb=&imdb=&cat=&limit=100&offset=0")] [TestCase(@"https://avistaz.to/api/v1/jackett/auth: username=mySecret&password=mySecret&pid=mySecret")] + [TestCase(@"https://www.sharewood.tv/api/2b51db35e1910123321025a12b9933d2/last-torrents")] + [TestCase(@"https://example.org/rss/torrents?rsskey=2b51db35e1910123321025a12b9933d2&search=")] // Indexer and Download Client Responses @@ -43,6 +45,8 @@ namespace NzbDrone.Common.Test.InstrumentationTests // danish bytes response [TestCase(@",""rsskey"":""2b51db35e1910123321025a12b9933d2"",")] [TestCase(@",""passkey"":""2b51db35e1910123321025a12b9933d2"",")] + [TestCase(@"{""rsskey"":""2b51db35e1910123321025a12b9933d2""}")] + [TestCase(@"{""passkey"":""2b51db35e1910123321025a12b9933d2""}")] // nzbgeek & usenet response [TestCase(@"https://api.nzbgeek.info/api?t=details&id=2b51db35e1910123321025a12b9933d2&apikey=2b51db35e1910123321025a12b9933d2")] @@ -87,8 +91,16 @@ namespace NzbDrone.Common.Test.InstrumentationTests // Download Station [TestCase(@"webapi/entry.cgi?api=(removed)&version=2&method=login&account=01233210&passwd=mySecret&format=sid&session=DownloadStation")] - // Tracker Responses - [TestCase(@"tracker"":""http://xxx.yyy/announce.php?passkey=9pr04sg601233210imaveql2tyu8xyui"",""info"":""http://xxx.yyy/info?a=b""")] + // Announce URLs (passkeys) Magnet & Tracker + [TestCase(@"magnet_uri"":""magnet:?xt=urn:btih:9pr04sgkillroyimaveql2tyu8xyui&dn=&tr=https%3a%2f%2fxxx.yyy%2f9pr04sg601233210IMAveQL2tyu8xyui%2fannounce""}")] + [TestCase(@"magnet_uri"":""magnet:?xt=urn:btih:9pr04sgkillroyimaveql2tyu8xyui&dn=&tr=https%3a%2f%2fxxx.yyy%2ftracker.php%2f9pr04sg601233210IMAveQL2tyu8xyui%2fannounce""}")] + [TestCase(@"magnet_uri"":""magnet:?xt=urn:btih:9pr04sgkillroyimaveql2tyu8xyui&dn=&tr=https%3a%2f%2fxxx.yyy%2fannounce%2f9pr04sg601233210IMAveQL2tyu8xyui""}")] + [TestCase(@"magnet_uri"":""magnet:?xt=urn:btih:9pr04sgkillroyimaveql2tyu8xyui&dn=&tr=https%3a%2f%2fxxx.yyy%2fannounce.php%3fpasskey%3d9pr04sg601233210IMAveQL2tyu8xyui""}")] + [TestCase(@"tracker"":""https://xxx.yyy/9pr04sg601233210IMAveQL2tyu8xyui/announce""}")] + [TestCase(@"tracker"":""https://xxx.yyy/tracker.php/9pr04sg601233210IMAveQL2tyu8xyui/announce""}")] + [TestCase(@"tracker"":""https://xxx.yyy/announce/9pr04sg601233210IMAveQL2tyu8xyui""}")] + [TestCase(@"tracker"":""https://xxx.yyy/announce.php?passkey=9pr04sg601233210IMAveQL2tyu8xyui""}")] + [TestCase(@"tracker"":""http://xxx.yyy/announce.php?passkey=9pr04sg601233210IMAveQL2tyu8xyui"",""info"":""http://xxx.yyy/info?a=b""")] // BroadcastheNet [TestCase(@"method: ""getTorrents"", ""params"": [ ""mySecret"",")] @@ -102,10 +114,17 @@ namespace NzbDrone.Common.Test.InstrumentationTests // RSS [TestCase(@"")] + // Applications + [TestCase(@"""name"":""apiKey"",""value"":""mySecret""")] + // Internal [TestCase(@"[Info] MigrationController: *** Migrating Database=prowlarr-main;Host=postgres14;Username=mySecret;Password=mySecret;Port=5432;Enlist=False ***")] [TestCase("/readarr/signalr/messages/negotiate?access_token=1234530f422f4aacb6b301233210aaaa&negotiateVersion=1")] [TestCase(@"[Info] MigrationController: *** Migrating Database=prowlarr-main;Host=postgres14;Username=mySecret;Password=mySecret;Port=5432;token=mySecret;Enlist=False&username=mySecret;mypassword=mySecret;mypass=shouldkeep1;test_token=mySecret;password=123%@%_@!#^#@;use_password=mySecret;get_token=shouldkeep2;usetoken=shouldkeep3;passwrd=mySecret;")] + + // Discord + [TestCase(@"https://discord.com/api/webhooks/mySecret")] + [TestCase(@"https://discord.com/api/webhooks/mySecret/01233210")] public void should_clean_message(string message) { var cleansedMessage = CleanseLogMessage.Cleanse(message); diff --git a/src/NzbDrone.Common.Test/InstrumentationTests/SentryTargetFixture.cs b/src/NzbDrone.Common.Test/InstrumentationTests/SentryTargetFixture.cs index 7392c3b85..b60fe2e54 100644 --- a/src/NzbDrone.Common.Test/InstrumentationTests/SentryTargetFixture.cs +++ b/src/NzbDrone.Common.Test/InstrumentationTests/SentryTargetFixture.cs @@ -4,6 +4,7 @@ using System.Linq; using FluentAssertions; using NLog; using NUnit.Framework; +using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Instrumentation.Sentry; using NzbDrone.Test.Common; @@ -26,7 +27,7 @@ namespace NzbDrone.Common.Test.InstrumentationTests [SetUp] public void Setup() { - _subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111"); + _subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111", Mocker.GetMock().Object); } private LogEventInfo GivenLogEvent(LogLevel level, Exception ex, string message) diff --git a/src/NzbDrone.Common.Test/PathExtensionFixture.cs b/src/NzbDrone.Common.Test/PathExtensionFixture.cs index a33a53c01..010bc3a02 100644 --- a/src/NzbDrone.Common.Test/PathExtensionFixture.cs +++ b/src/NzbDrone.Common.Test/PathExtensionFixture.cs @@ -3,6 +3,7 @@ using System.IO; using FluentAssertions; using Moq; using NUnit.Framework; +using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Test.Common; @@ -34,7 +35,7 @@ namespace NzbDrone.Common.Test [TestCase(@"\\Testserver\\Test\", @"\\Testserver\Test")] [TestCase(@"\\Testserver\Test\file.ext", @"\\Testserver\Test\file.ext")] [TestCase(@"\\Testserver\Test\file.ext\\", @"\\Testserver\Test\file.ext")] - [TestCase(@"\\Testserver\Test\file.ext \\", @"\\Testserver\Test\file.ext")] + [TestCase(@"\\Testserver\Test\file.ext ", @"\\Testserver\Test\file.ext")] [TestCase(@"//CAPITAL//lower// ", @"\\CAPITAL\lower")] public void Clean_Path_Windows(string dirty, string clean) { @@ -132,11 +133,16 @@ namespace NzbDrone.Common.Test [TestCase(@"C:\test\", @"C:\Test\mydir")] [TestCase(@"C:\test", @"C:\Test\mydir\")] - public void path_should_be_parent_on_windows_only(string parentPath, string childPath) + public void windows_path_should_be_parent(string parentPath, string childPath) { - var expectedResult = OsInfo.IsWindows; + parentPath.IsParentPath(childPath).Should().Be(true); + } - parentPath.IsParentPath(childPath).Should().Be(expectedResult); + [TestCase("/test", "/test/mydir/")] + [TestCase("/test/", "/test/mydir")] + public void posix_path_should_be_parent(string parentPath, string childPath) + { + parentPath.IsParentPath(childPath).Should().Be(true); } [TestCase(@"C:\Test\mydir", @"C:\Test")] @@ -144,20 +150,57 @@ namespace NzbDrone.Common.Test [TestCase(@"C:\", null)] [TestCase(@"\\server\share", null)] [TestCase(@"\\server\share\test", @"\\server\share")] - public void path_should_return_parent_windows(string path, string parentPath) + public void windows_path_should_return_parent(string path, string parentPath) { - WindowsOnly(); path.GetParentPath().Should().Be(parentPath); } [TestCase(@"/", null)] [TestCase(@"/test", "/")] - public void path_should_return_parent_mono(string path, string parentPath) + [TestCase(@"/test/tv", "/test")] + public void unix_path_should_return_parent(string path, string parentPath) { - PosixOnly(); path.GetParentPath().Should().Be(parentPath); } + [TestCase(@"C:\Test\mydir", "Test")] + [TestCase(@"C:\Test\", @"C:\")] + [TestCase(@"C:\Test", @"C:\")] + [TestCase(@"C:\", null)] + [TestCase(@"\\server\share", null)] + [TestCase(@"\\server\share\test", @"\\server\share")] + public void path_should_return_parent_name_windows(string path, string parentPath) + { + path.GetParentName().Should().Be(parentPath); + } + + [TestCase(@"/", null)] + [TestCase(@"/test", "/")] + [TestCase(@"/test/tv", "test")] + public void path_should_return_parent_name_mono(string path, string parentPath) + { + path.GetParentName().Should().Be(parentPath); + } + + [TestCase(@"C:\Test\mydir", "mydir")] + [TestCase(@"C:\Test\", "Test")] + [TestCase(@"C:\Test", "Test")] + [TestCase(@"C:\", "C:\\")] + [TestCase(@"\\server\share", @"\\server\share")] + [TestCase(@"\\server\share\test", "test")] + public void path_should_return_directory_name_windows(string path, string parentPath) + { + path.GetDirectoryName().Should().Be(parentPath); + } + + [TestCase(@"/", "/")] + [TestCase(@"/test", "test")] + [TestCase(@"/test/tv", "tv")] + public void path_should_return_directory_name_mono(string path, string parentPath) + { + path.GetDirectoryName().Should().Be(parentPath); + } + [Test] public void path_should_return_parent_for_oversized_path() { @@ -165,7 +208,7 @@ namespace NzbDrone.Common.Test // This test will fail on Windows if long path support is not enabled: https://www.howtogeek.com/266621/how-to-make-windows-10-accept-file-paths-over-260-characters/ // It will also fail if the app isn't configured to use long path (such as resharper): https://blogs.msdn.microsoft.com/jeremykuhne/2016/07/30/net-4-6-2-and-long-paths-on-windows-10/ - var path = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\nzbdrone\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories".AsOsAgnostic(); + var path = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\nzbdrone\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories".AsOsAgnostic(); var parentPath = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\nzbdrone\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing".AsOsAgnostic(); path.GetParentPath().Should().Be(parentPath); @@ -315,5 +358,80 @@ namespace NzbDrone.Common.Test result[2].Should().Be(@"TV"); result[3].Should().Be(@"Series Title"); } + + [TestCase(@"C:\Test\")] + [TestCase(@"C:\Test")] + [TestCase(@"C:\Test\TV\")] + [TestCase(@"C:\Test\TV")] + public void IsPathValid_should_be_true(string path) + { + path.AsOsAgnostic().IsPathValid(PathValidationType.CurrentOs).Should().BeTrue(); + } + + [TestCase(@"C:\Test \")] + [TestCase(@"C:\Test ")] + [TestCase(@"C:\ Test\")] + [TestCase(@"C:\ Test")] + [TestCase(@"C:\Test \TV")] + [TestCase(@"C:\ Test\TV")] + [TestCase(@"C:\Test \TV\")] + [TestCase(@"C:\ Test\TV\")] + [TestCase(@" C:\Test\TV\")] + [TestCase(@" C:\Test\TV")] + + public void IsPathValid_should_be_false_on_windows(string path) + { + WindowsOnly(); + path.IsPathValid(PathValidationType.CurrentOs).Should().BeFalse(); + } + + [TestCase(@"")] + [TestCase(@"relative/path")] + public void IsPathValid_should_be_false_on_unix(string path) + { + PosixOnly(); + path.AsOsAgnostic().IsPathValid(PathValidationType.CurrentOs).Should().BeFalse(); + } + + [TestCase(@"C:\", @"C:\")] + [TestCase(@"C:\\", @"C:\")] + [TestCase(@"C:\Test", @"C:\Test")] + [TestCase(@"C:\Test\", @"C:\Test")] + [TestCase(@"\\server\share", @"\\server\share")] + [TestCase(@"\\server\share\", @"\\server\share")] + public void windows_path_should_return_clean_path(string path, string cleanPath) + { + path.GetCleanPath().Should().Be(cleanPath); + } + + [TestCase("/", "/")] + [TestCase("//", "/")] + [TestCase("/test", "/test")] + [TestCase("/test/", "/test")] + [TestCase("/test//", "/test")] + public void unix_path_should_return_clean_path(string path, string cleanPath) + { + path.GetCleanPath().Should().Be(cleanPath); + } + + [TestCase(@"C:\Test\", @"C:\Test\Series Title", "Series Title")] + [TestCase(@"C:\Test\", @"C:\Test\Collection\Series Title", @"Collection\Series Title")] + [TestCase(@"C:\Test\mydir\", @"C:\Test\mydir\Collection\Series Title", @"Collection\Series Title")] + [TestCase(@"\\server\share", @"\\server\share\Series Title", "Series Title")] + [TestCase(@"\\server\share\mydir\", @"\\server\share\mydir\/Collection\Series Title", @"Collection\Series Title")] + public void windows_path_should_return_relative_path(string parentPath, string childPath, string relativePath) + { + parentPath.GetRelativePath(childPath).Should().Be(relativePath); + } + + [TestCase(@"/test", "/test/Series Title", "Series Title")] + [TestCase(@"/test/", "/test/Collection/Series Title", "Collection/Series Title")] + [TestCase(@"/test/mydir", "/test/mydir/Series Title", "Series Title")] + [TestCase(@"/test/mydir/", "/test/mydir/Collection/Series Title", "Collection/Series Title")] + [TestCase(@"/test/mydir/", @"/test/mydir/\Collection/Series Title", "Collection/Series Title")] + public void unix_path_should_return_relative_path(string parentPath, string childPath, string relativePath) + { + parentPath.GetRelativePath(childPath).Should().Be(relativePath); + } } } diff --git a/src/NzbDrone.Common.Test/ServiceFactoryFixture.cs b/src/NzbDrone.Common.Test/ServiceFactoryFixture.cs index 9c348316f..237febe74 100644 --- a/src/NzbDrone.Common.Test/ServiceFactoryFixture.cs +++ b/src/NzbDrone.Common.Test/ServiceFactoryFixture.cs @@ -10,6 +10,7 @@ using NUnit.Framework; using NzbDrone.Common.Composition.Extensions; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Common.Options; using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore.Extensions; using NzbDrone.Core.Lifecycle; @@ -29,10 +30,16 @@ namespace NzbDrone.Common.Test .AddNzbDroneLogger() .AutoAddServices(Bootstrap.ASSEMBLIES) .AddDummyDatabase() + .AddDummyLogDatabase() .AddStartupContext(new StartupContext("first", "second")); container.RegisterInstance(new Mock().Object); container.RegisterInstance(new Mock>().Object); + container.RegisterInstance(new Mock>().Object); + container.RegisterInstance(new Mock>().Object); + container.RegisterInstance(new Mock>().Object); + container.RegisterInstance(new Mock>().Object); + container.RegisterInstance(new Mock>().Object); var serviceProvider = container.GetServiceProvider(); diff --git a/src/NzbDrone.Common/ArchiveService.cs b/src/NzbDrone.Common/ArchiveService.cs index 4cd6f6835..d420bbbc0 100644 --- a/src/NzbDrone.Common/ArchiveService.cs +++ b/src/NzbDrone.Common/ArchiveService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using ICSharpCode.SharpZipLib.Core; using ICSharpCode.SharpZipLib.GZip; @@ -11,7 +12,7 @@ namespace NzbDrone.Common public interface IArchiveService { void Extract(string compressedFile, string destination); - void CreateZip(string path, params string[] files); + void CreateZip(string path, IEnumerable files); } public class ArchiveService : IArchiveService @@ -39,19 +40,20 @@ namespace NzbDrone.Common _logger.Debug("Extraction complete."); } - public void CreateZip(string path, params string[] files) + public void CreateZip(string path, IEnumerable files) { - using (var zipFile = ZipFile.Create(path)) + _logger.Debug("Creating archive {0}", path); + + using var zipFile = ZipFile.Create(path); + + zipFile.BeginUpdate(); + + foreach (var file in files) { - zipFile.BeginUpdate(); - - foreach (var file in files) - { - zipFile.Add(file, Path.GetFileName(file)); - } - - zipFile.CommitUpdate(); + zipFile.Add(file, Path.GetFileName(file)); } + + zipFile.CommitUpdate(); } private void ExtractZip(string compressedFile, string destination) diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs index 521cb61df..621d4b258 100644 --- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs +++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs @@ -46,7 +46,7 @@ namespace NzbDrone.Common.Disk { CheckFolderExists(path); - var dirFiles = GetFiles(path, SearchOption.AllDirectories).ToList(); + var dirFiles = GetFiles(path, true).ToList(); if (!dirFiles.Any()) { @@ -149,25 +149,34 @@ namespace NzbDrone.Common.Disk return Directory.EnumerateFileSystemEntries(path).Empty(); } - public string[] GetDirectories(string path) + public IEnumerable GetDirectories(string path) { Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs); - return Directory.GetDirectories(path); + return Directory.EnumerateDirectories(path, "*", new EnumerationOptions + { + AttributesToSkip = FileAttributes.System, + IgnoreInaccessible = true + }); } - public string[] GetFiles(string path, SearchOption searchOption) + public IEnumerable GetFiles(string path, bool recursive) { Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs); - return Directory.GetFiles(path, "*.*", searchOption); + return Directory.EnumerateFiles(path, "*", new EnumerationOptions + { + AttributesToSkip = FileAttributes.System, + RecurseSubdirectories = recursive, + IgnoreInaccessible = true + }); } public long GetFolderSize(string path) { Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs); - return GetFiles(path, SearchOption.AllDirectories).Sum(e => new FileInfo(e).Length); + return GetFiles(path, true).Sum(e => new FileInfo(e).Length); } public long GetFileSize(string path) @@ -180,6 +189,25 @@ namespace NzbDrone.Common.Disk } var fi = new FileInfo(path); + + try + { + // If the file is a symlink, resolve the target path and get the size of the target file. + if (fi.Attributes.HasFlag(FileAttributes.ReparsePoint)) + { + var targetPath = fi.ResolveLinkTarget(true)?.FullName; + + if (targetPath != null) + { + fi = new FileInfo(targetPath); + } + } + } + catch (IOException ex) + { + Logger.Trace(ex, "Unable to resolve symlink target for {0}", path); + } + return fi.Length; } @@ -288,8 +316,9 @@ namespace NzbDrone.Common.Disk { Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs); - var files = Directory.GetFiles(path, "*.*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); - Array.ForEach(files, RemoveReadOnly); + var files = GetFiles(path, recursive); + + files.ToList().ForEach(RemoveReadOnly); Directory.Delete(path, recursive); } @@ -414,7 +443,7 @@ namespace NzbDrone.Common.Disk { Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs); - foreach (var file in GetFiles(path, SearchOption.TopDirectoryOnly)) + foreach (var file in GetFiles(path, false)) { DeleteFile(file); } @@ -515,13 +544,18 @@ namespace NzbDrone.Common.Disk return new FileInfo(path); } - public List GetFileInfos(string path, SearchOption searchOption = SearchOption.TopDirectoryOnly) + public List GetFileInfos(string path, bool recursive = false) { Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs); var di = new DirectoryInfo(path); - return di.GetFiles("*", searchOption).ToList(); + return di.EnumerateFiles("*", new EnumerationOptions + { + AttributesToSkip = FileAttributes.System, + RecurseSubdirectories = recursive, + IgnoreInaccessible = true + }).ToList(); } public void RemoveEmptySubfolders(string path) diff --git a/src/NzbDrone.Common/Disk/IDiskProvider.cs b/src/NzbDrone.Common/Disk/IDiskProvider.cs index 6c33692c3..46589411a 100644 --- a/src/NzbDrone.Common/Disk/IDiskProvider.cs +++ b/src/NzbDrone.Common/Disk/IDiskProvider.cs @@ -22,8 +22,8 @@ namespace NzbDrone.Common.Disk bool FileExists(string path, StringComparison stringComparison); bool FolderWritable(string path); bool FolderEmpty(string path); - string[] GetDirectories(string path); - string[] GetFiles(string path, SearchOption searchOption); + IEnumerable GetDirectories(string path); + IEnumerable GetFiles(string path, bool recursive); long GetFolderSize(string path); long GetFileSize(string path); void CreateFolder(string path); @@ -52,7 +52,7 @@ namespace NzbDrone.Common.Disk IMount GetMount(string path); List GetDirectoryInfos(string path); FileInfo GetFileInfo(string path); - List GetFileInfos(string path, SearchOption searchOption = SearchOption.TopDirectoryOnly); + List GetFileInfos(string path, bool recursive = false); void RemoveEmptySubfolders(string path); void SaveStream(Stream stream, string path); bool IsValidFolderPermissionMask(string mask); diff --git a/src/NzbDrone.Common/Disk/OsPath.cs b/src/NzbDrone.Common/Disk/OsPath.cs index f6f01fccf..30661d747 100644 --- a/src/NzbDrone.Common/Disk/OsPath.cs +++ b/src/NzbDrone.Common/Disk/OsPath.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Text.RegularExpressions; using NzbDrone.Common.Extensions; namespace NzbDrone.Common.Disk @@ -9,6 +10,8 @@ namespace NzbDrone.Common.Disk private readonly string _path; private readonly OsPathKind _kind; + private static readonly Regex UncPathRegex = new Regex(@"(?^\\\\(?:\?\\UNC\\)?[^\\]+\\[^\\]+)(?:\\|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public OsPath(string path) { if (path == null) @@ -96,6 +99,29 @@ namespace NzbDrone.Common.Disk return path; } + private static string TrimTrailingSlash(string path, OsPathKind kind) + { + switch (kind) + { + case OsPathKind.Windows when !path.EndsWith(":\\"): + while (!path.EndsWith(":\\") && path.EndsWith('\\')) + { + path = path[..^1]; + } + + return path; + case OsPathKind.Unix when path != "/": + while (path != "/" && path.EndsWith('/')) + { + path = path[..^1]; + } + + return path; + } + + return path; + } + public OsPathKind Kind => _kind; public bool IsWindowsPath => _kind == OsPathKind.Windows; @@ -130,7 +156,19 @@ namespace NzbDrone.Common.Disk if (index == -1) { - return new OsPath(null); + return Null; + } + + var rootLength = GetRootLength(); + + if (rootLength == _path.Length) + { + return Null; + } + + if (rootLength > index + 1) + { + return new OsPath(_path.Substring(0, rootLength)); } return new OsPath(_path.Substring(0, index), _kind).AsDirectory(); @@ -139,6 +177,8 @@ namespace NzbDrone.Common.Disk public string FullPath => _path; + public string PathWithoutTrailingSlash => TrimTrailingSlash(_path, _kind); + public string FileName { get @@ -161,6 +201,29 @@ namespace NzbDrone.Common.Disk } } + public string Name + { + // Meant to behave similar to DirectoryInfo.Name + get + { + var index = GetFileNameIndex(); + + if (index == -1) + { + return PathWithoutTrailingSlash; + } + + var rootLength = GetRootLength(); + + if (rootLength > index + 1) + { + return _path.Substring(0, rootLength); + } + + return TrimTrailingSlash(_path.Substring(index).TrimStart('/', '\\'), _kind); + } + } + public bool IsValid => _path.IsPathValid(PathValidationType.CurrentOs); private int GetFileNameIndex() @@ -190,11 +253,50 @@ namespace NzbDrone.Common.Disk return index; } + private int GetRootLength() + { + if (!IsRooted) + { + return 0; + } + + if (_kind == OsPathKind.Unix) + { + return 1; + } + + if (_kind == OsPathKind.Windows) + { + if (HasWindowsDriveLetter(_path)) + { + return 3; + } + + var uncMatch = UncPathRegex.Match(_path); + + // \\?\UNC\server\share\ or \\server\share + if (uncMatch.Success) + { + return uncMatch.Groups["unc"].Length; + } + + // \\?\C:\ + if (_path.StartsWith(@"\\?\")) + { + return 7; + } + } + + return 0; + } + private string[] GetFragments() { return _path.Split(new char[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries); } + public static OsPath Null => new (null); + public override string ToString() { return _path; @@ -267,6 +369,11 @@ namespace NzbDrone.Common.Disk } public bool Equals(OsPath other) + { + return Equals(other, false); + } + + public bool Equals(OsPath other, bool ignoreTrailingSlash) { if (ReferenceEquals(other, null)) { @@ -278,8 +385,8 @@ namespace NzbDrone.Common.Disk return true; } - var left = _path; - var right = other._path; + var left = ignoreTrailingSlash ? PathWithoutTrailingSlash : _path; + var right = ignoreTrailingSlash ? other.PathWithoutTrailingSlash : other._path; if (Kind == OsPathKind.Windows || other.Kind == OsPathKind.Windows) { diff --git a/src/NzbDrone.Common/EnvironmentInfo/AppFolderFactory.cs b/src/NzbDrone.Common/EnvironmentInfo/AppFolderFactory.cs index 78caf0b12..178ce7a0f 100644 --- a/src/NzbDrone.Common/EnvironmentInfo/AppFolderFactory.cs +++ b/src/NzbDrone.Common/EnvironmentInfo/AppFolderFactory.cs @@ -159,7 +159,7 @@ namespace NzbDrone.Common.EnvironmentInfo private void CleanupSqLiteRollbackFiles() { - _diskProvider.GetFiles(_appFolderInfo.AppDataFolder, SearchOption.TopDirectoryOnly) + _diskProvider.GetFiles(_appFolderInfo.AppDataFolder, false) .Where(f => Path.GetFileName(f).StartsWith("nzbdrone.db")) .ToList() .ForEach(_diskProvider.DeleteFile); diff --git a/src/NzbDrone.Common/EnvironmentInfo/OsInfo.cs b/src/NzbDrone.Common/EnvironmentInfo/OsInfo.cs index 23b3ab885..aece27859 100644 --- a/src/NzbDrone.Common/EnvironmentInfo/OsInfo.cs +++ b/src/NzbDrone.Common/EnvironmentInfo/OsInfo.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using NLog; @@ -25,22 +24,25 @@ namespace NzbDrone.Common.EnvironmentInfo static OsInfo() { - var platform = Environment.OSVersion.Platform; - - switch (platform) + if (OperatingSystem.IsWindows()) { - case PlatformID.Win32NT: - { - Os = Os.Windows; - break; - } - - case PlatformID.MacOSX: - case PlatformID.Unix: - { - Os = GetPosixFlavour(); - break; - } + Os = Os.Windows; + } + else if (OperatingSystem.IsMacOS()) + { + Os = Os.Osx; + } + else if (OperatingSystem.IsFreeBSD()) + { + Os = Os.Bsd; + } + else + { +#if ISMUSL + Os = Os.LinuxMusl; +#else + Os = Os.Linux; +#endif } } @@ -77,64 +79,13 @@ namespace NzbDrone.Common.EnvironmentInfo FullName = Name; } - if (IsLinux && File.Exists("/proc/1/cgroup") && File.ReadAllText("/proc/1/cgroup").Contains("/docker/")) + if (IsLinux && + (File.Exists("/.dockerenv") || + (File.Exists("/proc/1/cgroup") && File.ReadAllText("/proc/1/cgroup").Contains("/docker/")))) { IsDocker = true; } } - - private static Os GetPosixFlavour() - { - var output = RunAndCapture("uname", "-s"); - - if (output.StartsWith("Darwin")) - { - return Os.Osx; - } - else if (output.Contains("BSD")) - { - return Os.Bsd; - } - else - { -#if ISMUSL - return Os.LinuxMusl; -#else - return Os.Linux; -#endif - } - } - - private static string RunAndCapture(string filename, string args) - { - var processStartInfo = new ProcessStartInfo - { - FileName = filename, - Arguments = args, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true - }; - - var output = string.Empty; - - try - { - using (var p = Process.Start(processStartInfo)) - { - // To avoid deadlocks, always read the output stream first and then wait. - output = p.StandardOutput.ReadToEnd(); - - p.WaitForExit(1000); - } - } - catch (Exception) - { - output = string.Empty; - } - - return output; - } } public interface IOsInfo diff --git a/src/NzbDrone.Common/Extensions/EnumExtensions.cs b/src/NzbDrone.Common/Extensions/EnumExtensions.cs new file mode 100644 index 000000000..fcc550d99 --- /dev/null +++ b/src/NzbDrone.Common/Extensions/EnumExtensions.cs @@ -0,0 +1,17 @@ +using System; +using System.Linq; + +namespace NzbDrone.Common.Extensions +{ + public static class EnumExtensions + { + public static T GetAttribute(this Enum value) + where T : Attribute + { + var enumType = value.GetType(); + var name = Enum.GetName(enumType, value); + + return name == null ? null : enumType.GetField(name)?.GetCustomAttributes(false).OfType().SingleOrDefault(); + } + } +} diff --git a/src/NzbDrone.Common/Extensions/IpAddressExtensions.cs b/src/NzbDrone.Common/Extensions/IpAddressExtensions.cs index 7feb431c4..cbc1f5f83 100644 --- a/src/NzbDrone.Common/Extensions/IpAddressExtensions.cs +++ b/src/NzbDrone.Common/Extensions/IpAddressExtensions.cs @@ -39,18 +39,24 @@ namespace NzbDrone.Common.Extensions private static bool IsLocalIPv4(byte[] ipv4Bytes) { // Link local (no IP assigned by DHCP): 169.254.0.0 to 169.254.255.255 (169.254.0.0/16) - bool IsLinkLocal() => ipv4Bytes[0] == 169 && ipv4Bytes[1] == 254; + var isLinkLocal = ipv4Bytes[0] == 169 && ipv4Bytes[1] == 254; // Class A private range: 10.0.0.0 – 10.255.255.255 (10.0.0.0/8) - bool IsClassA() => ipv4Bytes[0] == 10; + var isClassA = ipv4Bytes[0] == 10; // Class B private range: 172.16.0.0 – 172.31.255.255 (172.16.0.0/12) - bool IsClassB() => ipv4Bytes[0] == 172 && ipv4Bytes[1] >= 16 && ipv4Bytes[1] <= 31; + var isClassB = ipv4Bytes[0] == 172 && ipv4Bytes[1] >= 16 && ipv4Bytes[1] <= 31; // Class C private range: 192.168.0.0 – 192.168.255.255 (192.168.0.0/16) - bool IsClassC() => ipv4Bytes[0] == 192 && ipv4Bytes[1] == 168; + var isClassC = ipv4Bytes[0] == 192 && ipv4Bytes[1] == 168; - return IsLinkLocal() || IsClassA() || IsClassC() || IsClassB(); + return isLinkLocal || isClassA || isClassC || isClassB; + } + + public static bool IsCgnatIpAddress(this IPAddress ipAddress) + { + var bytes = ipAddress.GetAddressBytes(); + return bytes.Length == 4 && bytes[0] == 100 && bytes[1] >= 64 && bytes[1] <= 127; } } } diff --git a/src/NzbDrone.Common/Extensions/Int64Extensions.cs b/src/NzbDrone.Common/Extensions/NumberExtensions.cs similarity index 53% rename from src/NzbDrone.Common/Extensions/Int64Extensions.cs rename to src/NzbDrone.Common/Extensions/NumberExtensions.cs index bfca7f66c..15037b20b 100644 --- a/src/NzbDrone.Common/Extensions/Int64Extensions.cs +++ b/src/NzbDrone.Common/Extensions/NumberExtensions.cs @@ -1,9 +1,9 @@ -using System; +using System; using System.Globalization; namespace NzbDrone.Common.Extensions { - public static class Int64Extensions + public static class NumberExtensions { private static readonly string[] SizeSuffixes = { "B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB" }; @@ -26,5 +26,25 @@ namespace NzbDrone.Common.Extensions return string.Format(CultureInfo.InvariantCulture, "{0:n1} {1}", adjustedSize, SizeSuffixes[mag]); } + + public static long Megabytes(this int megabytes) + { + return Convert.ToInt64(megabytes * 1024L * 1024L); + } + + public static long Gigabytes(this int gigabytes) + { + return Convert.ToInt64(gigabytes * 1024L * 1024L * 1024L); + } + + public static long Megabytes(this double megabytes) + { + return Convert.ToInt64(megabytes * 1024L * 1024L); + } + + public static long Gigabytes(this double gigabytes) + { + return Convert.ToInt64(gigabytes * 1024L * 1024L * 1024L); + } } } diff --git a/src/NzbDrone.Common/Extensions/PathExtensions.cs b/src/NzbDrone.Common/Extensions/PathExtensions.cs index f2d82f10e..30a467f21 100644 --- a/src/NzbDrone.Common/Extensions/PathExtensions.cs +++ b/src/NzbDrone.Common/Extensions/PathExtensions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text.RegularExpressions; using NzbDrone.Common.Disk; using NzbDrone.Common.EnsureThat; @@ -24,10 +25,14 @@ namespace NzbDrone.Common.Extensions private static readonly string UPDATE_CLIENT_FOLDER_NAME = "Prowlarr.Update" + Path.DirectorySeparatorChar; private static readonly string UPDATE_LOG_FOLDER_NAME = "UpdateLogs" + Path.DirectorySeparatorChar; - private static readonly Regex PARENT_PATH_END_SLASH_REGEX = new Regex(@"(? path).IsNotNullOrWhiteSpace(); Ensure.That(path, () => path).IsValidPath(PathValidationType.AnyOs); @@ -36,10 +41,10 @@ namespace NzbDrone.Common.Extensions // UNC if (!info.FullName.Contains('/') && info.FullName.StartsWith(@"\\")) { - return info.FullName.TrimEnd('/', '\\', ' '); + return info.FullName.TrimEnd('/', '\\'); } - return info.FullName.TrimEnd('/').Trim('\\', ' '); + return info.FullName.TrimEnd('/').Trim('\\'); } public static bool PathNotEquals(this string firstPath, string secondPath, StringComparison? comparison = null) @@ -80,55 +85,50 @@ namespace NzbDrone.Common.Extensions throw new NotParentException("{0} is not a child of {1}", childPath, parentPath); } - return childPath.Substring(parentPath.Length).Trim(Path.DirectorySeparatorChar); + return childPath.Substring(parentPath.Length).Trim('\\', '/'); } public static string GetParentPath(this string childPath) { - var cleanPath = OsInfo.IsWindows - ? PARENT_PATH_END_SLASH_REGEX.Replace(childPath, "") - : childPath.TrimEnd(Path.DirectorySeparatorChar); + var path = new OsPath(childPath).Directory; - if (cleanPath.IsNullOrWhiteSpace()) - { - return null; - } + return path == OsPath.Null ? null : path.PathWithoutTrailingSlash; + } - return Directory.GetParent(cleanPath)?.FullName; + public static string GetParentName(this string childPath) + { + var path = new OsPath(childPath).Directory; + + return path == OsPath.Null ? null : path.Name; + } + + public static string GetDirectoryName(this string childPath) + { + var path = new OsPath(childPath); + + return path == OsPath.Null ? null : path.Name; } public static string GetCleanPath(this string path) { - var cleanPath = OsInfo.IsWindows - ? PARENT_PATH_END_SLASH_REGEX.Replace(path, "") - : path.TrimEnd(Path.DirectorySeparatorChar); + var osPath = new OsPath(path); - return cleanPath; + return osPath == OsPath.Null ? null : osPath.PathWithoutTrailingSlash; } public static bool IsParentPath(this string parentPath, string childPath) { - if (parentPath != "/" && !parentPath.EndsWith(":\\")) - { - parentPath = parentPath.TrimEnd(Path.DirectorySeparatorChar); - } + var parent = new OsPath(parentPath); + var child = new OsPath(childPath); - if (childPath != "/" && !parentPath.EndsWith(":\\")) + while (child.Directory != OsPath.Null) { - childPath = childPath.TrimEnd(Path.DirectorySeparatorChar); - } - - var parent = new DirectoryInfo(parentPath); - var child = new DirectoryInfo(childPath); - - while (child.Parent != null) - { - if (child.Parent.FullName.Equals(parent.FullName, DiskProviderBase.PathStringComparison)) + if (child.Directory.Equals(parent, true)) { return true; } - child = child.Parent; + child = child.Directory; } return false; @@ -138,11 +138,32 @@ namespace NzbDrone.Common.Extensions public static bool IsPathValid(this string path, PathValidationType validationType) { - if (path.ContainsInvalidPathChars() || string.IsNullOrWhiteSpace(path)) + if (string.IsNullOrWhiteSpace(path) || path.ContainsInvalidPathChars()) { return false; } + // Only check for leading or trailing spaces for path when running on Windows. + if (OsInfo.IsWindows) + { + if (path.Trim() != path) + { + return false; + } + + var directoryInfo = new DirectoryInfo(path); + + while (directoryInfo != null) + { + if (directoryInfo.Name.Trim() != directoryInfo.Name) + { + return false; + } + + directoryInfo = directoryInfo.Parent; + } + } + if (validationType == PathValidationType.AnyOs) { return IsPathValidForWindows(path) || IsPathValidForNonWindows(path); @@ -158,6 +179,11 @@ namespace NzbDrone.Common.Extensions public static bool ContainsInvalidPathChars(this string text) { + if (text.IsNullOrWhiteSpace()) + { + throw new ArgumentNullException(nameof(text)); + } + return text.IndexOfAny(Path.GetInvalidPathChars()) >= 0; } @@ -248,6 +274,11 @@ namespace NzbDrone.Common.Extensions return processName; } + public static string CleanPath(this string path) + { + return Path.Join(path.Split(Path.DirectorySeparatorChar).Select(s => s.Trim()).ToArray()); + } + public static string GetAppDataPath(this IAppFolderInfo appFolderInfo) { return appFolderInfo.AppDataFolder; diff --git a/src/NzbDrone.Common/Extensions/UrlExtensions.cs b/src/NzbDrone.Common/Extensions/UrlExtensions.cs index d71cfec15..fbe1832a8 100644 --- a/src/NzbDrone.Common/Extensions/UrlExtensions.cs +++ b/src/NzbDrone.Common/Extensions/UrlExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Web; namespace NzbDrone.Common.Extensions { @@ -18,5 +19,24 @@ namespace NzbDrone.Common.Extensions return Uri.TryCreate(path, UriKind.Absolute, out var uri) && uri.IsWellFormedOriginalString(); } + + public static Uri RemoveQueryParam(this Uri url, string name) + { + var uriBuilder = new UriBuilder(url); + var query = HttpUtility.ParseQueryString(uriBuilder.Query); + + query.Remove(name); + uriBuilder.Query = query.ToString() ?? string.Empty; + + return uriBuilder.Uri; + } + + public static string GetQueryParam(this Uri url, string name) + { + var uriBuilder = new UriBuilder(url); + var query = HttpUtility.ParseQueryString(uriBuilder.Query); + + return query[name]; + } } } diff --git a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs index 2401efaef..29bb49fa5 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs @@ -1,13 +1,16 @@ using System; using System.Diagnostics; using System.IO; +using System.Linq; using System.Net; using System.Net.Http; +using System.Net.NetworkInformation; using System.Net.Security; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; +using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http.Proxy; @@ -29,11 +32,14 @@ namespace NzbDrone.Common.Http.Dispatchers private readonly ICached _httpClientCache; private readonly ICached _credentialCache; + private readonly Logger _logger; + public ManagedHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider, ICreateManagedWebProxy createManagedWebProxy, ICertificateValidationService certificateValidationService, IUserAgentBuilder userAgentBuilder, - ICacheManager cacheManager) + ICacheManager cacheManager, + Logger logger) { _proxySettingsProvider = proxySettingsProvider; _createManagedWebProxy = createManagedWebProxy; @@ -42,6 +48,8 @@ namespace NzbDrone.Common.Http.Dispatchers _httpClientCache = cacheManager.GetCache(typeof(ManagedHttpDispatcher)); _credentialCache = cacheManager.GetCache(typeof(ManagedHttpDispatcher), "credentialcache"); + + _logger = logger; } public async Task GetResponseAsync(HttpRequest request, CookieContainer cookies) @@ -107,52 +115,59 @@ namespace NzbDrone.Common.Http.Dispatchers sw.Start(); - using var responseMessage = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cts.Token); + try { - byte[] data = null; - - try + using var responseMessage = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cts.Token); { - if (request.ResponseStream != null && responseMessage.StatusCode == HttpStatusCode.OK) + byte[] data = null; + + try { - await responseMessage.Content.CopyToAsync(request.ResponseStream, null, cts.Token); - } - else - { - data = responseMessage.Content.ReadAsByteArrayAsync(cts.Token).GetAwaiter().GetResult(); - } - } - catch (Exception ex) - { - throw new WebException("Failed to read complete http response", ex, WebExceptionStatus.ReceiveFailure, null); - } - - var headers = responseMessage.Headers.ToNameValueCollection(); - - headers.Add(responseMessage.Content.Headers.ToNameValueCollection()); - - var responseCookies = new CookieContainer(); - - if (responseMessage.Headers.TryGetValues("Set-Cookie", out var cookieHeaders)) - { - foreach (var responseCookieHeader in cookieHeaders) - { - try + if (request.ResponseStream != null && responseMessage.StatusCode == HttpStatusCode.OK) { - cookies.SetCookies(responseMessage.RequestMessage.RequestUri, responseCookieHeader); + await responseMessage.Content.CopyToAsync(request.ResponseStream, null, cts.Token); } - catch + else { - // Ignore invalid cookies + data = await responseMessage.Content.ReadAsByteArrayAsync(cts.Token); } } + catch (Exception ex) + { + throw new WebException("Failed to read complete http response", ex, WebExceptionStatus.ReceiveFailure, null); + } + + var headers = responseMessage.Headers.ToNameValueCollection(); + + headers.Add(responseMessage.Content.Headers.ToNameValueCollection()); + + var responseCookies = new CookieContainer(); + + if (responseMessage.Headers.TryGetValues("Set-Cookie", out var cookieHeaders)) + { + foreach (var responseCookieHeader in cookieHeaders) + { + try + { + cookies.SetCookies(responseMessage.RequestMessage.RequestUri, responseCookieHeader); + } + catch + { + // Ignore invalid cookies + } + } + } + + var cookieCollection = cookies.GetCookies(responseMessage.RequestMessage.RequestUri); + + sw.Stop(); + + return new HttpResponse(request, new HttpHeader(headers), cookieCollection, data, sw.ElapsedMilliseconds, responseMessage.StatusCode, responseMessage.Version); } - - var cookieCollection = cookies.GetCookies(responseMessage.RequestMessage.RequestUri); - - sw.Stop(); - - return new HttpResponse(request, new HttpHeader(headers), cookieCollection, data, sw.ElapsedMilliseconds, responseMessage.StatusCode, responseMessage.Version); + } + catch (OperationCanceledException ex) when (cts.IsCancellationRequested) + { + throw new WebException("Http request timed out", ex.InnerException, WebExceptionStatus.Timeout, null); } } @@ -268,7 +283,27 @@ namespace NzbDrone.Common.Http.Dispatchers return _credentialCache.Get("credentialCache", () => new CredentialCache()); } - private static async ValueTask onConnect(SocketsHttpConnectionContext context, CancellationToken cancellationToken) + private bool HasRoutableIPv4Address() + { + // Get all IPv4 addresses from all interfaces and return true if there are any with non-loopback addresses + try + { + var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces(); + + return networkInterfaces.Any(ni => + ni.OperationalStatus == OperationalStatus.Up && + ni.GetIPProperties().UnicastAddresses.Any(ip => + ip.Address.AddressFamily == AddressFamily.InterNetwork && + !IPAddress.IsLoopback(ip.Address))); + } + catch (Exception e) + { + _logger.Debug(e, "Caught exception while GetAllNetworkInterfaces assuming IPv4 connectivity: {0}", e.Message); + return true; + } + } + + private async ValueTask onConnect(SocketsHttpConnectionContext context, CancellationToken cancellationToken) { // Until .NET supports an implementation of Happy Eyeballs (https://tools.ietf.org/html/rfc8305#section-2), let's make IPv4 fallback work in a simple way. // This issue is being tracked at https://github.com/dotnet/runtime/issues/26177 and expected to be fixed in .NET 6. @@ -291,10 +326,10 @@ namespace NzbDrone.Common.Http.Dispatchers } catch { - // very naively fallback to ipv4 permanently for this execution based on the response of the first connection attempt. - // note that this may cause users to eventually get switched to ipv4 (on a random failure when they are switching networks, for instance) - // but in the interest of keeping this implementation simple, this is acceptable. - useIPv6 = false; + // Do not retry IPv6 if a routable IPv4 address is available, otherwise continue to attempt IPv6 connections. + var routableIPv4 = HasRoutableIPv4Address(); + _logger.Info("IPv4 is available: {0}, IPv6 will be {1}", routableIPv4, routableIPv4 ? "disabled" : "left enabled"); + useIPv6 = !routableIPv4; } finally { diff --git a/src/NzbDrone.Common/Http/HttpClient.cs b/src/NzbDrone.Common/Http/HttpClient.cs index a1e6f0243..326c30b04 100644 --- a/src/NzbDrone.Common/Http/HttpClient.cs +++ b/src/NzbDrone.Common/Http/HttpClient.cs @@ -109,7 +109,7 @@ namespace NzbDrone.Common.Http if (response.HasHttpRedirect && !RuntimeInfo.IsProduction) { - _logger.Error("Server requested a redirect to [{0}] while in developer mode. Update the request URL to avoid this redirect.", response.Headers["Location"]); + _logger.Error("Server requested a redirect to [{0}] while in developer mode. Update the request URL to avoid this redirect.", response.RedirectUrl); } if (!request.SuppressHttpError && response.HasHttpError && (request.SuppressHttpErrorStatusCodes == null || !request.SuppressHttpErrorStatusCodes.Contains(response.StatusCode))) @@ -221,11 +221,18 @@ namespace NzbDrone.Common.Http }; } - sourceContainer.Add((Uri)request.Url, cookie); - - if (request.StoreRequestCookie) + try { - presistentContainer.Add((Uri)request.Url, cookie); + sourceContainer.Add((Uri)request.Url, cookie); + + if (request.StoreRequestCookie) + { + presistentContainer.Add((Uri)request.Url, cookie); + } + } + catch (CookieException ex) + { + _logger.Debug(ex, "Invalid cookie in {0}", (Uri)request.Url); } } } @@ -260,7 +267,14 @@ namespace NzbDrone.Common.Http }; } - sourceContainer.Add((Uri)request.Url, cookie); + try + { + sourceContainer.Add((Uri)request.Url, cookie); + } + catch (CookieException ex) + { + _logger.Debug(ex, "Invalid cookie in {0}", (Uri)request.Url); + } } } diff --git a/src/NzbDrone.Common/Http/Proxy/HttpProxySettings.cs b/src/NzbDrone.Common/Http/Proxy/HttpProxySettings.cs index 022d8adee..c80044d29 100644 --- a/src/NzbDrone.Common/Http/Proxy/HttpProxySettings.cs +++ b/src/NzbDrone.Common/Http/Proxy/HttpProxySettings.cs @@ -30,7 +30,8 @@ namespace NzbDrone.Common.Http.Proxy { if (!string.IsNullOrWhiteSpace(BypassFilter)) { - var hostlist = BypassFilter.Split(','); + var hostlist = BypassFilter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + for (var i = 0; i < hostlist.Length; i++) { if (hostlist[i].StartsWith("*")) diff --git a/src/NzbDrone.Common/Http/XmlRpcRequestBuilder.cs b/src/NzbDrone.Common/Http/XmlRpcRequestBuilder.cs index e03161702..e7ab0126d 100644 --- a/src/NzbDrone.Common/Http/XmlRpcRequestBuilder.cs +++ b/src/NzbDrone.Common/Http/XmlRpcRequestBuilder.cs @@ -92,6 +92,10 @@ namespace NzbDrone.Common.Http { data = new XElement("base64", Convert.ToBase64String(bytes)); } + else if (value is Dictionary d) + { + data = new XElement("struct", d.Select(p => new XElement("member", new XElement("name", p.Key), new XElement("value", p.Value)))); + } else { throw new InvalidOperationException($"Unhandled argument type {value.GetType().Name}"); diff --git a/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs b/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs index 13d7e935d..393d6613a 100644 --- a/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs +++ b/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs @@ -10,7 +10,7 @@ namespace NzbDrone.Common.Instrumentation private static readonly Regex[] CleansingRules = { // Url - new (@"(?<=[?&: ;])(apikey|api_key|(?:(?:access|api)[-_]?)?token|pass(?:key|wd)?|auth|authkey|user|u?id|api|[a-z_]*apikey|account|pid|pwd)=(?[^&=""]+?)(?=[ ""&=]|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new (@"(?<=[?&: ;])(apikey|api_key|(?:(?:access|api)[-_]?)?token|pass(?:key|wd)?|auth|authkey|rsskey|user|u?id|api|[a-z_]*apikey|account|pid|pwd)=(?[^&=""]+?)(?=[ ""&=]|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), new (@"(?<=[?& ;])[^=]*?(_?(?[^&=]+?)(?= |&|$|;)", RegexOptions.Compiled | RegexOptions.IgnoreCase), new (@"rss(24h)?\.torrentleech\.org/(?!rss)(?[0-9a-z]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase), new (@"torrentleech\.org/rss/download/[0-9]+/(?[0-9a-z]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase), @@ -21,6 +21,7 @@ namespace NzbDrone.Common.Instrumentation new (@"(?<=authkey = "")(?[^&=]+?)(?="")", RegexOptions.Compiled | RegexOptions.IgnoreCase), new (@"(?<=beyond-hd\.[a-z]+/api/torrents/)(?[^&=][a-z0-9]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase), new (@"(?<=beyond-hd\.[a-z]+/torrent/download/[\w\d-]+[.]\d+[.])(?[a-z0-9]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new (@"(?:sharewood)\.[a-z]{2,3}/api/(?[a-z0-9]{16,})/", RegexOptions.Compiled | RegexOptions.IgnoreCase), // UNIT3D new (@"(?<=[a-z0-9-]+\.[a-z]+/torrent/download/\d+\.)(?[^&=][a-z0-9]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase), @@ -30,7 +31,7 @@ namespace NzbDrone.Common.Instrumentation new (@"""/(home|Users)/(?[^/""]+?)(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Trackers Announce Keys; Designed for Qbit Json; should work for all in theory - new (@"announce(\.php)?(/|%2f|%3fpasskey%3d)(?[a-z0-9]{16,})|(?[a-z0-9]{16,})(/|%2f)announce"), + new (@"announce(\.php)?(/|%2f|%3fpasskey%3d)(?[a-z0-9]{16,})|(?[a-z0-9]{16,})(/|%2f)announce", RegexOptions.Compiled | RegexOptions.IgnoreCase), // NzbGet new (@"""Name""\s*:\s*""[^""]*(username|password)""\s*,\s*""Value""\s*:\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), @@ -57,10 +58,13 @@ namespace NzbDrone.Common.Instrumentation // Indexer Responses new (@"(?:avistaz|exoticaz|cinemaz|privatehd)\.[a-z]{2,3}/rss/download/(?[^&=]+?)/(?[^&=]+?)\.torrent", RegexOptions.Compiled | RegexOptions.IgnoreCase), new (@"(?:animebytes)\.[a-z]{2,3}/torrent/[0-9]+/download/(?[^&=]+?)[""]", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new (@",""info_hash"":""(?[^&=]+?)"",", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new (@"""token"":""(?[^&=]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new (@",""pass[- _]?key"":""(?[^&=]+?)"",", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new (@",""rss[- _]?key"":""(?[^&=]+?)"",", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new (@"""(info_hash|token|((pass|rss)[- _]?key))"":""(?[^&=]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), + + // Applications + new (@"""name"":""apikey"",""value"":""(?[^&=]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), + + // Discord + new (@"discord.com/api/webhooks/((?[\w-]+)/)?(?[\w-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase) }; private static readonly Regex CleanseRemoteIPRegex = new (@"(?:Auth-\w+(? - { - c.ForLogger("System.*").WriteToNil(LogLevel.Warn); - c.ForLogger("Microsoft.*").WriteToNil(LogLevel.Warn); - c.ForLogger("Microsoft.Hosting.Lifetime*").WriteToNil(LogLevel.Info); - c.ForLogger("Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware").WriteToNil(LogLevel.Fatal); - }); - } - private static void RegisterConsole() { var level = LogLevel.Trace; @@ -122,7 +115,12 @@ namespace NzbDrone.Common.Instrumentation var coloredConsoleTarget = new ColoredConsoleTarget(); coloredConsoleTarget.Name = "consoleLogger"; - coloredConsoleTarget.Layout = "[${level}] ${logger}: ${message} ${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}${exception:format=Data}${newline}}"; + + var logFormat = Enum.TryParse(Environment.GetEnvironmentVariable("PROWLARR__LOG__CONSOLEFORMAT"), out var formatEnumValue) + ? formatEnumValue + : ConsoleLogFormat.Standard; + + ConfigureConsoleLayout(coloredConsoleTarget, logFormat); var loggingRule = new LoggingRule("*", level, coloredConsoleTarget); @@ -139,7 +137,7 @@ namespace NzbDrone.Common.Instrumentation private static void RegisterAppFile(IAppFolderInfo appFolderInfo, string name, string fileName, int maxArchiveFiles, LogLevel minLogLevel) { - var fileTarget = new NzbDroneFileTarget(); + var fileTarget = new CleansingFileTarget(); fileTarget.Name = name; fileTarget.FileName = Path.Combine(appFolderInfo.GetLogFolder(), fileName); @@ -148,11 +146,11 @@ namespace NzbDrone.Common.Instrumentation fileTarget.ConcurrentWrites = false; fileTarget.ConcurrentWriteAttemptDelay = 50; fileTarget.ConcurrentWriteAttempts = 10; - fileTarget.ArchiveAboveSize = 1024000; + fileTarget.ArchiveAboveSize = 1.Megabytes(); fileTarget.MaxArchiveFiles = maxArchiveFiles; fileTarget.EnableFileDelete = true; fileTarget.ArchiveNumbering = ArchiveNumberingMode.Rolling; - fileTarget.Layout = FILE_LOG_LAYOUT; + fileTarget.Layout = FileLogLayout; var loggingRule = new LoggingRule("*", minLogLevel, fileTarget); @@ -171,7 +169,7 @@ namespace NzbDrone.Common.Instrumentation fileTarget.ConcurrentWrites = false; fileTarget.ConcurrentWriteAttemptDelay = 50; fileTarget.ConcurrentWriteAttempts = 100; - fileTarget.Layout = FILE_LOG_LAYOUT; + fileTarget.Layout = FileLogLayout; var loggingRule = new LoggingRule("*", LogLevel.Trace, fileTarget); @@ -196,6 +194,17 @@ namespace NzbDrone.Common.Instrumentation LogManager.Configuration.LoggingRules.Insert(0, rule); } + private static void RegisterGlobalFilters() + { + LogManager.Setup().LoadConfiguration(c => + { + c.ForLogger("System.*").WriteToNil(LogLevel.Warn); + c.ForLogger("Microsoft.*").WriteToNil(LogLevel.Warn); + c.ForLogger("Microsoft.Hosting.Lifetime*").WriteToNil(LogLevel.Info); + c.ForLogger("Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware").WriteToNil(LogLevel.Fatal); + }); + } + public static Logger GetLogger(Type obj) { return LogManager.GetLogger(obj.Name.Replace("NzbDrone.", "")); @@ -205,5 +214,20 @@ namespace NzbDrone.Common.Instrumentation { return GetLogger(obj.GetType()); } + + public static void ConfigureConsoleLayout(ColoredConsoleTarget target, ConsoleLogFormat format) + { + target.Layout = format switch + { + ConsoleLogFormat.Clef => NzbDroneLogger.ClefLogLayout, + _ => NzbDroneLogger.CleansingConsoleLayout + }; + } + } + + public enum ConsoleLogFormat + { + Standard, + Clef } } diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs index 164c14a7d..3a4737f74 100644 --- a/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs +++ b/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs @@ -50,7 +50,13 @@ namespace NzbDrone.Common.Instrumentation.Sentry "UnauthorizedAccessException", // Filter out people stuck in boot loops - "CorruptDatabaseException" + "CorruptDatabaseException", + + // Filter SingleInstance Termination Exceptions + "TerminateApplicationException", + + // User config issue, root folder missing, etc. + "DirectoryNotFoundException" }; public static readonly List FilteredExceptionMessages = new List @@ -100,7 +106,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry public bool FilterEvents { get; set; } public bool SentryEnabled { get; set; } - public SentryTarget(string dsn) + public SentryTarget(string dsn, IAppFolderInfo appFolderInfo) { _sdk = SentrySdk.Init(o => { @@ -108,9 +114,33 @@ namespace NzbDrone.Common.Instrumentation.Sentry o.AttachStacktrace = true; o.MaxBreadcrumbs = 200; o.Release = $"{BuildInfo.AppName}@{BuildInfo.Release}"; - o.BeforeSend = x => SentryCleanser.CleanseEvent(x); - o.BeforeBreadcrumb = x => SentryCleanser.CleanseBreadcrumb(x); + o.SetBeforeSend(x => SentryCleanser.CleanseEvent(x)); + o.SetBeforeBreadcrumb(x => SentryCleanser.CleanseBreadcrumb(x)); o.Environment = BuildInfo.Branch; + + // Crash free run statistics (sends a ping for healthy and for crashes sessions) + o.AutoSessionTracking = false; + + // Caches files in the event device is offline + // Sentry creates a 'sentry' sub directory, no need to concat here + o.CacheDirectoryPath = appFolderInfo.GetAppDataPath(); + + // default environment is production + if (!RuntimeInfo.IsProduction) + { + if (RuntimeInfo.IsDevelopment) + { + o.Environment = "development"; + } + else if (RuntimeInfo.IsTesting) + { + o.Environment = "testing"; + } + else + { + o.Environment = "other"; + } + } }); InitializeScope(); @@ -118,7 +148,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry _debounce = new SentryDebounce(); // initialize to true and reconfigure later - // Otherwise it will default to false and any errors occuring + // Otherwise it will default to false and any errors occurring // before config file gets read will not be filtered FilterEvents = true; SentryEnabled = true; @@ -128,7 +158,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry { SentrySdk.ConfigureScope(scope => { - scope.User = new User + scope.User = new SentryUser { Id = HashUtil.AnonymousToken() }; @@ -177,9 +207,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry private void OnError(Exception ex) { - var webException = ex as WebException; - - if (webException != null) + if (ex is WebException webException) { var response = webException.Response as HttpWebResponse; var statusCode = response?.StatusCode; @@ -311,13 +339,21 @@ namespace NzbDrone.Common.Instrumentation.Sentry } } + var level = LoggingLevelMap[logEvent.Level]; var sentryEvent = new SentryEvent(logEvent.Exception) { - Level = LoggingLevelMap[logEvent.Level], + Level = level, Logger = logEvent.LoggerName, Message = logEvent.FormattedMessage }; + if (level is SentryLevel.Fatal && logEvent.Exception is not null) + { + // Usages of 'fatal' here indicates the process will crash. In Sentry this is represented with + // the 'unhandled' exception flag + logEvent.Exception.SetSentryMechanism("Logger.Fatal", "Logger.Fatal was called", false); + } + sentryEvent.SetExtras(extras); sentryEvent.SetFingerprint(fingerPrint); diff --git a/src/NzbDrone.Common/Options/AppOptions.cs b/src/NzbDrone.Common/Options/AppOptions.cs new file mode 100644 index 000000000..74cdf1d29 --- /dev/null +++ b/src/NzbDrone.Common/Options/AppOptions.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Common.Options; + +public class AppOptions +{ + public string InstanceName { get; set; } + public string Theme { get; set; } + public bool? LaunchBrowser { get; set; } +} diff --git a/src/NzbDrone.Common/Options/AuthOptions.cs b/src/NzbDrone.Common/Options/AuthOptions.cs new file mode 100644 index 000000000..64330b68b --- /dev/null +++ b/src/NzbDrone.Common/Options/AuthOptions.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Common.Options; + +public class AuthOptions +{ + public string ApiKey { get; set; } + public bool? Enabled { get; set; } + public string Method { get; set; } + public string Required { get; set; } + public bool? TrustCgnatIpAddresses { get; set; } +} diff --git a/src/NzbDrone.Common/Options/LogOptions.cs b/src/NzbDrone.Common/Options/LogOptions.cs new file mode 100644 index 000000000..6460eeaa6 --- /dev/null +++ b/src/NzbDrone.Common/Options/LogOptions.cs @@ -0,0 +1,17 @@ +namespace NzbDrone.Common.Options; + +public class LogOptions +{ + public string Level { get; set; } + public bool? FilterSentryEvents { get; set; } + public int? Rotate { get; set; } + public int? SizeLimit { get; set; } + public bool? Sql { get; set; } + public string ConsoleLevel { get; set; } + public string ConsoleFormat { get; set; } + public bool? AnalyticsEnabled { get; set; } + public string SyslogServer { get; set; } + public int? SyslogPort { get; set; } + public string SyslogLevel { get; set; } + public bool? DbEnabled { get; set; } +} diff --git a/src/NzbDrone.Common/Options/ServerOptions.cs b/src/NzbDrone.Common/Options/ServerOptions.cs new file mode 100644 index 000000000..d21e12b2a --- /dev/null +++ b/src/NzbDrone.Common/Options/ServerOptions.cs @@ -0,0 +1,12 @@ +namespace NzbDrone.Common.Options; + +public class ServerOptions +{ + public string UrlBase { get; set; } + public string BindAddress { get; set; } + public int? Port { get; set; } + public bool? EnableSsl { get; set; } + public int? SslPort { get; set; } + public string SslCertPath { get; set; } + public string SslCertPassword { get; set; } +} diff --git a/src/NzbDrone.Common/Options/UpdateOptions.cs b/src/NzbDrone.Common/Options/UpdateOptions.cs new file mode 100644 index 000000000..a8eaad8fb --- /dev/null +++ b/src/NzbDrone.Common/Options/UpdateOptions.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Common.Options; + +public class UpdateOptions +{ + public string Mechanism { get; set; } + public bool? Automatically { get; set; } + public string ScriptPath { get; set; } + public string Branch { get; set; } +} diff --git a/src/NzbDrone.Common/Processes/ProcessProvider.cs b/src/NzbDrone.Common/Processes/ProcessProvider.cs index 5db0565e0..c68207a09 100644 --- a/src/NzbDrone.Common/Processes/ProcessProvider.cs +++ b/src/NzbDrone.Common/Processes/ProcessProvider.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Linq; +using System.Text; using NLog; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Model; @@ -117,7 +118,9 @@ namespace NzbDrone.Common.Processes UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true, - RedirectStandardInput = true + RedirectStandardInput = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 }; if (environmentVariables != null) @@ -313,7 +316,7 @@ namespace NzbDrone.Common.Processes processInfo = new ProcessInfo(); processInfo.Id = process.Id; processInfo.Name = process.ProcessName; - processInfo.StartPath = process.MainModule.FileName; + processInfo.StartPath = process.MainModule?.FileName; if (process.Id != GetCurrentProcessId() && process.HasExited) { diff --git a/src/NzbDrone.Common/Prowlarr.Common.csproj b/src/NzbDrone.Common/Prowlarr.Common.csproj index 4cbb90d63..106890399 100644 --- a/src/NzbDrone.Common/Prowlarr.Common.csproj +++ b/src/NzbDrone.Common/Prowlarr.Common.csproj @@ -4,22 +4,25 @@ ISMUSL - - - + + + + - - - - + + + + + - + + - + - + diff --git a/src/NzbDrone.Common/Serializer/System.Text.Json/BooleanConverter.cs b/src/NzbDrone.Common/Serializer/System.Text.Json/BooleanConverter.cs new file mode 100644 index 000000000..dc7af179b --- /dev/null +++ b/src/NzbDrone.Common/Serializer/System.Text.Json/BooleanConverter.cs @@ -0,0 +1,29 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace NzbDrone.Common.Serializer; + +public class BooleanConverter : JsonConverter +{ + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.Number => reader.GetInt64() switch + { + 1 => true, + 0 => false, + _ => throw new JsonException() + }, + _ => throw new JsonException() + }; + } + + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + { + writer.WriteBooleanValue(value); + } +} diff --git a/src/NzbDrone.Common/Serializer/System.Text.Json/STJson.cs b/src/NzbDrone.Common/Serializer/System.Text.Json/STJson.cs index 4fba4fae1..e799b678b 100644 --- a/src/NzbDrone.Common/Serializer/System.Text.Json/STJson.cs +++ b/src/NzbDrone.Common/Serializer/System.Text.Json/STJson.cs @@ -36,6 +36,7 @@ namespace NzbDrone.Common.Serializer serializerSettings.Converters.Add(new STJTimeSpanConverter()); serializerSettings.Converters.Add(new STJUtcConverter()); serializerSettings.Converters.Add(new DictionaryStringObjectConverter()); + serializerSettings.Converters.Add(new BooleanConverter()); } public static T Deserialize(string json) diff --git a/src/NzbDrone.Common/TPL/DebounceManager.cs b/src/NzbDrone.Common/TPL/DebounceManager.cs new file mode 100644 index 000000000..60803a3a9 --- /dev/null +++ b/src/NzbDrone.Common/TPL/DebounceManager.cs @@ -0,0 +1,17 @@ +using System; + +namespace NzbDrone.Common.TPL +{ + public interface IDebounceManager + { + Debouncer CreateDebouncer(Action action, TimeSpan debounceDuration); + } + + public class DebounceManager : IDebounceManager + { + public Debouncer CreateDebouncer(Action action, TimeSpan debounceDuration) + { + return new Debouncer(action, debounceDuration); + } + } +} diff --git a/src/NzbDrone.Common/TPL/Debouncer.cs b/src/NzbDrone.Common/TPL/Debouncer.cs index 0fa101525..7f8435961 100644 --- a/src/NzbDrone.Common/TPL/Debouncer.cs +++ b/src/NzbDrone.Common/TPL/Debouncer.cs @@ -4,11 +4,11 @@ namespace NzbDrone.Common.TPL { public class Debouncer { - private readonly Action _action; - private readonly System.Timers.Timer _timer; + protected readonly Action _action; + protected readonly System.Timers.Timer _timer; - private volatile int _paused; - private volatile bool _triggered; + protected volatile int _paused; + protected volatile bool _triggered; public Debouncer(Action action, TimeSpan debounceDuration) { @@ -27,7 +27,7 @@ namespace NzbDrone.Common.TPL } } - public void Execute() + public virtual void Execute() { lock (_timer) { @@ -39,7 +39,7 @@ namespace NzbDrone.Common.TPL } } - public void Pause() + public virtual void Pause() { lock (_timer) { @@ -48,7 +48,7 @@ namespace NzbDrone.Common.TPL } } - public void Resume() + public virtual void Resume() { lock (_timer) { diff --git a/src/NzbDrone.Core.Test/Datastore/BasicRepositoryFixture.cs b/src/NzbDrone.Core.Test/Datastore/BasicRepositoryFixture.cs index 8070f9fda..fac7e7f00 100644 --- a/src/NzbDrone.Core.Test/Datastore/BasicRepositoryFixture.cs +++ b/src/NzbDrone.Core.Test/Datastore/BasicRepositoryFixture.cs @@ -13,6 +13,7 @@ namespace NzbDrone.Core.Test.Datastore [TestFixture] public class BasicRepositoryFixture : DbTest, ScheduledTask> { + private readonly TimeSpan _dateTimePrecision = TimeSpan.FromMilliseconds(20); private List _basicList; [SetUp] @@ -20,7 +21,7 @@ namespace NzbDrone.Core.Test.Datastore { AssertionOptions.AssertEquivalencyUsing(options => { - options.Using(ctx => ctx.Subject.Should().BeCloseTo(ctx.Expectation.ToUniversalTime())).WhenTypeIs(); + options.Using(ctx => ctx.Subject.Should().BeCloseTo(ctx.Expectation.ToUniversalTime(), _dateTimePrecision)).WhenTypeIs(); return options; }); diff --git a/src/NzbDrone.Core.Test/Datastore/Converters/TimeSpanConverterFixture.cs b/src/NzbDrone.Core.Test/Datastore/Converters/TimeSpanConverterFixture.cs new file mode 100644 index 000000000..79d0adaee --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Converters/TimeSpanConverterFixture.cs @@ -0,0 +1,43 @@ +using System; +using System.Data.SQLite; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Datastore.Converters; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Converters; + +[TestFixture] +public class TimeSpanConverterFixture : CoreTest +{ + private SQLiteParameter _param; + + [SetUp] + public void Setup() + { + _param = new SQLiteParameter(); + } + + [Test] + public void should_return_string_when_saving_timespan_to_db() + { + var span = TimeSpan.FromMilliseconds(10); + + Subject.SetValue(_param, span); + _param.Value.Should().Be(span.ToString()); + } + + [Test] + public void should_return_timespan_when_getting_string_from_db() + { + var span = TimeSpan.FromMilliseconds(10); + + Subject.Parse(span.ToString()).Should().Be(span); + } + + [Test] + public void should_return_zero_timespan_for_db_null_value_when_getting_from_db() + { + Subject.Parse(null).Should().Be(TimeSpan.Zero); + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/DatabaseFixture.cs b/src/NzbDrone.Core.Test/Datastore/DatabaseFixture.cs index 985fbb7d5..7f8a157b0 100644 --- a/src/NzbDrone.Core.Test/Datastore/DatabaseFixture.cs +++ b/src/NzbDrone.Core.Test/Datastore/DatabaseFixture.cs @@ -27,6 +27,20 @@ namespace NzbDrone.Core.Test.Datastore Mocker.Resolve().Vacuum(); } + [Test] + public void postgres_should_not_contain_timestamp_without_timezone_columns() + { + if (Db.DatabaseType != DatabaseType.PostgreSQL) + { + return; + } + + Mocker.Resolve() + .OpenConnection().Query("SELECT table_name, column_name, data_type FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = 'public' AND data_type = 'timestamp without time zone'") + .Should() + .BeNullOrEmpty(); + } + [Test] public void get_version() { diff --git a/src/NzbDrone.Core.Test/Datastore/DatabaseVersionParserFixture.cs b/src/NzbDrone.Core.Test/Datastore/DatabaseVersionParserFixture.cs new file mode 100644 index 000000000..05bf04fea --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/DatabaseVersionParserFixture.cs @@ -0,0 +1,38 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Test.Datastore; + +[TestFixture] +public class DatabaseVersionParserFixture +{ + [TestCase("3.44.2", 3, 44, 2)] + public void should_parse_sqlite_database_version(string serverVersion, int majorVersion, int minorVersion, int buildVersion) + { + var version = DatabaseVersionParser.ParseServerVersion(serverVersion); + + version.Should().NotBeNull(); + version.Major.Should().Be(majorVersion); + version.Minor.Should().Be(minorVersion); + version.Build.Should().Be(buildVersion); + } + + [TestCase("14.8 (Debian 14.8-1.pgdg110+1)", 14, 8, null)] + [TestCase("16.3 (Debian 16.3-1.pgdg110+1)", 16, 3, null)] + [TestCase("16.3 - Percona Distribution", 16, 3, null)] + [TestCase("17.0 - Percona Server", 17, 0, null)] + public void should_parse_postgres_database_version(string serverVersion, int majorVersion, int minorVersion, int? buildVersion) + { + var version = DatabaseVersionParser.ParseServerVersion(serverVersion); + + version.Should().NotBeNull(); + version.Major.Should().Be(majorVersion); + version.Minor.Should().Be(minorVersion); + + if (buildVersion.HasValue) + { + version.Build.Should().Be(buildVersion.Value); + } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/039_email_encryptionFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/039_email_encryptionFixture.cs new file mode 100644 index 000000000..513095162 --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/039_email_encryptionFixture.cs @@ -0,0 +1,151 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration; +using NzbDrone.Core.Notifications.Email; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Migration +{ + [TestFixture] + public class email_encryptionFixture : MigrationTest + { + [Test] + public void should_convert_do_not_require_encryption_to_auto() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Notifications").Row(new + { + OnGrab = true, + OnHealthIssue = true, + IncludeHealthWarnings = true, + Name = "Mail Prowlarr", + Implementation = "Email", + Tags = "[]", + Settings = new EmailSettings38 + { + Server = "smtp.gmail.com", + Port = 563, + To = new List { "dont@email.me" }, + RequireEncryption = false + }.ToJson(), + ConfigContract = "EmailSettings" + }); + }); + + var items = db.Query("SELECT * FROM \"Notifications\""); + + items.Should().HaveCount(1); + items.First().Implementation.Should().Be("Email"); + items.First().ConfigContract.Should().Be("EmailSettings"); + items.First().Settings.UseEncryption.Should().Be((int)EmailEncryptionType.Preferred); + } + + [Test] + public void should_convert_require_encryption_to_always() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Notifications").Row(new + { + OnGrab = true, + OnHealthIssue = true, + IncludeHealthWarnings = true, + Name = "Mail Prowlarr", + Implementation = "Email", + Tags = "[]", + Settings = new EmailSettings38 + { + Server = "smtp.gmail.com", + Port = 563, + To = new List { "dont@email.me" }, + RequireEncryption = true + }.ToJson(), + ConfigContract = "EmailSettings" + }); + }); + + var items = db.Query("SELECT * FROM \"Notifications\""); + + items.Should().HaveCount(1); + items.First().Implementation.Should().Be("Email"); + items.First().ConfigContract.Should().Be("EmailSettings"); + items.First().Settings.UseEncryption.Should().Be((int)EmailEncryptionType.Always); + } + + [Test] + public void should_use_defaults_when_settings_are_empty() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Notifications").Row(new + { + OnGrab = true, + OnHealthIssue = true, + IncludeHealthWarnings = true, + Name = "Mail Prowlarr", + Implementation = "Email", + Tags = "[]", + Settings = new { }.ToJson(), + ConfigContract = "EmailSettings" + }); + }); + + var items = db.Query("SELECT * FROM \"Notifications\""); + + items.Should().HaveCount(1); + items.First().Implementation.Should().Be("Email"); + items.First().ConfigContract.Should().Be("EmailSettings"); + items.First().Settings.UseEncryption.Should().Be((int)EmailEncryptionType.Preferred); + } + } + + public class NotificationDefinition39 + { + public int Id { get; set; } + public string Implementation { get; set; } + public string ConfigContract { get; set; } + public EmailSettings39 Settings { get; set; } + public string Name { get; set; } + public bool OnGrab { get; set; } + public bool OnHealthIssue { get; set; } + public bool OnHealthRestored { get; set; } + public bool OnApplicationUpdate { get; set; } + public bool SupportsOnGrab { get; set; } + public bool IncludeManualGrabs { get; set; } + public bool SupportsOnHealthIssue { get; set; } + public bool SupportsOnHealthRestored { get; set; } + public bool IncludeHealthWarnings { get; set; } + public bool SupportsOnApplicationUpdate { get; set; } + public List Tags { get; set; } + } + + public class EmailSettings38 + { + public string Server { get; set; } + public int Port { get; set; } + public bool RequireEncryption { get; set; } + public string Username { get; set; } + public string Password { get; set; } + public string From { get; set; } + public IEnumerable To { get; set; } + public IEnumerable Cc { get; set; } + public IEnumerable Bcc { get; set; } + } + + public class EmailSettings39 + { + public string Server { get; set; } + public int Port { get; set; } + public int UseEncryption { get; set; } + public string Username { get; set; } + public string Password { get; set; } + public string From { get; set; } + public IEnumerable To { get; set; } + public IEnumerable Cc { get; set; } + public IEnumerable Bcc { get; set; } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/040_newznab_category_to_capabilities_settingsFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/040_newznab_category_to_capabilities_settingsFixture.cs new file mode 100644 index 000000000..b51bf6433 --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/040_newznab_category_to_capabilities_settingsFixture.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Migration +{ + [TestFixture] + public class newznab_category_to_capabilities_settingsFixture : MigrationTest + { + [Test] + public void should_migrate_categories_when_capabilities_is_not_defined() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Indexers").Row(new + { + Name = "Usenet Indexer", + Redirect = false, + AppProfileId = 0, + DownloadClientId = 0, + Priority = 25, + Added = DateTime.UtcNow, + Implementation = "Newznab", + Settings = new + { + Categories = new[] + { + new { Id = 2000, Name = "Movies" }, + new { Id = 5000, Name = "TV" } + } + }.ToJson(), + ConfigContract = "NewznabSettings" + }); + }); + + var items = db.Query("SELECT \"Id\", \"Implementation\", \"ConfigContract\", \"Settings\" FROM \"Indexers\""); + + items.Should().HaveCount(1); + items.First().Implementation.Should().Be("Newznab"); + items.First().ConfigContract.Should().Be("NewznabSettings"); + items.First().Settings.Should().ContainKey("capabilities"); + items.First().Settings.Should().NotContainKey("categories"); + + var newznabSettings = items.First().Settings.ToObject(); + newznabSettings.Capabilities.Should().NotBeNull(); + newznabSettings.Capabilities.SupportsRawSearch.Should().Be(false); + newznabSettings.Capabilities.Categories.Should().HaveCount(2); + newznabSettings.Capabilities.Categories.Should().Contain(c => c.Id == 2000 && c.Name == "Movies"); + newznabSettings.Capabilities.Categories.Should().Contain(c => c.Id == 5000 && c.Name == "TV"); + } + + [Test] + public void should_migrate_categories_when_capabilities_is_defined() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Indexers").Row(new + { + Name = "Usenet Indexer", + Redirect = false, + AppProfileId = 0, + DownloadClientId = 0, + Priority = 25, + Added = DateTime.UtcNow, + Implementation = "Newznab", + Settings = new + { + Capabilities = new + { + SupportsRawSearch = true + }, + Categories = new[] + { + new { Id = 2000, Name = "Movies" }, + new { Id = 5000, Name = "TV" } + } + }.ToJson(), + ConfigContract = "NewznabSettings" + }); + }); + + var items = db.Query("SELECT \"Id\", \"Implementation\", \"ConfigContract\", \"Settings\" FROM \"Indexers\""); + + items.Should().HaveCount(1); + items.First().Implementation.Should().Be("Newznab"); + items.First().ConfigContract.Should().Be("NewznabSettings"); + items.First().Settings.Should().ContainKey("capabilities"); + items.First().Settings.Should().NotContainKey("categories"); + + var newznabSettings = items.First().Settings.ToObject(); + newznabSettings.Capabilities.Should().NotBeNull(); + newznabSettings.Capabilities.SupportsRawSearch.Should().Be(true); + newznabSettings.Capabilities.Categories.Should().HaveCount(2); + newznabSettings.Capabilities.Categories.Should().Contain(c => c.Id == 2000 && c.Name == "Movies"); + newznabSettings.Capabilities.Categories.Should().Contain(c => c.Id == 5000 && c.Name == "TV"); + } + + [Test] + public void should_use_defaults_when_categories_are_empty() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Indexers").Row(new + { + Name = "Usenet Indexer", + Redirect = false, + AppProfileId = 0, + DownloadClientId = 0, + Priority = 25, + Added = DateTime.UtcNow, + Implementation = "Newznab", + Settings = new + { + Categories = Array.Empty() + }.ToJson(), + ConfigContract = "NewznabSettings" + }); + }); + + var items = db.Query("SELECT \"Id\", \"Implementation\", \"ConfigContract\", \"Settings\" FROM \"Indexers\""); + + items.Should().HaveCount(1); + items.First().Implementation.Should().Be("Newznab"); + items.First().ConfigContract.Should().Be("NewznabSettings"); + items.First().Settings.Should().ContainKey("capabilities"); + items.First().Settings.Should().NotContainKey("categories"); + + var newznabSettings = items.First().Settings.ToObject(); + newznabSettings.Capabilities.Should().NotBeNull(); + newznabSettings.Capabilities.SupportsRawSearch.Should().Be(false); + newznabSettings.Capabilities.Categories.Should().NotBeNull(); + newznabSettings.Capabilities.Categories.Should().HaveCount(0); + } + + [Test] + public void should_use_defaults_when_settings_are_empty() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Indexers").Row(new + { + Name = "Usenet Indexer", + Redirect = false, + AppProfileId = 0, + DownloadClientId = 0, + Priority = 25, + Added = DateTime.UtcNow, + Implementation = "Newznab", + Settings = new { }.ToJson(), + ConfigContract = "NewznabSettings" + }); + }); + + var items = db.Query("SELECT \"Id\", \"Implementation\", \"ConfigContract\", \"Settings\" FROM \"Indexers\""); + + items.Should().HaveCount(1); + items.First().Implementation.Should().Be("Newznab"); + items.First().ConfigContract.Should().Be("NewznabSettings"); + items.First().Settings.Should().NotContainKey("capabilities"); + items.First().Settings.Should().NotContainKey("categories"); + items.First().Settings.ToObject().Capabilities.Should().BeNull(); + } + } + + public class IndexerDefinition40 + { + public int Id { get; set; } + public string Implementation { get; set; } + public string ConfigContract { get; set; } + public JObject Settings { get; set; } + } + + public class NewznabSettings39 + { + public object Categories { get; set; } + } + + public class NewznabSettings40 + { + public NewznabCapabilitiesSettings40 Capabilities { get; set; } + } + + public class NewznabCapabilitiesSettings40 + { + public bool SupportsRawSearch { get; set; } + public List Categories { get; set; } + } + + public class IndexerCategory40 + { + public int Id { get; set; } + public string Name { get; set; } + } +} diff --git a/src/NzbDrone.Core.Test/Framework/CoreTest.cs b/src/NzbDrone.Core.Test/Framework/CoreTest.cs index 6dbd46c31..3e6a8f66e 100644 --- a/src/NzbDrone.Core.Test/Framework/CoreTest.cs +++ b/src/NzbDrone.Core.Test/Framework/CoreTest.cs @@ -25,7 +25,7 @@ namespace NzbDrone.Core.Test.Framework Mocker.SetConstant(new HttpProxySettingsProvider(Mocker.Resolve())); Mocker.SetConstant(new ManagedWebProxyFactory(Mocker.Resolve())); Mocker.SetConstant(new X509CertificateValidationService(Mocker.Resolve(), TestLogger)); - Mocker.SetConstant(new ManagedHttpDispatcher(Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve())); + Mocker.SetConstant(new ManagedHttpDispatcher(Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), TestLogger)); Mocker.SetConstant(new HttpClient(Array.Empty(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), TestLogger)); Mocker.SetConstant(new ProwlarrCloudRequestBuilder()); } diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/NotificationStatusCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/NotificationStatusCheckFixture.cs new file mode 100644 index 000000000..67b79ae0b --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/NotificationStatusCheckFixture.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.HealthCheck.Checks; +using NzbDrone.Core.Localization; +using NzbDrone.Core.Notifications; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.HealthCheck.Checks +{ + [TestFixture] + public class NotificationStatusCheckFixture : CoreTest + { + private List _notifications = new List(); + private List _blockedNotifications = new List(); + + [SetUp] + public void SetUp() + { + Mocker.GetMock() + .Setup(v => v.GetAvailableProviders()) + .Returns(_notifications); + + Mocker.GetMock() + .Setup(v => v.GetBlockedProviders()) + .Returns(_blockedNotifications); + + Mocker.GetMock() + .Setup(s => s.GetLocalizedString(It.IsAny())) + .Returns("Some Warning Message"); + } + + private Mock GivenNotification(int id, double backoffHours, double failureHours) + { + var mockNotification = new Mock(); + mockNotification.SetupGet(s => s.Definition).Returns(new NotificationDefinition { Id = id }); + + _notifications.Add(mockNotification.Object); + + if (backoffHours != 0.0) + { + _blockedNotifications.Add(new NotificationStatus + { + ProviderId = id, + InitialFailure = DateTime.UtcNow.AddHours(-failureHours), + MostRecentFailure = DateTime.UtcNow.AddHours(-0.1), + EscalationLevel = 5, + DisabledTill = DateTime.UtcNow.AddHours(backoffHours) + }); + } + + return mockNotification; + } + + [Test] + public void should_not_return_error_when_no_notifications() + { + Subject.Check().ShouldBeOk(); + } + + [Test] + public void should_return_warning_if_notification_unavailable() + { + GivenNotification(1, 10.0, 24.0); + GivenNotification(2, 0.0, 0.0); + + Subject.Check().ShouldBeWarning(); + } + + [Test] + public void should_return_error_if_all_notifications_unavailable() + { + GivenNotification(1, 10.0, 24.0); + + Subject.Check().ShouldBeError(); + } + + [Test] + public void should_return_warning_if_few_notifications_unavailable() + { + GivenNotification(1, 10.0, 24.0); + GivenNotification(2, 10.0, 24.0); + GivenNotification(3, 0.0, 0.0); + + Subject.Check().ShouldBeWarning(); + } + } +} diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/UpdateCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/UpdateCheckFixture.cs index 64eeb9169..7d859eb9d 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/UpdateCheckFixture.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/UpdateCheckFixture.cs @@ -7,6 +7,7 @@ using NzbDrone.Core.HealthCheck.Checks; using NzbDrone.Core.Localization; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Update; +using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.HealthCheck.Checks { @@ -21,28 +22,10 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks .Returns("Some Warning Message"); } - [Test] - public void should_return_error_when_app_folder_is_write_protected() - { - WindowsOnly(); - - Mocker.GetMock() - .Setup(s => s.StartUpFolder) - .Returns(@"C:\NzbDrone"); - - Mocker.GetMock() - .Setup(c => c.FolderWritable(It.IsAny())) - .Returns(false); - - Subject.Check().ShouldBeError(); - } - [Test] public void should_return_error_when_app_folder_is_write_protected_and_update_automatically_is_enabled() { - PosixOnly(); - - const string startupFolder = @"/opt/nzbdrone"; + var startupFolder = @"C:\NzbDrone".AsOsAgnostic(); Mocker.GetMock() .Setup(s => s.UpdateAutomatically) @@ -62,10 +45,8 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks [Test] public void should_return_error_when_ui_folder_is_write_protected_and_update_automatically_is_enabled() { - PosixOnly(); - - const string startupFolder = @"/opt/nzbdrone"; - const string uiFolder = @"/opt/nzbdrone/UI"; + var startupFolder = @"C:\NzbDrone".AsOsAgnostic(); + var uiFolder = @"C:\NzbDrone\UI".AsOsAgnostic(); Mocker.GetMock() .Setup(s => s.UpdateAutomatically) @@ -89,7 +70,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks [Test] public void should_not_return_error_when_app_folder_is_write_protected_and_external_script_enabled() { - PosixOnly(); + var startupFolder = @"C:\NzbDrone".AsOsAgnostic(); Mocker.GetMock() .Setup(s => s.UpdateAutomatically) @@ -101,7 +82,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks Mocker.GetMock() .Setup(s => s.StartUpFolder) - .Returns(@"/opt/nzbdrone"); + .Returns(startupFolder); Mocker.GetMock() .Verify(c => c.FolderWritable(It.IsAny()), Times.Never()); diff --git a/src/NzbDrone.Core.Test/HealthCheck/HealthCheckServiceFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/HealthCheckServiceFixture.cs index 74802a39e..4ec0860ea 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/HealthCheckServiceFixture.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/HealthCheckServiceFixture.cs @@ -1,10 +1,14 @@ +using System; using System.Collections.Generic; using FluentAssertions; +using Moq; using NUnit.Framework; using NzbDrone.Common.Cache; using NzbDrone.Common.Messaging; +using NzbDrone.Common.TPL; using NzbDrone.Core.HealthCheck; using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.HealthCheck { @@ -19,10 +23,10 @@ namespace NzbDrone.Core.Test.HealthCheck Mocker.SetConstant>(new[] { _healthCheck }); Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); - Mocker.GetMock() - .Setup(v => v.GetServerChecks()) - .Returns(new List()); + Mocker.GetMock().Setup(s => s.CreateDebouncer(It.IsAny(), It.IsAny())) + .Returns((a, t) => new MockDebouncer(a, t)); } [Test] diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedNotificationStatusFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedNotificationStatusFixture.cs new file mode 100644 index 000000000..20e82ff7f --- /dev/null +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedNotificationStatusFixture.cs @@ -0,0 +1,56 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Housekeeping.Housekeepers; +using NzbDrone.Core.Notifications; +using NzbDrone.Core.Notifications.Join; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Housekeeping.Housekeepers +{ + [TestFixture] + public class CleanupOrphanedNotificationStatusFixture : DbTest + { + private NotificationDefinition _notification; + + [SetUp] + public void Setup() + { + _notification = Builder.CreateNew() + .With(s => s.Settings = new JoinSettings { }) + .BuildNew(); + } + + private void GivenNotification() + { + Db.Insert(_notification); + } + + [Test] + public void should_delete_orphaned_notificationstatus() + { + var status = Builder.CreateNew() + .With(h => h.ProviderId = _notification.Id) + .BuildNew(); + Db.Insert(status); + + Subject.Clean(); + AllStoredModels.Should().BeEmpty(); + } + + [Test] + public void should_not_delete_unorphaned_notificationstatus() + { + GivenNotification(); + + var status = Builder.CreateNew() + .With(h => h.ProviderId = _notification.Id) + .BuildNew(); + Db.Insert(status); + + Subject.Clean(); + AllStoredModels.Should().HaveCount(1); + AllStoredModels.Should().Contain(h => h.ProviderId == _notification.Id); + } + } +} diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/FixFutureRunScheduledTasksFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/FixFutureRunScheduledTasksFixture.cs index 39e193368..a0f303609 100644 --- a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/FixFutureRunScheduledTasksFixture.cs +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/FixFutureRunScheduledTasksFixture.cs @@ -42,7 +42,7 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers Subject.Clean(); // BeCloseTo handles Postgres rounding times - AllStoredModels.ToList().ForEach(t => t.LastExecution.Should().BeCloseTo(expectedTime)); + AllStoredModels.ToList().ForEach(t => t.LastExecution.Should().BeCloseTo(expectedTime, TimeSpan.FromMilliseconds(20))); } } } diff --git a/src/NzbDrone.Core.Test/Http/HttpProxySettingsProviderFixture.cs b/src/NzbDrone.Core.Test/Http/HttpProxySettingsProviderFixture.cs index 067149904..2beeb16f9 100644 --- a/src/NzbDrone.Core.Test/Http/HttpProxySettingsProviderFixture.cs +++ b/src/NzbDrone.Core.Test/Http/HttpProxySettingsProviderFixture.cs @@ -12,7 +12,7 @@ namespace NzbDrone.Core.Test.Http { private HttpProxySettings GetProxySettings() { - return new HttpProxySettings(ProxyType.Socks5, "localhost", 8080, "*.httpbin.org,google.com", true, null, null); + return new HttpProxySettings(ProxyType.Socks5, "localhost", 8080, "*.httpbin.org,google.com,172.16.0.0/12", true, null, null); } [Test] @@ -23,6 +23,7 @@ namespace NzbDrone.Core.Test.Http Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://eu.httpbin.org/get")).Should().BeTrue(); Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://google.com/get")).Should().BeTrue(); Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://localhost:8654/get")).Should().BeTrue(); + Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://172.21.0.1:8989/api/v3/indexer/schema")).Should().BeTrue(); } [Test] @@ -31,6 +32,7 @@ namespace NzbDrone.Core.Test.Http var settings = GetProxySettings(); Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://bing.com/get")).Should().BeFalse(); + Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://172.3.0.1:8989/api/v3/indexer/schema")).Should().BeFalse(); } } } diff --git a/src/NzbDrone.Core.Test/IndexerStatsTests/IndexerStatisticsServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerStatsTests/IndexerStatisticsServiceFixture.cs index c0ec172b4..b8f4ef702 100644 --- a/src/NzbDrone.Core.Test/IndexerStatsTests/IndexerStatisticsServiceFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerStatsTests/IndexerStatisticsServiceFixture.cs @@ -46,7 +46,7 @@ namespace NzbDrone.Core.Test.IndexerStatsTests .Setup(o => o.Between(It.IsAny(), It.IsAny())) .Returns((s, f) => history); - var statistics = Subject.IndexerStatistics(DateTime.UtcNow.AddMonths(-1), DateTime.UtcNow); + var statistics = Subject.IndexerStatistics(DateTime.UtcNow.AddMonths(-1), DateTime.UtcNow, new List { 5 }); statistics.IndexerStatistics.Count.Should().Be(1); statistics.IndexerStatistics.First().AverageResponseTime.Should().Be(0); diff --git a/src/NzbDrone.Core.Test/IndexerTests/AnimeBytesTests/AnimeBytesFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/AnimeBytesTests/AnimeBytesFixture.cs index 8532d7c8c..ae7eaa762 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/AnimeBytesTests/AnimeBytesFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/AnimeBytesTests/AnimeBytesFixture.cs @@ -43,12 +43,12 @@ namespace NzbDrone.Core.Test.IndexerTests.AnimeBytesTests .Setup(o => o.ExecuteProxiedAsync(It.Is(v => v.Method == HttpMethod.Get), Subject.Definition)) .Returns((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), recentFeed))); - var releases = (await Subject.Fetch(new BasicSearchCriteria { Categories = new[] { 2000, 5000 } })).Releases; + var releases = (await Subject.Fetch(new BasicSearchCriteria { SearchTerm = "test", Categories = new[] { 2000, 5000 } })).Releases; - releases.Should().HaveCount(33); + releases.Should().HaveCount(39); releases.First().Should().BeOfType(); - var firstTorrentInfo = releases.ElementAt(2) as TorrentInfo; + var firstTorrentInfo = releases.ElementAt(3) as TorrentInfo; firstTorrentInfo.Title.Should().Be("[SubsPlease] One Piece: The Great Gold Pirate - 1059 [Web][MKV][h264][720p][AAC 2.0][Softsubs (SubsPlease)][Episode 1059]"); firstTorrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); @@ -66,7 +66,7 @@ namespace NzbDrone.Core.Test.IndexerTests.AnimeBytesTests firstTorrentInfo.Files.Should().Be(1); firstTorrentInfo.MinimumSeedTime.Should().Be(259200); - var secondTorrentInfo = releases.ElementAt(16) as TorrentInfo; + var secondTorrentInfo = releases.ElementAt(20) as TorrentInfo; secondTorrentInfo.Title.Should().Be("[GHOST] BLEACH S03 [Blu-ray][MKV][h265 10-bit][1080p][AC3 2.0][Dual Audio][Softsubs (GHOST)]"); secondTorrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); @@ -84,7 +84,7 @@ namespace NzbDrone.Core.Test.IndexerTests.AnimeBytesTests secondTorrentInfo.Files.Should().Be(22); secondTorrentInfo.MinimumSeedTime.Should().Be(655200); - var thirdTorrentInfo = releases.ElementAt(18) as TorrentInfo; + var thirdTorrentInfo = releases.ElementAt(23) as TorrentInfo; thirdTorrentInfo.Title.Should().Be("[Polarwindz] Cowboy Bebop: Tengoku no Tobira 2001 [Blu-ray][MKV][h265 10-bit][1080p][Opus 5.1][Softsubs (Polarwindz)]"); thirdTorrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); @@ -102,7 +102,7 @@ namespace NzbDrone.Core.Test.IndexerTests.AnimeBytesTests thirdTorrentInfo.Files.Should().Be(1); thirdTorrentInfo.MinimumSeedTime.Should().Be(475200); - var fourthTorrentInfo = releases.ElementAt(3) as TorrentInfo; + var fourthTorrentInfo = releases.ElementAt(5) as TorrentInfo; fourthTorrentInfo.Title.Should().Be("[SubsPlease] Dr. STONE: NEW WORLD S03E03 - 03 [Web][MKV][h264][720p][AAC 2.0][Softsubs (SubsPlease)][Episode 3]"); fourthTorrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); @@ -120,9 +120,9 @@ namespace NzbDrone.Core.Test.IndexerTests.AnimeBytesTests fourthTorrentInfo.Files.Should().Be(1); fourthTorrentInfo.MinimumSeedTime.Should().Be(259200); - var fifthTorrentInfo = releases.ElementAt(23) as TorrentInfo; + var fifthTorrentInfo = releases.ElementAt(28) as TorrentInfo; - fifthTorrentInfo.Title.Should().Be("[-ZR-] Dr. STONE: STONE WARS S02 [Web][MKV][h264][1080p][AAC 2.0][Dual Audio][Softsubs (-ZR-)]"); + fifthTorrentInfo.Title.Should().Be("[-ZR-] Dr. STONE: STONE WARS 2021 S02 [Web][MKV][h264][1080p][AAC 2.0][Dual Audio][Softsubs (-ZR-)]"); fifthTorrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); fifthTorrentInfo.DownloadUrl.Should().Be("https://animebytes.tv/torrent/944509/download/somepass"); fifthTorrentInfo.InfoUrl.Should().Be("https://animebytes.tv/torrent/944509/group"); @@ -138,7 +138,7 @@ namespace NzbDrone.Core.Test.IndexerTests.AnimeBytesTests fifthTorrentInfo.Files.Should().Be(11); fifthTorrentInfo.MinimumSeedTime.Should().Be(529200); - var sixthTorrentInfo = releases.ElementAt(31) as TorrentInfo; + var sixthTorrentInfo = releases.ElementAt(37) as TorrentInfo; sixthTorrentInfo.Title.Should().Be("[HorribleSubs] Dr. STONE S01 [Web][MKV][h264][720p][AAC 2.0][Softsubs (HorribleSubs)]"); sixthTorrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); diff --git a/src/NzbDrone.Core.Test/IndexerTests/AvistazTests/ExoticazFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/AvistazTests/ExoticazFixture.cs index 50cdcbac6..fe4ada593 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/AvistazTests/ExoticazFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/AvistazTests/ExoticazFixture.cs @@ -51,7 +51,7 @@ namespace NzbDrone.Core.Test.IndexerTests.AvistazTests torrentInfo.InfoUrl.Should().Be("https://exoticaz.to/torrent/64040-ssis-419-my-first-experience-is-yua-mikami-from-the-day-i-lost-my-virginity-i-was-devoted-to-sex"); torrentInfo.CommentUrl.Should().BeNullOrEmpty(); torrentInfo.Indexer.Should().Be(Subject.Definition.Name); - torrentInfo.PublishDate.Should().Be(DateTime.Parse("2022-06-11 15:04:50")); + torrentInfo.PublishDate.Should().Be(DateTime.Parse("2022-06-11 09:04:50")); torrentInfo.Size.Should().Be(7085405541); torrentInfo.InfoHash.Should().Be("asdjfiasdf54asd7f4a2sdf544asdf"); torrentInfo.MagnetUrl.Should().Be(null); diff --git a/src/NzbDrone.Core.Test/IndexerTests/BroadcastheNetTests/BroadcastheNetRequestGeneratorFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/BroadcastheNetTests/BroadcastheNetRequestGeneratorFixture.cs index 10a4c8145..39d5a6f61 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/BroadcastheNetTests/BroadcastheNetRequestGeneratorFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/BroadcastheNetTests/BroadcastheNetRequestGeneratorFixture.cs @@ -114,7 +114,7 @@ namespace NzbDrone.Core.Test.IndexerTests.BroadcastheNetTests query.Tvrage.Should().BeNull(); query.Search.Should().BeNull(); query.Category.Should().Be("Episode"); - query.Name.Should().Be("S01E03"); + query.Name.Should().Be("S01E03%"); } [Test] @@ -249,7 +249,7 @@ namespace NzbDrone.Core.Test.IndexerTests.BroadcastheNetTests query.Tvrage.Should().BeNull(); query.Search.Should().Be("Malcolm%in%the%Middle"); query.Category.Should().Be("Episode"); - query.Name.Should().Be("S02E03"); + query.Name.Should().Be("S02E03%"); } [Test] diff --git a/src/NzbDrone.Core.Test/IndexerTests/GazelleGamesTests/GazelleGamesFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/GazelleGamesTests/GazelleGamesFixture.cs index 5ff5b5ee9..4bc704ccd 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/GazelleGamesTests/GazelleGamesFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/GazelleGamesTests/GazelleGamesFixture.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Linq; using System.Net; using System.Net.Http; @@ -21,10 +22,10 @@ namespace NzbDrone.Core.Test.IndexerTests.GazelleGamesTests [SetUp] public void Setup() { - Subject.Definition = new IndexerDefinition() + Subject.Definition = new IndexerDefinition { Name = "GazelleGames", - Settings = new GazelleGamesSettings() { Apikey = "somekey" } + Settings = new GazelleGamesSettings { Apikey = "somekey" } }; } @@ -37,20 +38,20 @@ namespace NzbDrone.Core.Test.IndexerTests.GazelleGamesTests .Setup(o => o.ExecuteProxiedAsync(It.Is(v => v.Method == HttpMethod.Get), Subject.Definition)) .Returns((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), recentFeed))); - var releases = (await Subject.Fetch(new BasicSearchCriteria { Categories = new int[] { 2000 } })).Releases; + var releases = (await Subject.Fetch(new BasicSearchCriteria { Categories = new[] { 2000 } })).Releases; - releases.Should().HaveCount(1464); + releases.Should().HaveCount(1462); releases.First().Should().BeOfType(); var torrentInfo = releases.First() as TorrentInfo; - torrentInfo.Title.Should().Be("Microsoft_Flight_Simulator-HOODLUM"); + torrentInfo.Title.Should().Be("Microsoft_Flight_Simulator-HOODLUM (2020) [Windows / Multi-Language / Full ISO]"); torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); torrentInfo.DownloadUrl.Should().Be("https://gazellegames.net/torrents.php?action=download&id=303216&authkey=prowlarr&torrent_pass="); torrentInfo.InfoUrl.Should().Be("https://gazellegames.net/torrents.php?id=84781&torrentid=303216"); torrentInfo.CommentUrl.Should().BeNullOrEmpty(); torrentInfo.Indexer.Should().Be(Subject.Definition.Name); - torrentInfo.PublishDate.Should().Be(DateTime.Parse("2022-07-25 6:39:11").ToUniversalTime()); + torrentInfo.PublishDate.Should().Be(DateTime.Parse("2022-07-25 06:39:11", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal)); torrentInfo.Size.Should().Be(80077617780); torrentInfo.InfoHash.Should().Be(null); torrentInfo.MagnetUrl.Should().Be(null); @@ -74,7 +75,7 @@ namespace NzbDrone.Core.Test.IndexerTests.GazelleGamesTests .Setup(o => o.ExecuteProxiedAsync(It.Is(v => v.Method == HttpMethod.Get), Subject.Definition)) .Returns((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), recentFeed))); - var releases = (await Subject.Fetch(new BasicSearchCriteria { Categories = new int[] { 2000 } })).Releases; + var releases = (await Subject.Fetch(new BasicSearchCriteria { Categories = new[] { 2000 } })).Releases; releases.Should().HaveCount(0); } diff --git a/src/NzbDrone.Core.Test/IndexerTests/HDBitsTests/HDBitsFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/HDBitsTests/HDBitsFixture.cs index debe6c891..06326d162 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/HDBitsTests/HDBitsFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/HDBitsTests/HDBitsFixture.cs @@ -26,15 +26,15 @@ namespace NzbDrone.Core.Test.IndexerTests.HDBitsTests [SetUp] public void Setup() { - Subject.Definition = new IndexerDefinition() + Subject.Definition = new IndexerDefinition { Name = "HdBits", - Settings = new HDBitsSettings() { ApiKey = "fakekey" } + Settings = new HDBitsSettings { ApiKey = "fakekey" } }; _movieSearchCriteria = new MovieSearchCriteria { - Categories = new int[] { 2000, 2010 }, + Categories = new[] { 2000, 2010 }, ImdbId = "0076759" }; } @@ -52,12 +52,12 @@ namespace NzbDrone.Core.Test.IndexerTests.HDBitsTests var torrents = (await Subject.Fetch(_movieSearchCriteria)).Releases; torrents.Should().HaveCount(2); - torrents.First().Should().BeOfType(); + torrents.First().Should().BeOfType(); var first = torrents.First() as TorrentInfo; first.Guid.Should().Be("HDBits-257142"); - first.Title.Should().Be("Supernatural S10E17 1080p WEB-DL DD5.1 H.264-ECI"); + first.Title.Should().Be("Supernatural.S10E17.1080p.WEB-DL.DD5.1.H.264-ECI"); first.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); first.DownloadUrl.Should().Be("https://hdbits.org/download.php?id=257142&passkey=fakekey"); first.InfoUrl.Should().Be("https://hdbits.org/details.php?id=257142"); diff --git a/src/NzbDrone.Core.Test/IndexerTests/IndexerStatusServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/IndexerStatusServiceFixture.cs index 55ed8abfb..f836852e1 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/IndexerStatusServiceFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/IndexerStatusServiceFixture.cs @@ -68,5 +68,16 @@ namespace NzbDrone.Core.Test.IndexerTests VerifyNoUpdate(); } + + [Test] + public void should_not_record_failure_for_unknown_provider() + { + Subject.RecordFailure(0); + + Mocker.GetMock() + .Verify(v => v.FindByProviderId(1), Times.Never); + + VerifyNoUpdate(); + } } } diff --git a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabFixture.cs index 231c4bb56..084767ffa 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabFixture.cs @@ -33,6 +33,10 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests }; _caps = new IndexerCapabilities(); + + _caps.Categories.AddCategoryMapping(2000, NewznabStandardCategory.Movies, "Movies"); + _caps.Categories.AddCategoryMapping(5000, NewznabStandardCategory.TV, "TV"); + Mocker.GetMock() .Setup(v => v.GetCapabilities(It.IsAny(), It.IsAny())) .Returns(_caps); diff --git a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabRequestGeneratorFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabRequestGeneratorFixture.cs index ead7db168..b26a1db21 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabRequestGeneratorFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabRequestGeneratorFixture.cs @@ -19,6 +19,11 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests [SetUp] public void SetUp() { + Subject.Definition = new IndexerDefinition + { + Name = "Newznab" + }; + Subject.Settings = new NewznabSettings() { BaseUrl = "http://127.0.0.1:1234/", diff --git a/src/NzbDrone.Core.Test/IndexerTests/OrpheusTests/OrpheusFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/OrpheusTests/OrpheusFixture.cs index 43ec59c53..d33dde2d3 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/OrpheusTests/OrpheusFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/OrpheusTests/OrpheusFixture.cs @@ -9,8 +9,8 @@ using NUnit.Framework; using NzbDrone.Common.Http; using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers.Definitions; -using NzbDrone.Core.Indexers.Definitions.Gazelle; using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.IndexerTests.OrpheusTests @@ -39,12 +39,12 @@ namespace NzbDrone.Core.Test.IndexerTests.OrpheusTests var releases = (await Subject.Fetch(new BasicSearchCriteria { Categories = new[] { 3000 } })).Releases; - releases.Should().HaveCount(65); - releases.First().Should().BeOfType(); + releases.Should().HaveCount(50); + releases.First().Should().BeOfType(); - var torrentInfo = releases.First() as GazelleInfo; + var torrentInfo = releases.First() as TorrentInfo; - torrentInfo.Title.Should().Be("The Beatles - Abbey Road [1969] [Album] [2.0 Mix 2019] [MP3 V2 (VBR)] [BD]"); + torrentInfo.Title.Should().Be("The Beatles - Abbey Road (1969) [Album] [2.0 Mix 2019] [MP3 V2 (VBR) / BD]"); torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); torrentInfo.DownloadUrl.Should().Be("https://orpheus.network/ajax.php?action=download&id=1902448"); torrentInfo.InfoUrl.Should().Be("https://orpheus.network/torrents.php?id=466&torrentid=1902448"); diff --git a/src/NzbDrone.Core.Test/IndexerTests/PTPTests/PTPFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/PTPTests/PTPFixture.cs index 307585d29..1d6ad7271 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/PTPTests/PTPFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/PTPTests/PTPFixture.cs @@ -6,9 +6,8 @@ using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common.Http; -using NzbDrone.Common.Serializer; using NzbDrone.Core.Indexers; -using NzbDrone.Core.Indexers.PassThePopcorn; +using NzbDrone.Core.Indexers.Definitions.PassThePopcorn; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; @@ -21,26 +20,22 @@ namespace NzbDrone.Core.Test.IndexerTests.PTPTests [SetUp] public void Setup() { - Subject.Definition = new IndexerDefinition() + Subject.Definition = new IndexerDefinition { Name = "PTP", - Settings = new PassThePopcornSettings() { APIUser = "asdf", APIKey = "sad" } + Settings = new PassThePopcornSettings + { + APIUser = "asdf", + APIKey = "sad" + } }; } [TestCase("Files/Indexers/PTP/imdbsearch.json")] public async Task should_parse_feed_from_PTP(string fileName) { - var authResponse = new PassThePopcornAuthResponse { Result = "Ok" }; - - var authStream = new System.IO.StringWriter(); - Json.Serialize(authResponse, authStream); var responseJson = ReadAllText(fileName); - Mocker.GetMock() - .Setup(o => o.ExecuteProxiedAsync(It.Is(v => v.Method == HttpMethod.Post), Subject.Definition)) - .Returns((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), authStream.ToString()))); - Mocker.GetMock() .Setup(o => o.ExecuteProxiedAsync(It.Is(v => v.Method == HttpMethod.Get), Subject.Definition)) .Returns((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader { ContentType = HttpAccept.Json.Value }, new CookieCollection(), responseJson))); diff --git a/src/NzbDrone.Core.Test/IndexerTests/RedactedTests/RedactedFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/RedactedTests/RedactedFixture.cs index a23162b5d..d7eb35cd1 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/RedactedTests/RedactedFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/RedactedTests/RedactedFixture.cs @@ -9,8 +9,8 @@ using NUnit.Framework; using NzbDrone.Common.Http; using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers.Definitions; -using NzbDrone.Core.Indexers.Definitions.Gazelle; using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.IndexerTests.RedactedTests @@ -40,14 +40,14 @@ namespace NzbDrone.Core.Test.IndexerTests.RedactedTests var releases = (await Subject.Fetch(new BasicSearchCriteria { Categories = new[] { 3000 } })).Releases; releases.Should().HaveCount(39); - releases.First().Should().BeOfType(); + releases.First().Should().BeOfType(); - var torrentInfo = releases.First() as GazelleInfo; + var torrentInfo = releases.First() as TorrentInfo; - torrentInfo.Title.Should().Be("Red Hot Chili Peppers - Californication [1999] [Album] [US / Reissue 2020] [FLAC 24bit Lossless] [Vinyl]"); + torrentInfo.Title.Should().Be("Red Hot Chili Peppers - Californication (1999) [Album] [US / Reissue 2020] [FLAC 24bit Lossless / Vinyl]"); torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); - torrentInfo.DownloadUrl.Should().Be("https://redacted.ch/ajax.php?action=download&id=3892313"); - torrentInfo.InfoUrl.Should().Be("https://redacted.ch/torrents.php?id=16720&torrentid=3892313"); + torrentInfo.DownloadUrl.Should().Be("https://redacted.sh/ajax.php?action=download&id=3892313"); + torrentInfo.InfoUrl.Should().Be("https://redacted.sh/torrents.php?id=16720&torrentid=3892313"); torrentInfo.CommentUrl.Should().BeNullOrEmpty(); torrentInfo.Indexer.Should().Be(Subject.Definition.Name); torrentInfo.PublishDate.Should().Be(DateTime.Parse("2022-12-17 08:02:35")); diff --git a/src/NzbDrone.Core.Test/IndexerTests/SecretCinemaTests/SecretCinemaFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/SecretCinemaTests/SecretCinemaFixture.cs index e3aa89400..7c5166d26 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/SecretCinemaTests/SecretCinemaFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/SecretCinemaTests/SecretCinemaFixture.cs @@ -11,6 +11,7 @@ using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers.Definitions; using NzbDrone.Core.Indexers.Definitions.Gazelle; using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.IndexerTests.SecretCinemaTests @@ -40,9 +41,9 @@ namespace NzbDrone.Core.Test.IndexerTests.SecretCinemaTests var releases = (await Subject.Fetch(new BasicSearchCriteria { Categories = new[] { 2000 } })).Releases; releases.Should().HaveCount(3); - releases.First().Should().BeOfType(); + releases.First().Should().BeOfType(); - var torrentInfo = releases.First() as GazelleInfo; + var torrentInfo = releases.First() as TorrentInfo; torrentInfo.Title.Should().Be("Singin' in the Rain (1952) 2160p"); torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); diff --git a/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs index e5a6f6c32..a5fdf0506 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs @@ -37,6 +37,9 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests _caps.Categories.AddCategoryMapping(2000, NewznabStandardCategory.Movies, "Movies"); _caps.Categories.AddCategoryMapping(2040, NewznabStandardCategory.MoviesHD, "Movies/HD"); + _caps.Categories.AddCategoryMapping(5000, NewznabStandardCategory.TV, "TV"); + _caps.Categories.AddCategoryMapping(5040, NewznabStandardCategory.TVHD, "TV/HD"); + _caps.Categories.AddCategoryMapping(5070, NewznabStandardCategory.TVAnime, "TV/Anime"); Mocker.GetMock() .Setup(v => v.GetCapabilities(It.IsAny(), It.IsAny())) @@ -83,23 +86,7 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests var releases = (await Subject.Fetch(new MovieSearchCriteria())).Releases; - releases.Should().HaveCount(5); - - releases.First().Should().BeOfType(); - var releaseInfo = releases.First() as TorrentInfo; - - releaseInfo.Title.Should().Be("Series Title S05E02 HDTV x264-Xclusive [eztv]"); - releaseInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); - releaseInfo.MagnetUrl.Should().Be("magnet:?xt=urn:btih:9fb267cff5ae5603f07a347676ec3bf3e35f75e1&dn=Game+of+Thrones+S05E02+HDTV+x264-Xclusive+%5Beztv%5D&tr=udp:%2F%2Fopen.demonii.com:1337&tr=udp:%2F%2Ftracker.coppersurfer.tk:6969&tr=udp:%2F%2Ftracker.leechers-paradise.org:6969&tr=udp:%2F%2Fexodus.desync.com:6969"); - releaseInfo.DownloadUrl.Should().Be("magnet:?xt=urn:btih:9fb267cff5ae5603f07a347676ec3bf3e35f75e1&dn=Game+of+Thrones+S05E02+HDTV+x264-Xclusive+%5Beztv%5D&tr=udp:%2F%2Fopen.demonii.com:1337&tr=udp:%2F%2Ftracker.coppersurfer.tk:6969&tr=udp:%2F%2Ftracker.leechers-paradise.org:6969&tr=udp:%2F%2Fexodus.desync.com:6969"); - releaseInfo.InfoUrl.Should().Be("https://thepiratebay.se/torrent/11811366/Series_Title_S05E02_HDTV_x264-Xclusive_%5Beztv%5D"); - releaseInfo.CommentUrl.Should().Be("https://thepiratebay.se/torrent/11811366/Series_Title_S05E02_HDTV_x264-Xclusive_%5Beztv%5D"); - releaseInfo.Indexer.Should().Be(Subject.Definition.Name); - releaseInfo.PublishDate.Should().Be(DateTime.Parse("Sat, 11 Apr 2015 21:34:00 -0600").ToUniversalTime()); - releaseInfo.Size.Should().Be(388895872); - releaseInfo.InfoHash.Should().Be("9fb267cff5ae5603f07a347676ec3bf3e35f75e1"); - releaseInfo.Seeders.Should().Be(34128); - releaseInfo.Peers.Should().Be(36724); + releases.Should().HaveCount(0); } [Test] diff --git a/src/NzbDrone.Core.Test/Localization/LocalizationServiceFixture.cs b/src/NzbDrone.Core.Test/Localization/LocalizationServiceFixture.cs index e49b0592d..cd1b189ed 100644 --- a/src/NzbDrone.Core.Test/Localization/LocalizationServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Localization/LocalizationServiceFixture.cs @@ -5,7 +5,6 @@ using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration; using NzbDrone.Core.Localization; using NzbDrone.Core.Test.Framework; -using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.Localization { @@ -29,19 +28,20 @@ namespace NzbDrone.Core.Test.Localization } [Test] - public void should_get_string_in_default_dictionary_if_no_lang_exists_and_string_exists() + public void should_get_string_in_french() { - var localizedString = Subject.GetLocalizedString("BackupNow", "an"); + Mocker.GetMock().Setup(m => m.UILanguage).Returns("fr"); - localizedString.Should().Be("Backup Now"); + var localizedString = Subject.GetLocalizedString("BackupNow"); - ExceptionVerification.ExpectedErrors(1); + localizedString.Should().Be("Sauvegarder maintenant"); } [Test] - public void should_get_string_in_default_dictionary_if_lang_empty_and_string_exists() + public void should_get_string_in_default_dictionary_if_unknown_language_and_string_exists() { - var localizedString = Subject.GetLocalizedString("BackupNow", ""); + Mocker.GetMock().Setup(m => m.UILanguage).Returns(""); + var localizedString = Subject.GetLocalizedString("BackupNow"); localizedString.Should().Be("Backup Now"); } @@ -49,7 +49,7 @@ namespace NzbDrone.Core.Test.Localization [Test] public void should_return_argument_if_string_doesnt_exists() { - var localizedString = Subject.GetLocalizedString("BadString", "en"); + var localizedString = Subject.GetLocalizedString("BadString"); localizedString.Should().Be("BadString"); } diff --git a/src/NzbDrone.Core.Test/NotificationTests/EmailTests/EmailSettingsValidatorFixture.cs b/src/NzbDrone.Core.Test/NotificationTests/EmailTests/EmailSettingsValidatorFixture.cs new file mode 100644 index 000000000..309169c69 --- /dev/null +++ b/src/NzbDrone.Core.Test/NotificationTests/EmailTests/EmailSettingsValidatorFixture.cs @@ -0,0 +1,111 @@ +using System; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Notifications.Email; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.NotificationTests.EmailTests +{ + [TestFixture] + public class EmailSettingsValidatorFixture : CoreTest + { + private EmailSettings _emailSettings; + private TestValidator _validator; + + [SetUp] + public void Setup() + { + _validator = new TestValidator + { + v => v.RuleFor(s => s).SetValidator(Subject) + }; + + _emailSettings = Builder.CreateNew() + .With(s => s.Server = "someserver") + .With(s => s.Port = 567) + .With(s => s.UseEncryption = (int)EmailEncryptionType.Always) + .With(s => s.From = "dont@email.me") + .With(s => s.To = new string[] { "dont@email.me" }) + .Build(); + } + + [Test] + public void should_be_valid_if_all_settings_valid() + { + _validator.Validate(_emailSettings).IsValid.Should().BeTrue(); + } + + [Test] + public void should_not_be_valid_if_port_is_out_of_range() + { + _emailSettings.Port = 900000; + + _validator.Validate(_emailSettings).IsValid.Should().BeFalse(); + } + + [Test] + public void should_not_be_valid_if_server_is_empty() + { + _emailSettings.Server = ""; + + _validator.Validate(_emailSettings).IsValid.Should().BeFalse(); + } + + [Test] + public void should_not_be_valid_if_from_is_empty() + { + _emailSettings.From = ""; + + _validator.Validate(_emailSettings).IsValid.Should().BeFalse(); + } + + [TestCase("prowlarr")] + [TestCase("email.me")] + [Ignore("Allowed coz some email servers allow arbitrary source, we probably need to support 'Name ' syntax")] + public void should_not_be_valid_if_from_is_invalid(string email) + { + _emailSettings.From = email; + + _validator.Validate(_emailSettings).IsValid.Should().BeFalse(); + } + + [TestCase("prowlarr")] + [TestCase("email.me")] + public void should_not_be_valid_if_to_is_invalid(string email) + { + _emailSettings.To = new string[] { email }; + + _validator.Validate(_emailSettings).IsValid.Should().BeFalse(); + } + + [TestCase("prowlarr")] + [TestCase("email.me")] + public void should_not_be_valid_if_cc_is_invalid(string email) + { + _emailSettings.Cc = new string[] { email }; + + _validator.Validate(_emailSettings).IsValid.Should().BeFalse(); + } + + [TestCase("prowlarr")] + [TestCase("email.me")] + public void should_not_be_valid_if_bcc_is_invalid(string email) + { + _emailSettings.Bcc = new string[] { email }; + + _validator.Validate(_emailSettings).IsValid.Should().BeFalse(); + } + + [Test] + public void should_not_be_valid_if_to_bcc_cc_are_all_empty() + { + _emailSettings.To = Array.Empty(); + _emailSettings.Cc = Array.Empty(); + _emailSettings.Bcc = Array.Empty(); + + _validator.Validate(_emailSettings).IsValid.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/NotificationTests/NotificationStatusServiceFixture.cs b/src/NzbDrone.Core.Test/NotificationTests/NotificationStatusServiceFixture.cs new file mode 100644 index 000000000..183246313 --- /dev/null +++ b/src/NzbDrone.Core.Test/NotificationTests/NotificationStatusServiceFixture.cs @@ -0,0 +1,161 @@ +using System; +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Notifications; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.NotificationTests +{ + public class NotificationStatusServiceFixture : CoreTest + { + private DateTime _epoch; + + [SetUp] + public void SetUp() + { + _epoch = DateTime.UtcNow; + + Mocker.GetMock() + .SetupGet(v => v.StartTime) + .Returns(_epoch - TimeSpan.FromHours(1)); + } + + private NotificationStatus WithStatus(NotificationStatus status) + { + Mocker.GetMock() + .Setup(v => v.FindByProviderId(1)) + .Returns(status); + + Mocker.GetMock() + .Setup(v => v.All()) + .Returns(new[] { status }); + + return status; + } + + private void VerifyUpdate() + { + Mocker.GetMock() + .Verify(v => v.Upsert(It.IsAny()), Times.Once()); + } + + private void VerifyNoUpdate() + { + Mocker.GetMock() + .Verify(v => v.Upsert(It.IsAny()), Times.Never()); + } + + [Test] + public void should_not_consider_blocked_within_5_minutes_since_initial_failure() + { + WithStatus(new NotificationStatus + { + InitialFailure = _epoch - TimeSpan.FromMinutes(4), + MostRecentFailure = _epoch - TimeSpan.FromSeconds(4), + EscalationLevel = 3 + }); + + Subject.RecordFailure(1); + + VerifyUpdate(); + + var status = Subject.GetBlockedProviders().FirstOrDefault(); + status.Should().BeNull(); + } + + [Test] + public void should_consider_blocked_after_5_minutes_since_initial_failure() + { + WithStatus(new NotificationStatus + { + InitialFailure = _epoch - TimeSpan.FromMinutes(6), + MostRecentFailure = _epoch - TimeSpan.FromSeconds(120), + EscalationLevel = 3 + }); + + Subject.RecordFailure(1); + + VerifyUpdate(); + + var status = Subject.GetBlockedProviders().FirstOrDefault(); + status.Should().NotBeNull(); + } + + [Test] + public void should_not_escalate_further_till_after_5_minutes_since_initial_failure() + { + var origStatus = WithStatus(new NotificationStatus + { + InitialFailure = _epoch - TimeSpan.FromMinutes(4), + MostRecentFailure = _epoch - TimeSpan.FromSeconds(4), + EscalationLevel = 3 + }); + + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + + var status = Subject.GetBlockedProviders().FirstOrDefault(); + status.Should().BeNull(); + + origStatus.EscalationLevel.Should().Be(3); + } + + [Test] + public void should_escalate_further_after_5_minutes_since_initial_failure() + { + WithStatus(new NotificationStatus + { + InitialFailure = _epoch - TimeSpan.FromMinutes(6), + MostRecentFailure = _epoch - TimeSpan.FromSeconds(120), + EscalationLevel = 3 + }); + + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + + var status = Subject.GetBlockedProviders().FirstOrDefault(); + status.Should().NotBeNull(); + + status.EscalationLevel.Should().BeGreaterThan(3); + } + + [Test] + public void should_not_escalate_beyond_3_hours() + { + WithStatus(new NotificationStatus + { + InitialFailure = _epoch - TimeSpan.FromMinutes(6), + MostRecentFailure = _epoch - TimeSpan.FromSeconds(120), + EscalationLevel = 3 + }); + + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + Subject.RecordFailure(1); + + var status = Subject.GetBlockedProviders().FirstOrDefault(); + status.Should().NotBeNull(); + status.DisabledTill.Should().HaveValue(); + status.DisabledTill.Should().NotBeAfter(_epoch + TimeSpan.FromHours(3.1)); + } + } +} diff --git a/src/NzbDrone.Core.Test/Prowlarr.Core.Test.csproj b/src/NzbDrone.Core.Test/Prowlarr.Core.Test.csproj index 3609cb943..13bc15cbc 100644 --- a/src/NzbDrone.Core.Test/Prowlarr.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/Prowlarr.Core.Test.csproj @@ -3,10 +3,10 @@ net6.0 - + - + diff --git a/src/NzbDrone.Core.Test/ThingiProviderTests/ProviderStatusServiceFixture.cs b/src/NzbDrone.Core.Test/ThingiProviderTests/ProviderStatusServiceFixture.cs index 8e2f6b8c6..dfe58dbb0 100644 --- a/src/NzbDrone.Core.Test/ThingiProviderTests/ProviderStatusServiceFixture.cs +++ b/src/NzbDrone.Core.Test/ThingiProviderTests/ProviderStatusServiceFixture.cs @@ -34,6 +34,7 @@ namespace NzbDrone.Core.Test.ThingiProviderTests public class ProviderStatusServiceFixture : CoreTest { + private readonly TimeSpan _disabledTillPrecision = TimeSpan.FromMilliseconds(500); private DateTime _epoch; [SetUp] @@ -90,7 +91,7 @@ namespace NzbDrone.Core.Test.ThingiProviderTests var status = Subject.GetBlockedProviders().FirstOrDefault(); status.Should().NotBeNull(); status.DisabledTill.Should().HaveValue(); - status.DisabledTill.Value.Should().BeCloseTo(_epoch + TimeSpan.FromMinutes(1), 500); + status.DisabledTill.Value.Should().BeCloseTo(_epoch + TimeSpan.FromMinutes(1), _disabledTillPrecision); } [Test] @@ -133,7 +134,7 @@ namespace NzbDrone.Core.Test.ThingiProviderTests var status = Subject.GetBlockedProviders().FirstOrDefault(); status.Should().NotBeNull(); status.DisabledTill.Should().HaveValue(); - status.DisabledTill.Value.Should().BeCloseTo(_epoch + TimeSpan.FromMinutes(5), 500); + status.DisabledTill.Value.Should().BeCloseTo(_epoch + TimeSpan.FromMinutes(5), _disabledTillPrecision); } [Test] @@ -160,7 +161,7 @@ namespace NzbDrone.Core.Test.ThingiProviderTests status.Should().NotBeNull(); origStatus.EscalationLevel.Should().Be(3); - status.DisabledTill.Should().BeCloseTo(_epoch + TimeSpan.FromMinutes(1), 500); + status.DisabledTill.Should().BeCloseTo(_epoch + TimeSpan.FromMinutes(5), _disabledTillPrecision); } } } diff --git a/src/NzbDrone.Core.Test/UpdateTests/UpdateServiceFixture.cs b/src/NzbDrone.Core.Test/UpdateTests/UpdateServiceFixture.cs index ce2c446e6..5f2aa2662 100644 --- a/src/NzbDrone.Core.Test/UpdateTests/UpdateServiceFixture.cs +++ b/src/NzbDrone.Core.Test/UpdateTests/UpdateServiceFixture.cs @@ -35,18 +35,18 @@ namespace NzbDrone.Core.Test.UpdateTests { _updatePackage = new UpdatePackage { - FileName = "NzbDrone.develop.2.0.0.0.tar.gz", + FileName = "NzbDrone.develop.1.0.0.0.tar.gz", Url = "http://download.sonarr.tv/v2/develop/mono/NzbDrone.develop.tar.gz", - Version = new Version("2.0.0.0") + Version = new Version("1.0.0.0") }; } else { _updatePackage = new UpdatePackage { - FileName = "NzbDrone.develop.2.0.0.0.zip", + FileName = "NzbDrone.develop.1.0.0.0.zip", Url = "http://download.sonarr.tv/v2/develop/windows/NzbDrone.develop.zip", - Version = new Version("2.0.0.0") + Version = new Version("1.0.0.0") }; } @@ -90,17 +90,6 @@ namespace NzbDrone.Core.Test.UpdateTests .Returns(true); } - [Test] - public void should_not_update_if_inside_docker() - { - Mocker.GetMock().Setup(x => x.IsDocker).Returns(true); - - Subject.Execute(new ApplicationUpdateCommand()); - - Mocker.GetMock() - .Verify(c => c.Start(It.IsAny(), It.Is(s => s.StartsWith("12")), null, null, null), Times.Never()); - } - [Test] public void should_delete_sandbox_before_update_if_folder_exists() { @@ -338,6 +327,28 @@ namespace NzbDrone.Core.Test.UpdateTests .Verify(v => v.SaveConfigDictionary(It.Is>(d => d.ContainsKey("Branch") && (string)d["Branch"] == "fake")), Times.Once()); } + [Test] + public void should_not_update_with_built_in_updater_inside_docker_container() + { + Mocker.GetMock().Setup(x => x.PackageUpdateMechanism).Returns(UpdateMechanism.Docker); + + Subject.Execute(new ApplicationUpdateCommand()); + + Mocker.GetMock() + .Verify(c => c.Start(It.IsAny(), It.Is(s => s.StartsWith("12")), null, null, null), Times.Never()); + } + + [Test] + public void should_not_update_with_built_in_updater_when_external_updater_is_configured() + { + Mocker.GetMock().Setup(x => x.IsExternalUpdateMechanism).Returns(true); + + Subject.Execute(new ApplicationUpdateCommand()); + + Mocker.GetMock() + .Verify(c => c.Start(It.IsAny(), It.Is(s => s.StartsWith("12")), null, null, null), Times.Never()); + } + [TearDown] public void TearDown() { diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index d35409c0b..8d31502fa 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -41,6 +41,23 @@ namespace NzbDrone.Core.Annotations public string Hint { get; set; } } + [AttributeUsage(AttributeTargets.Property, AllowMultiple = true)] + public class FieldTokenAttribute : Attribute + { + public FieldTokenAttribute(TokenField field, string label = "", string token = "", object value = null) + { + Label = label; + Field = field; + Token = token; + Value = value?.ToString(); + } + + public string Label { get; set; } + public TokenField Field { get; set; } + public string Token { get; set; } + public string Value { get; set; } + } + public class FieldSelectOption { public int Value { get; set; } @@ -82,4 +99,11 @@ namespace NzbDrone.Core.Annotations ApiKey, UserName } + + public enum TokenField + { + Label, + HelpText, + HelpTextWarning + } } diff --git a/src/NzbDrone.Core/Applications/AppIndexerMapRepository.cs b/src/NzbDrone.Core/Applications/AppIndexerMapRepository.cs index ff966b196..349e50622 100644 --- a/src/NzbDrone.Core/Applications/AppIndexerMapRepository.cs +++ b/src/NzbDrone.Core/Applications/AppIndexerMapRepository.cs @@ -10,9 +10,9 @@ namespace NzbDrone.Core.Applications void DeleteAllForApp(int appId); } - public class TagRepository : BasicRepository, IAppIndexerMapRepository + public class AppIndexerMapRepository : BasicRepository, IAppIndexerMapRepository { - public TagRepository(IMainDatabase database, IEventAggregator eventAggregator) + public AppIndexerMapRepository(IMainDatabase database, IEventAggregator eventAggregator) : base(database, eventAggregator) { } diff --git a/src/NzbDrone.Core/Applications/ApplicationBase.cs b/src/NzbDrone.Core/Applications/ApplicationBase.cs index f516fddf1..b4d32f054 100644 --- a/src/NzbDrone.Core/Applications/ApplicationBase.cs +++ b/src/NzbDrone.Core/Applications/ApplicationBase.cs @@ -11,6 +11,8 @@ namespace NzbDrone.Core.Applications public abstract class ApplicationBase : IApplication where TSettings : IProviderConfig, new() { + private readonly IIndexerFactory _indexerFactory; + protected readonly IAppIndexerMapService _appIndexerMapService; protected readonly Logger _logger; @@ -27,9 +29,10 @@ namespace NzbDrone.Core.Applications protected TSettings Settings => (TSettings)Definition.Settings; - public ApplicationBase(IAppIndexerMapService appIndexerMapService, Logger logger) + public ApplicationBase(IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger) { _appIndexerMapService = appIndexerMapService; + _indexerFactory = indexerFactory; _logger = logger; } @@ -46,7 +49,6 @@ namespace NzbDrone.Core.Applications yield return new ApplicationDefinition { - Name = GetType().Name, SyncLevel = ApplicationSyncLevel.FullSync, Implementation = GetType().Name, Settings = config @@ -55,7 +57,7 @@ namespace NzbDrone.Core.Applications } public abstract void AddIndexer(IndexerDefinition indexer); - public abstract void UpdateIndexer(IndexerDefinition indexer); + public abstract void UpdateIndexer(IndexerDefinition indexer, bool forceSync = false); public abstract void RemoveIndexer(int indexerId); public abstract List GetIndexerMappings(); @@ -63,5 +65,17 @@ namespace NzbDrone.Core.Applications { return null; } + + protected IndexerCapabilities GetIndexerCapabilities(IndexerDefinition indexer) + { + try + { + return _indexerFactory.GetInstance(indexer).GetCapabilities(); + } + catch (Exception) + { + return indexer.Capabilities; + } + } } } diff --git a/src/NzbDrone.Core/Applications/ApplicationFactory.cs b/src/NzbDrone.Core/Applications/ApplicationFactory.cs index 31386ad50..2ecd2e78b 100644 --- a/src/NzbDrone.Core/Applications/ApplicationFactory.cs +++ b/src/NzbDrone.Core/Applications/ApplicationFactory.cs @@ -42,13 +42,18 @@ namespace NzbDrone.Core.Applications return enabledClients.ToList(); } + protected override List Active() + { + return base.Active().Where(c => c.Enable).ToList(); + } + private IEnumerable FilterBlockedApplications(IEnumerable applications) { var blockedApplications = _applicationStatusService.GetBlockedProviders().ToDictionary(v => v.ProviderId, v => v); foreach (var application in applications) { - if (blockedApplications.TryGetValue(application.Definition.Id, out var blockedApplicationStatus)) + if (blockedApplications.TryGetValue(application.Definition.Id, out var blockedApplicationStatus) && blockedApplicationStatus.DisabledTill.HasValue) { _logger.Debug("Temporarily ignoring application {0} till {1} due to recent failures.", application.Definition.Name, blockedApplicationStatus.DisabledTill.Value.ToLocalTime()); continue; @@ -62,10 +67,19 @@ namespace NzbDrone.Core.Applications { var result = base.Test(definition); - if ((result == null || result.IsValid) && definition.Id != 0) + if (definition.Id == 0) + { + return result; + } + + if (result == null || result.IsValid) { _applicationStatusService.RecordSuccess(definition.Id); } + else + { + _applicationStatusService.RecordFailure(definition.Id); + } return result; } diff --git a/src/NzbDrone.Core/Applications/ApplicationIndexerSyncCommand.cs b/src/NzbDrone.Core/Applications/ApplicationIndexerSyncCommand.cs index ad9023993..769843dd3 100644 --- a/src/NzbDrone.Core/Applications/ApplicationIndexerSyncCommand.cs +++ b/src/NzbDrone.Core/Applications/ApplicationIndexerSyncCommand.cs @@ -4,8 +4,15 @@ namespace NzbDrone.Core.Applications { public class ApplicationIndexerSyncCommand : Command { + public bool ForceSync { get; set; } + + public ApplicationIndexerSyncCommand() + { + ForceSync = false; + } + public override bool SendUpdatesToClient => true; - public override string CompletionMessage => null; + public override string CompletionMessage => "Completed"; } } diff --git a/src/NzbDrone.Core/Applications/ApplicationService.cs b/src/NzbDrone.Core/Applications/ApplicationService.cs index 3434c8aa4..0a5a81fbd 100644 --- a/src/NzbDrone.Core/Applications/ApplicationService.cs +++ b/src/NzbDrone.Core/Applications/ApplicationService.cs @@ -67,10 +67,11 @@ namespace NzbDrone.Core.Applications public void HandleAsync(ProviderAddedEvent message) { var enabledApps = _applicationsFactory.SyncEnabled(); + var indexer = _indexerFactory.GetInstance((IndexerDefinition)message.Definition); foreach (var app in enabledApps) { - if (ShouldHandleIndexer(app.Definition, message.Definition)) + if (ShouldHandleIndexer(app.Definition, indexer.Definition)) { ExecuteAction(a => a.AddIndexer((IndexerDefinition)message.Definition), app); } @@ -92,8 +93,9 @@ namespace NzbDrone.Core.Applications var enabledApps = _applicationsFactory.SyncEnabled() .Where(n => ((ApplicationDefinition)n.Definition).SyncLevel == ApplicationSyncLevel.FullSync) .ToList(); + var indexer = _indexerFactory.GetInstance((IndexerDefinition)message.Definition); - SyncIndexers(enabledApps, new List { (IndexerDefinition)message.Definition }); + SyncIndexers(enabledApps, new List { (IndexerDefinition)indexer.Definition }); } public void HandleAsync(ApiKeyChangedEvent message) @@ -102,7 +104,7 @@ namespace NzbDrone.Core.Applications var indexers = _indexerFactory.AllProviders().Select(i => (IndexerDefinition)i.Definition).ToList(); - SyncIndexers(enabledApps, indexers, true); + SyncIndexers(enabledApps, indexers, true, true); } public void HandleAsync(ProviderBulkUpdatedEvent message) @@ -120,11 +122,13 @@ namespace NzbDrone.Core.Applications var indexers = _indexerFactory.AllProviders().Select(i => (IndexerDefinition)i.Definition).ToList(); - SyncIndexers(enabledApps, indexers, true); + SyncIndexers(enabledApps, indexers, true, message.ForceSync); } - private void SyncIndexers(List applications, List indexers, bool removeRemote = false) + private void SyncIndexers(List applications, List indexers, bool removeRemote = false, bool forceSync = false) { + var sortedIndexers = indexers.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase).ToList(); + foreach (var app in applications) { var indexerMappings = _appIndexerMapService.GetMappingsForApp(app.Definition.Id); @@ -155,7 +159,7 @@ namespace NzbDrone.Core.Applications } } - foreach (var indexer in indexers) + foreach (var indexer in sortedIndexers) { var definition = indexer; @@ -163,7 +167,7 @@ namespace NzbDrone.Core.Applications { if (((ApplicationDefinition)app.Definition).SyncLevel == ApplicationSyncLevel.FullSync && ShouldHandleIndexer(app.Definition, indexer)) { - ExecuteAction(a => a.UpdateIndexer(definition), app); + ExecuteAction(a => a.UpdateIndexer(definition, forceSync), app); } } else @@ -200,9 +204,17 @@ namespace NzbDrone.Core.Applications private bool ShouldHandleIndexer(ProviderDefinition app, ProviderDefinition indexer) { + if (!indexer.Settings.Validate().IsValid) + { + _logger.Debug("Indexer {0} [{1}] has invalid settings.", indexer.Name, indexer.Id); + + return false; + } + if (app.Tags.Empty()) { _logger.Debug("No tags set to application {0}.", app.Name); + return true; } @@ -211,10 +223,12 @@ namespace NzbDrone.Core.Applications if (intersectingTags.Any()) { _logger.Debug("Application {0} and indexer {1} [{2}] have {3} intersecting (matching) tags.", app.Name, indexer.Name, indexer.Id, intersectingTags.Length); + return true; } _logger.Debug("Application {0} does not have any intersecting (matching) tags with {1} [{2}]. Indexer will neither be synced to nor removed from the application.", app.Name, indexer.Name, indexer.Id); + return false; } @@ -240,11 +254,11 @@ namespace NzbDrone.Core.Applications if (webException.Message.Contains("502") || webException.Message.Contains("503") || webException.Message.Contains("timed out")) { - _logger.Warn("{0} server is currently unavailable. {1}", this, webException.Message); + _logger.Warn(webException, "{0} server is currently unavailable. {1}", this, webException.Message); } else { - _logger.Warn("{0} {1}", this, webException.Message); + _logger.Warn(webException, "{0} {1}", this, webException.Message); } } catch (TooManyRequestsException ex) @@ -252,12 +266,12 @@ namespace NzbDrone.Core.Applications var minimumBackOff = ex.RetryAfter != TimeSpan.Zero ? ex.RetryAfter : TimeSpan.FromHours(1); _applicationStatusService.RecordFailure(application.Definition.Id, minimumBackOff); - _logger.Warn("API Request Limit reached for {0}", this); + _logger.Warn(ex, "API Request Limit reached for {0}", this); } catch (HttpException ex) { _applicationStatusService.RecordFailure(application.Definition.Id); - _logger.Warn("{0} {1}", this, ex.Message); + _logger.Warn(ex, "{0} {1}", this, ex.Message); } catch (Exception ex) { @@ -289,11 +303,11 @@ namespace NzbDrone.Core.Applications if (webException.Message.Contains("502") || webException.Message.Contains("503") || webException.Message.Contains("timed out")) { - _logger.Warn("{0} server is currently unavailable. {1}", this, webException.Message); + _logger.Warn(webException, "{0} server is currently unavailable. {1}", this, webException.Message); } else { - _logger.Warn("{0} {1}", this, webException.Message); + _logger.Warn(webException, "{0} {1}", this, webException.Message); } } catch (TooManyRequestsException ex) @@ -301,12 +315,12 @@ namespace NzbDrone.Core.Applications var minimumBackOff = ex.RetryAfter != TimeSpan.Zero ? ex.RetryAfter : TimeSpan.FromHours(1); _applicationStatusService.RecordFailure(application.Definition.Id, minimumBackOff); - _logger.Warn("API Request Limit reached for {0}", this); + _logger.Warn(ex, "API Request Limit reached for {0}", this); } catch (HttpException ex) { _applicationStatusService.RecordFailure(application.Definition.Id); - _logger.Warn("{0} {1}", this, ex.Message); + _logger.Warn(ex, "{0} {1}", this, ex.Message); } catch (Exception ex) { diff --git a/src/NzbDrone.Core/Applications/IApplication.cs b/src/NzbDrone.Core/Applications/IApplication.cs index 5dd4572f5..599010ee1 100644 --- a/src/NzbDrone.Core/Applications/IApplication.cs +++ b/src/NzbDrone.Core/Applications/IApplication.cs @@ -7,7 +7,7 @@ namespace NzbDrone.Core.Applications public interface IApplication : IProvider { void AddIndexer(IndexerDefinition indexer); - void UpdateIndexer(IndexerDefinition indexer); + void UpdateIndexer(IndexerDefinition indexer, bool forceSync = false); void RemoveIndexer(int indexerId); List GetIndexerMappings(); } diff --git a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarian.cs b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarian.cs index 7ed59061c..108972d6e 100644 --- a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarian.cs +++ b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarian.cs @@ -16,8 +16,8 @@ namespace NzbDrone.Core.Applications.LazyLibrarian private readonly ILazyLibrarianV1Proxy _lazyLibrarianV1Proxy; private readonly IConfigFileProvider _configFileProvider; - public LazyLibrarian(ILazyLibrarianV1Proxy lazyLibrarianV1Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, Logger logger) - : base(appIndexerMapService, logger) + public LazyLibrarian(ILazyLibrarianV1Proxy lazyLibrarianV1Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger) + : base(appIndexerMapService, indexerFactory, logger) { _lazyLibrarianV1Proxy = lazyLibrarianV1Proxy; _configFileProvider = configFileProvider; @@ -33,7 +33,7 @@ namespace NzbDrone.Core.Applications.LazyLibrarian } catch (Exception ex) { - _logger.Error(ex, "Unable to complete application test"); + _logger.Warn(ex, "Unable to complete application test"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to LazyLibrarian. {ex.Message}")); } @@ -65,7 +65,9 @@ namespace NzbDrone.Core.Applications.LazyLibrarian public override void AddIndexer(IndexerDefinition indexer) { - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty()) + var indexerCapabilities = GetIndexerCapabilities(indexer); + + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty()) { _logger.Trace("Skipping add for indexer {0} [{1}] due to no app Sync Categories supported by the indexer", indexer.Name, indexer.Id); @@ -74,7 +76,7 @@ namespace NzbDrone.Core.Applications.LazyLibrarian _logger.Trace("Adding indexer {0} [{1}]", indexer.Name, indexer.Id); - var lazyLibrarianIndexer = BuildLazyLibrarianIndexer(indexer, indexer.Protocol); + var lazyLibrarianIndexer = BuildLazyLibrarianIndexer(indexer, indexerCapabilities, indexer.Protocol); var remoteIndexer = _lazyLibrarianV1Proxy.AddIndexer(lazyLibrarianIndexer, Settings); @@ -103,25 +105,28 @@ namespace NzbDrone.Core.Applications.LazyLibrarian } } - public override void UpdateIndexer(IndexerDefinition indexer) + public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = false) { _logger.Debug("Updating indexer {0} [{1}]", indexer.Name, indexer.Id); + var indexerCapabilities = GetIndexerCapabilities(indexer); var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id); var indexerProps = indexerMapping.RemoteIndexerName.Split(","); - var lazyLibrarianIndexer = BuildLazyLibrarianIndexer(indexer, indexer.Protocol, indexerProps[1]); + var lazyLibrarianIndexer = BuildLazyLibrarianIndexer(indexer, indexerCapabilities, indexer.Protocol, indexerProps[1]); //Use the old remote id to find the indexer on LazyLibrarian incase the update was from a name change in Prowlarr var remoteIndexer = _lazyLibrarianV1Proxy.GetIndexer(indexerProps[1], lazyLibrarianIndexer.Type, Settings); if (remoteIndexer != null) { - _logger.Debug("Remote indexer found, syncing with current settings"); + _logger.Debug("Remote indexer {0} found", remoteIndexer.Name); - if (!lazyLibrarianIndexer.Equals(remoteIndexer)) + if (!lazyLibrarianIndexer.Equals(remoteIndexer) || forceSync) { + _logger.Debug("Syncing remote indexer with current settings"); + _lazyLibrarianV1Proxy.UpdateIndexer(lazyLibrarianIndexer, Settings); indexerMapping.RemoteIndexerName = $"{lazyLibrarianIndexer.Type},{lazyLibrarianIndexer.Altername}"; _appIndexerMapService.Update(indexerMapping); @@ -131,7 +136,7 @@ namespace NzbDrone.Core.Applications.LazyLibrarian { _appIndexerMapService.Delete(indexerMapping.Id); - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) { _logger.Debug("Remote indexer not found, re-adding {0} [{1}] to LazyLibrarian", indexer.Name, indexer.Id); var newRemoteIndexer = _lazyLibrarianV1Proxy.AddIndexer(lazyLibrarianIndexer, Settings); @@ -144,7 +149,7 @@ namespace NzbDrone.Core.Applications.LazyLibrarian } } - private LazyLibrarianIndexer BuildLazyLibrarianIndexer(IndexerDefinition indexer, DownloadProtocol protocol, string originalName = null) + private LazyLibrarianIndexer BuildLazyLibrarianIndexer(IndexerDefinition indexer, IndexerCapabilities indexerCapabilities, DownloadProtocol protocol, string originalName = null) { var schema = protocol == DownloadProtocol.Usenet ? LazyLibrarianProviderType.Newznab : LazyLibrarianProviderType.Torznab; @@ -154,12 +159,19 @@ namespace NzbDrone.Core.Applications.LazyLibrarian Altername = $"{indexer.Name} (Prowlarr)", Host = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/api", Apikey = _configFileProvider.ApiKey, - Categories = string.Join(",", indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())), + Categories = string.Join(",", indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())), Enabled = indexer.Enable, Type = schema, Priority = indexer.Priority }; + if (indexer.Protocol == DownloadProtocol.Torrent) + { + lazyLibrarianIndexer.MinimumSeeders = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.AppMinimumSeeders ?? indexer.AppProfile.Value.MinimumSeeders; + lazyLibrarianIndexer.SeedRatio = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.SeedRatio.GetValueOrDefault(); + lazyLibrarianIndexer.SeedTime = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.SeedTime.GetValueOrDefault(); + } + return lazyLibrarianIndexer; } } diff --git a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianIndexer.cs b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianIndexer.cs index bc20c3ddf..b13403aac 100644 --- a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianIndexer.cs +++ b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianIndexer.cs @@ -31,6 +31,9 @@ namespace NzbDrone.Core.Applications.LazyLibrarian public string Altername { get; set; } public LazyLibrarianProviderType Type { get; set; } public int Priority { get; set; } + public double SeedRatio { get; set; } + public int SeedTime { get; set; } + public int MinimumSeeders { get; set; } public bool Equals(LazyLibrarianIndexer other) { @@ -45,7 +48,10 @@ namespace NzbDrone.Core.Applications.LazyLibrarian other.Categories == Categories && other.Enabled == Enabled && other.Altername == Altername && - other.Priority == Priority; + other.Priority == Priority && + other.SeedRatio == SeedRatio && + other.SeedTime == SeedTime && + other.MinimumSeeders == MinimumSeeders; } } } diff --git a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianV1Proxy.cs b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianV1Proxy.cs index 563f9ed3b..f4c6da138 100644 --- a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianV1Proxy.cs +++ b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianV1Proxy.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Net.Http; using FluentValidation.Results; @@ -96,6 +97,13 @@ namespace NzbDrone.Core.Applications.LazyLibrarian { "dlpriority", CalculatePriority(indexer.Priority).ToString() } }; + if (indexer.Type == LazyLibrarianProviderType.Torznab) + { + parameters.Add("seeders", indexer.MinimumSeeders.ToString()); + parameters.Add("seed_ratio", indexer.SeedRatio.ToString(CultureInfo.InvariantCulture)); + parameters.Add("seed_duration", indexer.SeedTime.ToString()); + } + var request = BuildRequest(settings, "/api", "addProvider", HttpMethod.Get, parameters); CheckForError(Execute(request)); return indexer; @@ -115,6 +123,13 @@ namespace NzbDrone.Core.Applications.LazyLibrarian { "dlpriority", CalculatePriority(indexer.Priority).ToString() } }; + if (indexer.Type == LazyLibrarianProviderType.Torznab) + { + parameters.Add("seeders", indexer.MinimumSeeders.ToString()); + parameters.Add("seed_ratio", indexer.SeedRatio.ToString(CultureInfo.InvariantCulture)); + parameters.Add("seed_duration", indexer.SeedTime.ToString()); + } + var request = BuildRequest(settings, "/api", "changeProvider", HttpMethod.Get, parameters); CheckForError(Execute(request)); return indexer; diff --git a/src/NzbDrone.Core/Applications/Lidarr/Lidarr.cs b/src/NzbDrone.Core/Applications/Lidarr/Lidarr.cs index 68137dc4d..19c842f5c 100644 --- a/src/NzbDrone.Core/Applications/Lidarr/Lidarr.cs +++ b/src/NzbDrone.Core/Applications/Lidarr/Lidarr.cs @@ -22,8 +22,8 @@ namespace NzbDrone.Core.Applications.Lidarr private readonly ICached> _schemaCache; private readonly IConfigFileProvider _configFileProvider; - public Lidarr(ICacheManager cacheManager, ILidarrV1Proxy lidarrV1Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, Logger logger) - : base(appIndexerMapService, logger) + public Lidarr(ICacheManager cacheManager, ILidarrV1Proxy lidarrV1Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger) + : base(appIndexerMapService, indexerFactory, logger) { _schemaCache = cacheManager.GetCache>(GetType()); _lidarrV1Proxy = lidarrV1Proxy; @@ -49,7 +49,7 @@ namespace NzbDrone.Core.Applications.Lidarr try { - failures.AddIfNotNull(_lidarrV1Proxy.TestConnection(BuildLidarrIndexer(testIndexer, DownloadProtocol.Usenet), Settings)); + failures.AddIfNotNull(_lidarrV1Proxy.TestConnection(BuildLidarrIndexer(testIndexer, testIndexer.Capabilities, DownloadProtocol.Usenet), Settings)); } catch (HttpException ex) { @@ -64,11 +64,12 @@ namespace NzbDrone.Core.Applications.Lidarr failures.AddIfNotNull(new ValidationFailure("ProwlarrUrl", "Prowlarr URL is invalid, Lidarr cannot connect to Prowlarr")); break; case HttpStatusCode.SeeOther: + case HttpStatusCode.TemporaryRedirect: _logger.Warn(ex, "Lidarr returned redirect and is invalid"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Lidarr URL is invalid, Prowlarr cannot connect to Lidarr - are you missing a URL base?")); break; default: - _logger.Error(ex, "Unable to complete application test"); + _logger.Warn(ex, "Unable to complete application test"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Lidarr. {ex.Message}")); break; } @@ -80,7 +81,7 @@ namespace NzbDrone.Core.Applications.Lidarr } catch (Exception ex) { - _logger.Error(ex, "Unable to complete application test"); + _logger.Warn(ex, "Unable to complete application test"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Lidarr. {ex.Message}")); } @@ -96,15 +97,20 @@ namespace NzbDrone.Core.Applications.Lidarr foreach (var indexer in indexers) { - if ((string)indexer.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value == _configFileProvider.ApiKey) - { - var match = AppIndexerRegex.Match((string)indexer.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value); + var baseUrl = (string)indexer.Fields.FirstOrDefault(x => x.Name == "baseUrl")?.Value ?? string.Empty; - if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId)) - { - // Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance - mappings.Add(new AppIndexerMap { IndexerId = indexerId, RemoteIndexerId = indexer.Id }); - } + if (!baseUrl.StartsWith(Settings.ProwlarrUrl.TrimEnd('/')) && + (string)indexer.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value != _configFileProvider.ApiKey) + { + continue; + } + + var match = AppIndexerRegex.Match(baseUrl); + + if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId)) + { + // Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance + mappings.Add(new AppIndexerMap { IndexerId = indexerId, RemoteIndexerId = indexer.Id }); } } @@ -113,7 +119,9 @@ namespace NzbDrone.Core.Applications.Lidarr public override void AddIndexer(IndexerDefinition indexer) { - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty()) + var indexerCapabilities = GetIndexerCapabilities(indexer); + + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty()) { _logger.Trace("Skipping add for indexer {0} [{1}] due to no app Sync Categories supported by the indexer", indexer.Name, indexer.Id); @@ -122,7 +130,7 @@ namespace NzbDrone.Core.Applications.Lidarr _logger.Trace("Adding indexer {0} [{1}]", indexer.Name, indexer.Id); - var lidarrIndexer = BuildLidarrIndexer(indexer, indexer.Protocol); + var lidarrIndexer = BuildLidarrIndexer(indexer, indexerCapabilities, indexer.Protocol); var remoteIndexer = _lidarrV1Proxy.AddIndexer(lidarrIndexer, Settings); @@ -150,24 +158,27 @@ namespace NzbDrone.Core.Applications.Lidarr } } - public override void UpdateIndexer(IndexerDefinition indexer) + public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = false) { _logger.Debug("Updating indexer {0} [{1}]", indexer.Name, indexer.Id); + var indexerCapabilities = GetIndexerCapabilities(indexer); var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id); - var lidarrIndexer = BuildLidarrIndexer(indexer, indexer.Protocol, indexerMapping?.RemoteIndexerId ?? 0); + var lidarrIndexer = BuildLidarrIndexer(indexer, indexerCapabilities, indexer.Protocol, indexerMapping?.RemoteIndexerId ?? 0); var remoteIndexer = _lidarrV1Proxy.GetIndexer(indexerMapping.RemoteIndexerId, Settings); if (remoteIndexer != null) { - _logger.Debug("Remote indexer found, syncing with current settings"); + _logger.Debug("Remote indexer {0} [{1}] found", remoteIndexer.Name, remoteIndexer.Id); - if (!lidarrIndexer.Equals(remoteIndexer)) + if (!lidarrIndexer.Equals(remoteIndexer) || forceSync) { - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + _logger.Debug("Syncing remote indexer with current settings"); + + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) { // Retain user fields not-affiliated with Prowlarr lidarrIndexer.Fields.AddRange(remoteIndexer.Fields.Where(f => lidarrIndexer.Fields.All(s => s.Name != f.Name))); @@ -193,7 +204,7 @@ namespace NzbDrone.Core.Applications.Lidarr { _appIndexerMapService.Delete(indexerMapping.Id); - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) { _logger.Debug("Remote indexer not found, re-adding {0} [{1}] to Lidarr", indexer.Name, indexer.Id); lidarrIndexer.Id = 0; @@ -207,11 +218,11 @@ namespace NzbDrone.Core.Applications.Lidarr } } - private LidarrIndexer BuildLidarrIndexer(IndexerDefinition indexer, DownloadProtocol protocol, int id = 0) + private LidarrIndexer BuildLidarrIndexer(IndexerDefinition indexer, IndexerCapabilities indexerCapabilities, DownloadProtocol protocol, int id = 0) { var cacheKey = $"{Settings.BaseUrl}"; var schemas = _schemaCache.Get(cacheKey, () => _lidarrV1Proxy.GetIndexerSchema(Settings), TimeSpan.FromDays(7)); - var syncFields = new List { "baseUrl", "apiPath", "apiKey", "categories", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime", "seedCriteria.discographySeedTime" }; + var syncFields = new List { "baseUrl", "apiPath", "apiKey", "categories", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime", "seedCriteria.discographySeedTime", "rejectBlocklistedTorrentHashesWhileGrabbing" }; if (id == 0) { @@ -243,7 +254,7 @@ namespace NzbDrone.Core.Applications.Lidarr lidarrIndexer.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/"; lidarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiPath").Value = "/api"; lidarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiKey").Value = _configFileProvider.ApiKey; - lidarrIndexer.Fields.FirstOrDefault(x => x.Name == "categories").Value = JArray.FromObject(indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())); + lidarrIndexer.Fields.FirstOrDefault(x => x.Name == "categories").Value = JArray.FromObject(indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())); if (indexer.Protocol == DownloadProtocol.Torrent) { @@ -251,10 +262,15 @@ namespace NzbDrone.Core.Applications.Lidarr lidarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.SeedRatio; lidarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.SeedTime; - if (lidarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.discographySeedTime") != null) + if (lidarrIndexer.Fields.Any(x => x.Name == "seedCriteria.discographySeedTime")) { lidarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.discographySeedTime").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.PackSeedTime ?? ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.SeedTime; } + + if (lidarrIndexer.Fields.Any(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")) + { + lidarrIndexer.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing").Value = Settings.SyncRejectBlocklistedTorrentHashesWhileGrabbing; + } } return lidarrIndexer; diff --git a/src/NzbDrone.Core/Applications/Lidarr/LidarrField.cs b/src/NzbDrone.Core/Applications/Lidarr/LidarrField.cs index bfbf59dea..9637e48d1 100644 --- a/src/NzbDrone.Core/Applications/Lidarr/LidarrField.cs +++ b/src/NzbDrone.Core/Applications/Lidarr/LidarrField.cs @@ -2,12 +2,7 @@ namespace NzbDrone.Core.Applications.Lidarr { public class LidarrField { - public int Order { get; set; } public string Name { get; set; } - public string Label { get; set; } - public string Unit { get; set; } - public string HelpText { get; set; } - public string HelpLink { get; set; } public object Value { get; set; } public string Type { get; set; } public bool Advanced { get; set; } diff --git a/src/NzbDrone.Core/Applications/Lidarr/LidarrIndexer.cs b/src/NzbDrone.Core/Applications/Lidarr/LidarrIndexer.cs index 7ac02fd45..98c6125ff 100644 --- a/src/NzbDrone.Core/Applications/Lidarr/LidarrIndexer.cs +++ b/src/NzbDrone.Core/Applications/Lidarr/LidarrIndexer.cs @@ -29,9 +29,12 @@ namespace NzbDrone.Core.Applications.Lidarr } var baseUrl = (string)Fields.FirstOrDefault(x => x.Name == "baseUrl").Value == (string)other.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value; - var apiKey = (string)Fields.FirstOrDefault(x => x.Name == "apiKey").Value == (string)other.Fields.FirstOrDefault(x => x.Name == "apiKey").Value; var cats = JToken.DeepEquals((JArray)Fields.FirstOrDefault(x => x.Name == "categories").Value, (JArray)other.Fields.FirstOrDefault(x => x.Name == "categories").Value); + var apiKey = (string)Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value; + var otherApiKey = (string)other.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value; + var apiKeyCompare = apiKey == otherApiKey || otherApiKey == "********"; + var apiPath = Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value == null ? null : Fields.FirstOrDefault(x => x.Name == "apiPath").Value; var otherApiPath = other.Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value == null ? null : other.Fields.FirstOrDefault(x => x.Name == "apiPath").Value; var apiPathCompare = apiPath.Equals(otherApiPath); @@ -52,6 +55,10 @@ namespace NzbDrone.Core.Applications.Lidarr var otherSeedRatio = other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio")?.Value == null ? null : (double?)Convert.ToDouble(other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio").Value); var seedRatioCompare = seedRatio == otherSeedRatio; + var rejectBlocklistedTorrentHashesWhileGrabbing = Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")?.Value == null ? null : (bool?)Convert.ToBoolean(Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing").Value); + var otherRejectBlocklistedTorrentHashesWhileGrabbing = other.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")?.Value == null ? null : (bool?)Convert.ToBoolean(other.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing").Value); + var rejectBlocklistedTorrentHashesWhileGrabbingCompare = rejectBlocklistedTorrentHashesWhileGrabbing == otherRejectBlocklistedTorrentHashesWhileGrabbing; + return other.EnableRss == EnableRss && other.EnableAutomaticSearch == EnableAutomaticSearch && other.EnableInteractiveSearch == EnableInteractiveSearch && @@ -59,7 +66,7 @@ namespace NzbDrone.Core.Applications.Lidarr other.Implementation == Implementation && other.Priority == Priority && other.Id == Id && - apiKey && apiPathCompare && baseUrl && cats && minimumSeedersCompare && seedRatioCompare && seedTimeCompare && discographySeedTimeCompare; + apiKeyCompare && apiPathCompare && baseUrl && cats && minimumSeedersCompare && seedRatioCompare && seedTimeCompare && discographySeedTimeCompare && rejectBlocklistedTorrentHashesWhileGrabbingCompare; } } } diff --git a/src/NzbDrone.Core/Applications/Lidarr/LidarrSettings.cs b/src/NzbDrone.Core/Applications/Lidarr/LidarrSettings.cs index f53f3d90d..0197255a2 100644 --- a/src/NzbDrone.Core/Applications/Lidarr/LidarrSettings.cs +++ b/src/NzbDrone.Core/Applications/Lidarr/LidarrSettings.cs @@ -36,9 +36,12 @@ namespace NzbDrone.Core.Applications.Lidarr [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Lidarr in Settings/General")] public string ApiKey { get; set; } - [FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), Advanced = true, HelpText = "Only Indexers that support these categories will be synced")] + [FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), HelpText = "Only Indexers that support these categories will be synced", Advanced = true)] public IEnumerable SyncCategories { get; set; } + [FieldDefinition(4, Type = FieldType.Checkbox, Label = "ApplicationSettingsSyncRejectBlocklistedTorrentHashes", HelpText = "ApplicationSettingsSyncRejectBlocklistedTorrentHashesHelpText", Advanced = true)] + public bool SyncRejectBlocklistedTorrentHashesWhileGrabbing { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Applications/Lidarr/LidarrV1Proxy.cs b/src/NzbDrone.Core/Applications/Lidarr/LidarrV1Proxy.cs index 40aeaf3c0..3fcb337c0 100644 --- a/src/NzbDrone.Core/Applications/Lidarr/LidarrV1Proxy.cs +++ b/src/NzbDrone.Core/Applications/Lidarr/LidarrV1Proxy.cs @@ -84,6 +84,7 @@ namespace NzbDrone.Core.Applications.Lidarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Post); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); try { @@ -103,10 +104,21 @@ namespace NzbDrone.Core.Applications.Lidarr { var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put); - request.Url = request.Url.AddQueryParam("forceSave", "true"); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); - return ExecuteIndexerRequest(request); + try + { + return ExecuteIndexerRequest(request); + } + catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest) + { + _logger.Debug("Retrying to update indexer forcefully"); + + request.Url = request.Url.AddQueryParam("forceSave", "true"); + + return ExecuteIndexerRequest(request); + } } public ValidationFailure TestConnection(LidarrIndexer indexer, LidarrSettings settings) @@ -114,6 +126,7 @@ namespace NzbDrone.Core.Applications.Lidarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}/test", HttpMethod.Post); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version"); @@ -153,6 +166,7 @@ namespace NzbDrone.Core.Applications.Lidarr _logger.Error(ex, "Invalid Request"); break; case HttpStatusCode.SeeOther: + case HttpStatusCode.TemporaryRedirect: _logger.Warn(ex, "App returned redirect and is invalid. Check App URL"); break; case HttpStatusCode.NotFound: diff --git a/src/NzbDrone.Core/Applications/Mylar/Mylar.cs b/src/NzbDrone.Core/Applications/Mylar/Mylar.cs index 7d84feae1..e9fd9ffe7 100644 --- a/src/NzbDrone.Core/Applications/Mylar/Mylar.cs +++ b/src/NzbDrone.Core/Applications/Mylar/Mylar.cs @@ -16,8 +16,8 @@ namespace NzbDrone.Core.Applications.Mylar private readonly IMylarV3Proxy _mylarV3Proxy; private readonly IConfigFileProvider _configFileProvider; - public Mylar(IMylarV3Proxy mylarV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, Logger logger) - : base(appIndexerMapService, logger) + public Mylar(IMylarV3Proxy mylarV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger) + : base(appIndexerMapService, indexerFactory, logger) { _mylarV3Proxy = mylarV3Proxy; _configFileProvider = configFileProvider; @@ -33,7 +33,7 @@ namespace NzbDrone.Core.Applications.Mylar } catch (Exception ex) { - _logger.Error(ex, "Unable to complete application test"); + _logger.Warn(ex, "Unable to complete application test"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Mylar. {ex.Message}")); } @@ -65,7 +65,9 @@ namespace NzbDrone.Core.Applications.Mylar public override void AddIndexer(IndexerDefinition indexer) { - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty()) + var indexerCapabilities = GetIndexerCapabilities(indexer); + + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty()) { _logger.Trace("Skipping add for indexer {0} [{1}] due to no app Sync Categories supported by the indexer", indexer.Name, indexer.Id); @@ -74,7 +76,7 @@ namespace NzbDrone.Core.Applications.Mylar _logger.Trace("Adding indexer {0} [{1}]", indexer.Name, indexer.Id); - var mylarIndexer = BuildMylarIndexer(indexer, indexer.Protocol); + var mylarIndexer = BuildMylarIndexer(indexer, indexerCapabilities, indexer.Protocol); var remoteIndexer = _mylarV3Proxy.AddIndexer(mylarIndexer, Settings); @@ -103,25 +105,28 @@ namespace NzbDrone.Core.Applications.Mylar } } - public override void UpdateIndexer(IndexerDefinition indexer) + public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = false) { _logger.Debug("Updating indexer {0} [{1}]", indexer.Name, indexer.Id); + var indexerCapabilities = GetIndexerCapabilities(indexer); var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id); var indexerProps = indexerMapping.RemoteIndexerName.Split(","); - var mylarIndexer = BuildMylarIndexer(indexer, indexer.Protocol, indexerProps[1]); + var mylarIndexer = BuildMylarIndexer(indexer, indexerCapabilities, indexer.Protocol, indexerProps[1]); //Use the old remote id to find the indexer on Mylar incase the update was from a name change in Prowlarr var remoteIndexer = _mylarV3Proxy.GetIndexer(indexerProps[1], mylarIndexer.Type, Settings); if (remoteIndexer != null) { - _logger.Debug("Remote indexer found, syncing with current settings"); + _logger.Debug("Remote indexer {0} found", remoteIndexer.Name); - if (!mylarIndexer.Equals(remoteIndexer)) + if (!mylarIndexer.Equals(remoteIndexer) || forceSync) { + _logger.Debug("Syncing remote indexer with current settings"); + _mylarV3Proxy.UpdateIndexer(mylarIndexer, Settings); indexerMapping.RemoteIndexerName = $"{mylarIndexer.Type},{mylarIndexer.Altername}"; _appIndexerMapService.Update(indexerMapping); @@ -131,7 +136,7 @@ namespace NzbDrone.Core.Applications.Mylar { _appIndexerMapService.Delete(indexerMapping.Id); - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) { _logger.Debug("Remote indexer not found, re-adding {0} [{1}] to Mylar", indexer.Name, indexer.Id); var newRemoteIndexer = _mylarV3Proxy.AddIndexer(mylarIndexer, Settings); @@ -144,7 +149,7 @@ namespace NzbDrone.Core.Applications.Mylar } } - private MylarIndexer BuildMylarIndexer(IndexerDefinition indexer, DownloadProtocol protocol, string originalName = null) + private MylarIndexer BuildMylarIndexer(IndexerDefinition indexer, IndexerCapabilities indexerCapabilities, DownloadProtocol protocol, string originalName = null) { var schema = protocol == DownloadProtocol.Usenet ? MylarProviderType.Newznab : MylarProviderType.Torznab; @@ -154,7 +159,7 @@ namespace NzbDrone.Core.Applications.Mylar Altername = $"{indexer.Name} (Prowlarr)", Host = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/api", Apikey = _configFileProvider.ApiKey, - Categories = string.Join(",", indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())), + Categories = string.Join(",", indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())), Enabled = indexer.Enable, Type = schema, }; diff --git a/src/NzbDrone.Core/Applications/Radarr/Radarr.cs b/src/NzbDrone.Core/Applications/Radarr/Radarr.cs index a3d63317f..85b9c4a3b 100644 --- a/src/NzbDrone.Core/Applications/Radarr/Radarr.cs +++ b/src/NzbDrone.Core/Applications/Radarr/Radarr.cs @@ -22,8 +22,8 @@ namespace NzbDrone.Core.Applications.Radarr private readonly ICached> _schemaCache; private readonly IConfigFileProvider _configFileProvider; - public Radarr(ICacheManager cacheManager, IRadarrV3Proxy radarrV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, Logger logger) - : base(appIndexerMapService, logger) + public Radarr(ICacheManager cacheManager, IRadarrV3Proxy radarrV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger) + : base(appIndexerMapService, indexerFactory, logger) { _schemaCache = cacheManager.GetCache>(GetType()); _radarrV3Proxy = radarrV3Proxy; @@ -49,7 +49,7 @@ namespace NzbDrone.Core.Applications.Radarr try { - failures.AddIfNotNull(_radarrV3Proxy.TestConnection(BuildRadarrIndexer(testIndexer, DownloadProtocol.Usenet), Settings)); + failures.AddIfNotNull(_radarrV3Proxy.TestConnection(BuildRadarrIndexer(testIndexer, testIndexer.Capabilities, DownloadProtocol.Usenet), Settings)); } catch (HttpException ex) { @@ -64,11 +64,12 @@ namespace NzbDrone.Core.Applications.Radarr failures.AddIfNotNull(new ValidationFailure("ProwlarrUrl", "Prowlarr URL is invalid, Radarr cannot connect to Prowlarr")); break; case HttpStatusCode.SeeOther: + case HttpStatusCode.TemporaryRedirect: _logger.Warn(ex, "Radarr returned redirect and is invalid"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Radarr URL is invalid, Prowlarr cannot connect to Radarr - are you missing a URL base?")); break; default: - _logger.Error(ex, "Unable to complete application test"); + _logger.Warn(ex, "Unable to complete application test"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Radarr. {ex.Message}")); break; } @@ -80,7 +81,7 @@ namespace NzbDrone.Core.Applications.Radarr } catch (Exception ex) { - _logger.Error(ex, "Unable to complete application test"); + _logger.Warn(ex, "Unable to complete application test"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Radarr. {ex.Message}")); } @@ -96,15 +97,20 @@ namespace NzbDrone.Core.Applications.Radarr foreach (var indexer in indexers) { - if ((string)indexer.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value == _configFileProvider.ApiKey) - { - var match = AppIndexerRegex.Match((string)indexer.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value); + var baseUrl = (string)indexer.Fields.FirstOrDefault(x => x.Name == "baseUrl")?.Value ?? string.Empty; - if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId)) - { - // Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance - mappings.Add(new AppIndexerMap { IndexerId = indexerId, RemoteIndexerId = indexer.Id }); - } + if (!baseUrl.StartsWith(Settings.ProwlarrUrl.TrimEnd('/')) && + (string)indexer.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value != _configFileProvider.ApiKey) + { + continue; + } + + var match = AppIndexerRegex.Match(baseUrl); + + if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId)) + { + // Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance + mappings.Add(new AppIndexerMap { IndexerId = indexerId, RemoteIndexerId = indexer.Id }); } } @@ -113,7 +119,9 @@ namespace NzbDrone.Core.Applications.Radarr public override void AddIndexer(IndexerDefinition indexer) { - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty()) + var indexerCapabilities = GetIndexerCapabilities(indexer); + + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty()) { _logger.Trace("Skipping add for indexer {0} [{1}] due to no app Sync Categories supported by the indexer", indexer.Name, indexer.Id); @@ -122,7 +130,7 @@ namespace NzbDrone.Core.Applications.Radarr _logger.Trace("Adding indexer {0} [{1}]", indexer.Name, indexer.Id); - var radarrIndexer = BuildRadarrIndexer(indexer, indexer.Protocol); + var radarrIndexer = BuildRadarrIndexer(indexer, indexerCapabilities, indexer.Protocol); var remoteIndexer = _radarrV3Proxy.AddIndexer(radarrIndexer, Settings); @@ -150,24 +158,25 @@ namespace NzbDrone.Core.Applications.Radarr } } - public override void UpdateIndexer(IndexerDefinition indexer) + public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = false) { _logger.Debug("Updating indexer {0} [{1}]", indexer.Name, indexer.Id); + var indexerCapabilities = GetIndexerCapabilities(indexer); var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id); - var radarrIndexer = BuildRadarrIndexer(indexer, indexer.Protocol, indexerMapping?.RemoteIndexerId ?? 0); + var radarrIndexer = BuildRadarrIndexer(indexer, indexerCapabilities, indexer.Protocol, indexerMapping?.RemoteIndexerId ?? 0); var remoteIndexer = _radarrV3Proxy.GetIndexer(indexerMapping.RemoteIndexerId, Settings); if (remoteIndexer != null) { - _logger.Debug("Remote indexer found, syncing with current settings"); + _logger.Debug("Remote indexer {0} [{1}] found", remoteIndexer.Name, remoteIndexer.Id); - if (!radarrIndexer.Equals(remoteIndexer)) + if (!radarrIndexer.Equals(remoteIndexer) || forceSync) { - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) { // Retain user fields not-affiliated with Prowlarr radarrIndexer.Fields.AddRange(remoteIndexer.Fields.Where(f => radarrIndexer.Fields.All(s => s.Name != f.Name))); @@ -193,7 +202,7 @@ namespace NzbDrone.Core.Applications.Radarr { _appIndexerMapService.Delete(indexerMapping.Id); - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) { _logger.Debug("Remote indexer not found, re-adding {0} [{1}] to Radarr", indexer.Name, indexer.Id); radarrIndexer.Id = 0; @@ -207,11 +216,11 @@ namespace NzbDrone.Core.Applications.Radarr } } - private RadarrIndexer BuildRadarrIndexer(IndexerDefinition indexer, DownloadProtocol protocol, int id = 0) + private RadarrIndexer BuildRadarrIndexer(IndexerDefinition indexer, IndexerCapabilities indexerCapabilities, DownloadProtocol protocol, int id = 0) { var cacheKey = $"{Settings.BaseUrl}"; var schemas = _schemaCache.Get(cacheKey, () => _radarrV3Proxy.GetIndexerSchema(Settings), TimeSpan.FromDays(7)); - var syncFields = new List { "baseUrl", "apiPath", "apiKey", "categories", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime" }; + var syncFields = new List { "baseUrl", "apiPath", "apiKey", "categories", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime", "rejectBlocklistedTorrentHashesWhileGrabbing" }; if (id == 0) { @@ -243,13 +252,18 @@ namespace NzbDrone.Core.Applications.Radarr radarrIndexer.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/"; radarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiPath").Value = "/api"; radarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiKey").Value = _configFileProvider.ApiKey; - radarrIndexer.Fields.FirstOrDefault(x => x.Name == "categories").Value = JArray.FromObject(indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())); + radarrIndexer.Fields.FirstOrDefault(x => x.Name == "categories").Value = JArray.FromObject(indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())); if (indexer.Protocol == DownloadProtocol.Torrent) { radarrIndexer.Fields.FirstOrDefault(x => x.Name == "minimumSeeders").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.AppMinimumSeeders ?? indexer.AppProfile.Value.MinimumSeeders; radarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.SeedRatio; radarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.SeedTime; + + if (radarrIndexer.Fields.Any(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")) + { + radarrIndexer.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing").Value = Settings.SyncRejectBlocklistedTorrentHashesWhileGrabbing; + } } return radarrIndexer; diff --git a/src/NzbDrone.Core/Applications/Radarr/RadarrField.cs b/src/NzbDrone.Core/Applications/Radarr/RadarrField.cs index 322b97116..fbafc3b9c 100644 --- a/src/NzbDrone.Core/Applications/Radarr/RadarrField.cs +++ b/src/NzbDrone.Core/Applications/Radarr/RadarrField.cs @@ -2,12 +2,7 @@ namespace NzbDrone.Core.Applications.Radarr { public class RadarrField { - public int Order { get; set; } public string Name { get; set; } - public string Label { get; set; } - public string Unit { get; set; } - public string HelpText { get; set; } - public string HelpLink { get; set; } public object Value { get; set; } public string Type { get; set; } public bool Advanced { get; set; } diff --git a/src/NzbDrone.Core/Applications/Radarr/RadarrIndexer.cs b/src/NzbDrone.Core/Applications/Radarr/RadarrIndexer.cs index 38082724e..3ae820f3a 100644 --- a/src/NzbDrone.Core/Applications/Radarr/RadarrIndexer.cs +++ b/src/NzbDrone.Core/Applications/Radarr/RadarrIndexer.cs @@ -29,9 +29,12 @@ namespace NzbDrone.Core.Applications.Radarr } var baseUrl = (string)Fields.FirstOrDefault(x => x.Name == "baseUrl").Value == (string)other.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value; - var apiKey = (string)Fields.FirstOrDefault(x => x.Name == "apiKey").Value == (string)other.Fields.FirstOrDefault(x => x.Name == "apiKey").Value; var cats = JToken.DeepEquals((JArray)Fields.FirstOrDefault(x => x.Name == "categories").Value, (JArray)other.Fields.FirstOrDefault(x => x.Name == "categories").Value); + var apiKey = (string)Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value; + var otherApiKey = (string)other.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value; + var apiKeyCompare = apiKey == otherApiKey || otherApiKey == "********"; + var apiPath = Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value == null ? null : Fields.FirstOrDefault(x => x.Name == "apiPath").Value; var otherApiPath = other.Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value == null ? null : other.Fields.FirstOrDefault(x => x.Name == "apiPath").Value; var apiPathCompare = apiPath.Equals(otherApiPath); @@ -48,6 +51,10 @@ namespace NzbDrone.Core.Applications.Radarr var otherSeedRatio = other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio")?.Value == null ? null : (double?)Convert.ToDouble(other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio").Value); var seedRatioCompare = seedRatio == otherSeedRatio; + var rejectBlocklistedTorrentHashesWhileGrabbing = Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")?.Value == null ? null : (bool?)Convert.ToBoolean(Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing").Value); + var otherRejectBlocklistedTorrentHashesWhileGrabbing = other.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")?.Value == null ? null : (bool?)Convert.ToBoolean(other.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing").Value); + var rejectBlocklistedTorrentHashesWhileGrabbingCompare = rejectBlocklistedTorrentHashesWhileGrabbing == otherRejectBlocklistedTorrentHashesWhileGrabbing; + return other.EnableRss == EnableRss && other.EnableAutomaticSearch == EnableAutomaticSearch && other.EnableInteractiveSearch == EnableInteractiveSearch && @@ -55,7 +62,7 @@ namespace NzbDrone.Core.Applications.Radarr other.Implementation == Implementation && other.Priority == Priority && other.Id == Id && - apiKey && apiPathCompare && baseUrl && cats && minimumSeedersCompare && seedRatioCompare && seedTimeCompare; + apiKeyCompare && apiPathCompare && baseUrl && cats && minimumSeedersCompare && seedRatioCompare && seedTimeCompare && rejectBlocklistedTorrentHashesWhileGrabbingCompare; } } } diff --git a/src/NzbDrone.Core/Applications/Radarr/RadarrSettings.cs b/src/NzbDrone.Core/Applications/Radarr/RadarrSettings.cs index 3736b38f2..457d7d0df 100644 --- a/src/NzbDrone.Core/Applications/Radarr/RadarrSettings.cs +++ b/src/NzbDrone.Core/Applications/Radarr/RadarrSettings.cs @@ -37,9 +37,12 @@ namespace NzbDrone.Core.Applications.Radarr [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Radarr in Settings/General")] public string ApiKey { get; set; } - [FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), Advanced = true, HelpText = "Only Indexers that support these categories will be synced")] + [FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), HelpText = "Only Indexers that support these categories will be synced", Advanced = true)] public IEnumerable SyncCategories { get; set; } + [FieldDefinition(4, Type = FieldType.Checkbox, Label = "ApplicationSettingsSyncRejectBlocklistedTorrentHashes", HelpText = "ApplicationSettingsSyncRejectBlocklistedTorrentHashesHelpText", Advanced = true)] + public bool SyncRejectBlocklistedTorrentHashesWhileGrabbing { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Applications/Radarr/RadarrV3Proxy.cs b/src/NzbDrone.Core/Applications/Radarr/RadarrV3Proxy.cs index 6e23077ae..d431856aa 100644 --- a/src/NzbDrone.Core/Applications/Radarr/RadarrV3Proxy.cs +++ b/src/NzbDrone.Core/Applications/Radarr/RadarrV3Proxy.cs @@ -85,6 +85,7 @@ namespace NzbDrone.Core.Applications.Radarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Post); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); try { @@ -104,10 +105,21 @@ namespace NzbDrone.Core.Applications.Radarr { var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put); - request.Url = request.Url.AddQueryParam("forceSave", "true"); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); - return ExecuteIndexerRequest(request); + try + { + return ExecuteIndexerRequest(request); + } + catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest) + { + _logger.Debug("Retrying to update indexer forcefully"); + + request.Url = request.Url.AddQueryParam("forceSave", "true"); + + return ExecuteIndexerRequest(request); + } } public ValidationFailure TestConnection(RadarrIndexer indexer, RadarrSettings settings) @@ -115,6 +127,7 @@ namespace NzbDrone.Core.Applications.Radarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}/test", HttpMethod.Post); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version"); @@ -166,6 +179,7 @@ namespace NzbDrone.Core.Applications.Radarr _logger.Error(ex, "Invalid Request"); break; case HttpStatusCode.SeeOther: + case HttpStatusCode.TemporaryRedirect: _logger.Warn(ex, "App returned redirect and is invalid. Check App URL"); break; case HttpStatusCode.NotFound: diff --git a/src/NzbDrone.Core/Applications/Readarr/Readarr.cs b/src/NzbDrone.Core/Applications/Readarr/Readarr.cs index 0860c4c6f..1fc6742ae 100644 --- a/src/NzbDrone.Core/Applications/Readarr/Readarr.cs +++ b/src/NzbDrone.Core/Applications/Readarr/Readarr.cs @@ -22,8 +22,8 @@ namespace NzbDrone.Core.Applications.Readarr private readonly IReadarrV1Proxy _readarrV1Proxy; private readonly IConfigFileProvider _configFileProvider; - public Readarr(ICacheManager cacheManager, IReadarrV1Proxy readarrV1Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, Logger logger) - : base(appIndexerMapService, logger) + public Readarr(ICacheManager cacheManager, IReadarrV1Proxy readarrV1Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger) + : base(appIndexerMapService, indexerFactory, logger) { _schemaCache = cacheManager.GetCache>(GetType()); _readarrV1Proxy = readarrV1Proxy; @@ -49,7 +49,7 @@ namespace NzbDrone.Core.Applications.Readarr try { - failures.AddIfNotNull(_readarrV1Proxy.TestConnection(BuildReadarrIndexer(testIndexer, DownloadProtocol.Usenet), Settings)); + failures.AddIfNotNull(_readarrV1Proxy.TestConnection(BuildReadarrIndexer(testIndexer, testIndexer.Capabilities, DownloadProtocol.Usenet), Settings)); } catch (HttpException ex) { @@ -64,11 +64,12 @@ namespace NzbDrone.Core.Applications.Readarr failures.AddIfNotNull(new ValidationFailure("ProwlarrUrl", "Prowlarr URL is invalid, Readarr cannot connect to Prowlarr")); break; case HttpStatusCode.SeeOther: + case HttpStatusCode.TemporaryRedirect: _logger.Warn(ex, "Readarr returned redirect and is invalid"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Readarr URL is invalid, Prowlarr cannot connect to Readarr - are you missing a URL base?")); break; default: - _logger.Error(ex, "Unable to complete application test"); + _logger.Warn(ex, "Unable to complete application test"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Readarr. {ex.Message}")); break; } @@ -80,7 +81,7 @@ namespace NzbDrone.Core.Applications.Readarr } catch (Exception ex) { - _logger.Error(ex, "Unable to complete application test"); + _logger.Warn(ex, "Unable to complete application test"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Readarr. {ex.Message}")); } @@ -90,21 +91,26 @@ namespace NzbDrone.Core.Applications.Readarr public override List GetIndexerMappings() { var indexers = _readarrV1Proxy.GetIndexers(Settings) - .Where(i => i.Implementation == "Newznab" || i.Implementation == "Torznab"); + .Where(i => i.Implementation is "Newznab" or "Torznab"); var mappings = new List(); foreach (var indexer in indexers) { - if ((string)indexer.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value == _configFileProvider.ApiKey) - { - var match = AppIndexerRegex.Match((string)indexer.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value); + var baseUrl = (string)indexer.Fields.FirstOrDefault(x => x.Name == "baseUrl")?.Value ?? string.Empty; - if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId)) - { - // Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance - mappings.Add(new AppIndexerMap { IndexerId = indexerId, RemoteIndexerId = indexer.Id }); - } + if (!baseUrl.StartsWith(Settings.ProwlarrUrl.TrimEnd('/')) && + (string)indexer.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value != _configFileProvider.ApiKey) + { + continue; + } + + var match = AppIndexerRegex.Match(baseUrl); + + if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId)) + { + // Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance + mappings.Add(new AppIndexerMap { IndexerId = indexerId, RemoteIndexerId = indexer.Id }); } } @@ -113,7 +119,9 @@ namespace NzbDrone.Core.Applications.Readarr public override void AddIndexer(IndexerDefinition indexer) { - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty()) + var indexerCapabilities = GetIndexerCapabilities(indexer); + + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty()) { _logger.Trace("Skipping add for indexer {0} [{1}] due to no app Sync Categories supported by the indexer", indexer.Name, indexer.Id); @@ -122,7 +130,7 @@ namespace NzbDrone.Core.Applications.Readarr _logger.Trace("Adding indexer {0} [{1}]", indexer.Name, indexer.Id); - var readarrIndexer = BuildReadarrIndexer(indexer, indexer.Protocol); + var readarrIndexer = BuildReadarrIndexer(indexer, indexerCapabilities, indexer.Protocol); var remoteIndexer = _readarrV1Proxy.AddIndexer(readarrIndexer, Settings); @@ -150,24 +158,27 @@ namespace NzbDrone.Core.Applications.Readarr } } - public override void UpdateIndexer(IndexerDefinition indexer) + public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = false) { _logger.Debug("Updating indexer {0} [{1}]", indexer.Name, indexer.Id); + var indexerCapabilities = GetIndexerCapabilities(indexer); var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id); - var readarrIndexer = BuildReadarrIndexer(indexer, indexer.Protocol, indexerMapping?.RemoteIndexerId ?? 0); + var readarrIndexer = BuildReadarrIndexer(indexer, indexerCapabilities, indexer.Protocol, indexerMapping?.RemoteIndexerId ?? 0); var remoteIndexer = _readarrV1Proxy.GetIndexer(indexerMapping.RemoteIndexerId, Settings); if (remoteIndexer != null) { - _logger.Debug("Remote indexer found, syncing with current settings"); + _logger.Debug("Remote indexer {0} [{1}] found", remoteIndexer.Name, remoteIndexer.Id); - if (!readarrIndexer.Equals(remoteIndexer)) + if (!readarrIndexer.Equals(remoteIndexer) || forceSync) { - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + _logger.Debug("Syncing remote indexer with current settings"); + + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) { // Retain user fields not-affiliated with Prowlarr readarrIndexer.Fields.AddRange(remoteIndexer.Fields.Where(f => readarrIndexer.Fields.All(s => s.Name != f.Name))); @@ -175,6 +186,9 @@ namespace NzbDrone.Core.Applications.Readarr // Retain user tags not-affiliated with Prowlarr readarrIndexer.Tags.UnionWith(remoteIndexer.Tags); + // Retain user settings not-affiliated with Prowlarr + readarrIndexer.DownloadClientId = remoteIndexer.DownloadClientId; + // Update the indexer if it still has categories that match _readarrV1Proxy.UpdateIndexer(readarrIndexer, Settings); } @@ -190,7 +204,7 @@ namespace NzbDrone.Core.Applications.Readarr { _appIndexerMapService.Delete(indexerMapping.Id); - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) { _logger.Debug("Remote indexer not found, re-adding {0} [{1}] to Readarr", indexer.Name, indexer.Id); readarrIndexer.Id = 0; @@ -204,11 +218,11 @@ namespace NzbDrone.Core.Applications.Readarr } } - private ReadarrIndexer BuildReadarrIndexer(IndexerDefinition indexer, DownloadProtocol protocol, int id = 0) + private ReadarrIndexer BuildReadarrIndexer(IndexerDefinition indexer, IndexerCapabilities indexerCapabilities, DownloadProtocol protocol, int id = 0) { var cacheKey = $"{Settings.BaseUrl}"; var schemas = _schemaCache.Get(cacheKey, () => _readarrV1Proxy.GetIndexerSchema(Settings), TimeSpan.FromDays(7)); - var syncFields = new[] { "baseUrl", "apiPath", "apiKey", "categories", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime", "seedCriteria.discographySeedTime" }; + var syncFields = new[] { "baseUrl", "apiPath", "apiKey", "categories", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime", "seedCriteria.discographySeedTime", "rejectBlocklistedTorrentHashesWhileGrabbing" }; var newznab = schemas.First(i => i.Implementation == "Newznab"); var torznab = schemas.First(i => i.Implementation == "Torznab"); @@ -234,7 +248,7 @@ namespace NzbDrone.Core.Applications.Readarr readarrIndexer.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/"; readarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiPath").Value = "/api"; readarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiKey").Value = _configFileProvider.ApiKey; - readarrIndexer.Fields.FirstOrDefault(x => x.Name == "categories").Value = JArray.FromObject(indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())); + readarrIndexer.Fields.FirstOrDefault(x => x.Name == "categories").Value = JArray.FromObject(indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())); if (indexer.Protocol == DownloadProtocol.Torrent) { @@ -242,10 +256,15 @@ namespace NzbDrone.Core.Applications.Readarr readarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.SeedRatio; readarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.SeedTime; - if (readarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.discographySeedTime") != null) + if (readarrIndexer.Fields.Any(x => x.Name == "seedCriteria.discographySeedTime")) { readarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.discographySeedTime").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.PackSeedTime ?? ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.SeedTime; } + + if (readarrIndexer.Fields.Any(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")) + { + readarrIndexer.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing").Value = Settings.SyncRejectBlocklistedTorrentHashesWhileGrabbing; + } } return readarrIndexer; diff --git a/src/NzbDrone.Core/Applications/Readarr/ReadarrField.cs b/src/NzbDrone.Core/Applications/Readarr/ReadarrField.cs index c615b9938..587559cfd 100644 --- a/src/NzbDrone.Core/Applications/Readarr/ReadarrField.cs +++ b/src/NzbDrone.Core/Applications/Readarr/ReadarrField.cs @@ -2,12 +2,7 @@ namespace NzbDrone.Core.Applications.Readarr { public class ReadarrField { - public int Order { get; set; } public string Name { get; set; } - public string Label { get; set; } - public string Unit { get; set; } - public string HelpText { get; set; } - public string HelpLink { get; set; } public object Value { get; set; } public string Type { get; set; } public bool Advanced { get; set; } diff --git a/src/NzbDrone.Core/Applications/Readarr/ReadarrIndexer.cs b/src/NzbDrone.Core/Applications/Readarr/ReadarrIndexer.cs index e4683ae01..b26b50ae0 100644 --- a/src/NzbDrone.Core/Applications/Readarr/ReadarrIndexer.cs +++ b/src/NzbDrone.Core/Applications/Readarr/ReadarrIndexer.cs @@ -17,6 +17,7 @@ namespace NzbDrone.Core.Applications.Readarr public string Implementation { get; set; } public string ConfigContract { get; set; } public string InfoLink { get; set; } + public int? DownloadClientId { get; set; } public HashSet Tags { get; set; } public List Fields { get; set; } @@ -28,9 +29,12 @@ namespace NzbDrone.Core.Applications.Readarr } var baseUrl = (string)Fields.FirstOrDefault(x => x.Name == "baseUrl").Value == (string)other.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value; - var apiKey = (string)Fields.FirstOrDefault(x => x.Name == "apiKey").Value == (string)other.Fields.FirstOrDefault(x => x.Name == "apiKey").Value; var cats = JToken.DeepEquals((JArray)Fields.FirstOrDefault(x => x.Name == "categories").Value, (JArray)other.Fields.FirstOrDefault(x => x.Name == "categories").Value); + var apiKey = (string)Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value; + var otherApiKey = (string)other.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value; + var apiKeyCompare = apiKey == otherApiKey || otherApiKey == "********"; + var apiPath = Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value == null ? null : Fields.FirstOrDefault(x => x.Name == "apiPath").Value; var otherApiPath = other.Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value == null ? null : other.Fields.FirstOrDefault(x => x.Name == "apiPath").Value; var apiPathCompare = apiPath.Equals(otherApiPath); @@ -51,6 +55,10 @@ namespace NzbDrone.Core.Applications.Readarr var otherSeedRatio = other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio")?.Value == null ? null : (double?)Convert.ToDouble(other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio").Value); var seedRatioCompare = seedRatio == otherSeedRatio; + var rejectBlocklistedTorrentHashesWhileGrabbing = Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")?.Value == null ? null : (bool?)Convert.ToBoolean(Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing").Value); + var otherRejectBlocklistedTorrentHashesWhileGrabbing = other.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")?.Value == null ? null : (bool?)Convert.ToBoolean(other.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing").Value); + var rejectBlocklistedTorrentHashesWhileGrabbingCompare = rejectBlocklistedTorrentHashesWhileGrabbing == otherRejectBlocklistedTorrentHashesWhileGrabbing; + return other.EnableRss == EnableRss && other.EnableAutomaticSearch == EnableAutomaticSearch && other.EnableInteractiveSearch == EnableInteractiveSearch && @@ -58,7 +66,7 @@ namespace NzbDrone.Core.Applications.Readarr other.Implementation == Implementation && other.Priority == Priority && other.Id == Id && - apiKey && apiPathCompare && baseUrl && cats && minimumSeedersCompare && seedRatioCompare && seedTimeCompare && discographySeedTimeCompare; + apiKeyCompare && apiPathCompare && baseUrl && cats && minimumSeedersCompare && seedRatioCompare && seedTimeCompare && discographySeedTimeCompare && rejectBlocklistedTorrentHashesWhileGrabbingCompare; } } } diff --git a/src/NzbDrone.Core/Applications/Readarr/ReadarrSettings.cs b/src/NzbDrone.Core/Applications/Readarr/ReadarrSettings.cs index 55a7014bd..f789586d3 100644 --- a/src/NzbDrone.Core/Applications/Readarr/ReadarrSettings.cs +++ b/src/NzbDrone.Core/Applications/Readarr/ReadarrSettings.cs @@ -37,9 +37,12 @@ namespace NzbDrone.Core.Applications.Readarr [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Readarr in Settings/General")] public string ApiKey { get; set; } - [FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), Advanced = true, HelpText = "Only Indexers that support these categories will be synced")] + [FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), HelpText = "Only Indexers that support these categories will be synced", Advanced = true)] public IEnumerable SyncCategories { get; set; } + [FieldDefinition(4, Type = FieldType.Checkbox, Label = "ApplicationSettingsSyncRejectBlocklistedTorrentHashes", HelpText = "ApplicationSettingsSyncRejectBlocklistedTorrentHashesHelpText", Advanced = true)] + public bool SyncRejectBlocklistedTorrentHashesWhileGrabbing { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Applications/Readarr/ReadarrV1Proxy.cs b/src/NzbDrone.Core/Applications/Readarr/ReadarrV1Proxy.cs index 01302eca7..899ef79b6 100644 --- a/src/NzbDrone.Core/Applications/Readarr/ReadarrV1Proxy.cs +++ b/src/NzbDrone.Core/Applications/Readarr/ReadarrV1Proxy.cs @@ -81,6 +81,7 @@ namespace NzbDrone.Core.Applications.Readarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Post); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); try { @@ -100,10 +101,21 @@ namespace NzbDrone.Core.Applications.Readarr { var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put); - request.Url = request.Url.AddQueryParam("forceSave", "true"); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); - return ExecuteIndexerRequest(request); + try + { + return ExecuteIndexerRequest(request); + } + catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest) + { + _logger.Debug("Retrying to update indexer forcefully"); + + request.Url = request.Url.AddQueryParam("forceSave", "true"); + + return ExecuteIndexerRequest(request); + } } public ValidationFailure TestConnection(ReadarrIndexer indexer, ReadarrSettings settings) @@ -111,6 +123,7 @@ namespace NzbDrone.Core.Applications.Readarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}/test", HttpMethod.Post); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); _httpClient.Post(request); @@ -140,6 +153,7 @@ namespace NzbDrone.Core.Applications.Readarr _logger.Error(ex, "Invalid Request"); break; case HttpStatusCode.SeeOther: + case HttpStatusCode.TemporaryRedirect: _logger.Warn(ex, "App returned redirect and is invalid. Check App URL"); break; case HttpStatusCode.NotFound: diff --git a/src/NzbDrone.Core/Applications/Sonarr/Sonarr.cs b/src/NzbDrone.Core/Applications/Sonarr/Sonarr.cs index a894b9d1c..6e5284fc7 100644 --- a/src/NzbDrone.Core/Applications/Sonarr/Sonarr.cs +++ b/src/NzbDrone.Core/Applications/Sonarr/Sonarr.cs @@ -22,8 +22,8 @@ namespace NzbDrone.Core.Applications.Sonarr private readonly ISonarrV3Proxy _sonarrV3Proxy; private readonly IConfigFileProvider _configFileProvider; - public Sonarr(ICacheManager cacheManager, ISonarrV3Proxy sonarrV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, Logger logger) - : base(appIndexerMapService, logger) + public Sonarr(ICacheManager cacheManager, ISonarrV3Proxy sonarrV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger) + : base(appIndexerMapService, indexerFactory, logger) { _schemaCache = cacheManager.GetCache>(GetType()); _sonarrV3Proxy = sonarrV3Proxy; @@ -49,7 +49,7 @@ namespace NzbDrone.Core.Applications.Sonarr try { - failures.AddIfNotNull(_sonarrV3Proxy.TestConnection(BuildSonarrIndexer(testIndexer, DownloadProtocol.Usenet), Settings)); + failures.AddIfNotNull(_sonarrV3Proxy.TestConnection(BuildSonarrIndexer(testIndexer, testIndexer.Capabilities, DownloadProtocol.Usenet), Settings)); } catch (HttpException ex) { @@ -64,15 +64,16 @@ namespace NzbDrone.Core.Applications.Sonarr failures.AddIfNotNull(new ValidationFailure("ProwlarrUrl", "Prowlarr URL is invalid, Sonarr cannot connect to Prowlarr")); break; case HttpStatusCode.SeeOther: + case HttpStatusCode.TemporaryRedirect: _logger.Warn(ex, "Sonarr returned redirect and is invalid"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Sonarr URL is invalid, Prowlarr cannot connect to Sonarr - are you missing a URL base?")); break; case HttpStatusCode.NotFound: - _logger.Error(ex, "Sonarr not found"); + _logger.Warn(ex, "Sonarr not found"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Sonarr URL is invalid, Prowlarr cannot connect to Sonarr. Is Sonarr running and accessible? Sonarr v2 is not supported.")); break; default: - _logger.Error(ex, "Unable to complete application test"); + _logger.Warn(ex, "Unable to complete application test"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Sonarr. {ex.Message}")); break; } @@ -84,7 +85,7 @@ namespace NzbDrone.Core.Applications.Sonarr } catch (Exception ex) { - _logger.Error(ex, "Unable to complete application test"); + _logger.Warn(ex, "Unable to complete application test"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Sonarr. {ex.Message}")); } @@ -100,15 +101,20 @@ namespace NzbDrone.Core.Applications.Sonarr foreach (var indexer in indexers) { - if ((string)indexer.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value == _configFileProvider.ApiKey) - { - var match = AppIndexerRegex.Match((string)indexer.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value); + var baseUrl = (string)indexer.Fields.FirstOrDefault(x => x.Name == "baseUrl")?.Value ?? string.Empty; - if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId)) - { - // Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance - mappings.Add(new AppIndexerMap { IndexerId = indexerId, RemoteIndexerId = indexer.Id }); - } + if (!baseUrl.StartsWith(Settings.ProwlarrUrl.TrimEnd('/')) && + (string)indexer.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value != _configFileProvider.ApiKey) + { + continue; + } + + var match = AppIndexerRegex.Match(baseUrl); + + if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId)) + { + // Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance + mappings.Add(new AppIndexerMap { IndexerId = indexerId, RemoteIndexerId = indexer.Id }); } } @@ -117,8 +123,10 @@ namespace NzbDrone.Core.Applications.Sonarr public override void AddIndexer(IndexerDefinition indexer) { - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty() && - indexer.Capabilities.Categories.SupportedCategories(Settings.AnimeSyncCategories.ToArray()).Empty()) + var indexerCapabilities = GetIndexerCapabilities(indexer); + + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty() && + indexerCapabilities.Categories.SupportedCategories(Settings.AnimeSyncCategories.ToArray()).Empty()) { _logger.Trace("Skipping add for indexer {0} [{1}] due to no app Sync Categories supported by the indexer", indexer.Name, indexer.Id); @@ -127,7 +135,7 @@ namespace NzbDrone.Core.Applications.Sonarr _logger.Trace("Adding indexer {0} [{1}]", indexer.Name, indexer.Id); - var sonarrIndexer = BuildSonarrIndexer(indexer, indexer.Protocol); + var sonarrIndexer = BuildSonarrIndexer(indexer, indexerCapabilities, indexer.Protocol); var remoteIndexer = _sonarrV3Proxy.AddIndexer(sonarrIndexer, Settings); @@ -155,24 +163,27 @@ namespace NzbDrone.Core.Applications.Sonarr } } - public override void UpdateIndexer(IndexerDefinition indexer) + public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = false) { _logger.Debug("Updating indexer {0} [{1}]", indexer.Name, indexer.Id); + var indexerCapabilities = GetIndexerCapabilities(indexer); var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id); - var sonarrIndexer = BuildSonarrIndexer(indexer, indexer.Protocol, indexerMapping?.RemoteIndexerId ?? 0); + var sonarrIndexer = BuildSonarrIndexer(indexer, indexerCapabilities, indexer.Protocol, indexerMapping?.RemoteIndexerId ?? 0); var remoteIndexer = _sonarrV3Proxy.GetIndexer(indexerMapping.RemoteIndexerId, Settings); if (remoteIndexer != null) { - _logger.Debug("Remote indexer found, syncing with current settings"); + _logger.Debug("Remote indexer {0} [{1}] found", remoteIndexer.Name, remoteIndexer.Id); - if (!sonarrIndexer.Equals(remoteIndexer)) + if (!sonarrIndexer.Equals(remoteIndexer) || forceSync) { - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any() || indexer.Capabilities.Categories.SupportedCategories(Settings.AnimeSyncCategories.ToArray()).Any()) + _logger.Debug("Syncing remote indexer with current settings"); + + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any() || indexerCapabilities.Categories.SupportedCategories(Settings.AnimeSyncCategories.ToArray()).Any()) { // Retain user fields not-affiliated with Prowlarr sonarrIndexer.Fields.AddRange(remoteIndexer.Fields.Where(f => sonarrIndexer.Fields.All(s => s.Name != f.Name))); @@ -199,7 +210,7 @@ namespace NzbDrone.Core.Applications.Sonarr { _appIndexerMapService.Delete(indexerMapping.Id); - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any() || indexer.Capabilities.Categories.SupportedCategories(Settings.AnimeSyncCategories.ToArray()).Any()) + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any() || indexerCapabilities.Categories.SupportedCategories(Settings.AnimeSyncCategories.ToArray()).Any()) { _logger.Debug("Remote indexer not found, re-adding {0} [{1}] to Sonarr", indexer.Name, indexer.Id); sonarrIndexer.Id = 0; @@ -213,11 +224,11 @@ namespace NzbDrone.Core.Applications.Sonarr } } - private SonarrIndexer BuildSonarrIndexer(IndexerDefinition indexer, DownloadProtocol protocol, int id = 0) + private SonarrIndexer BuildSonarrIndexer(IndexerDefinition indexer, IndexerCapabilities indexerCapabilities, DownloadProtocol protocol, int id = 0) { var cacheKey = $"{Settings.BaseUrl}"; var schemas = _schemaCache.Get(cacheKey, () => _sonarrV3Proxy.GetIndexerSchema(Settings), TimeSpan.FromDays(7)); - var syncFields = new List { "baseUrl", "apiPath", "apiKey", "categories", "animeCategories", "animeStandardFormatSearch", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime", "seedCriteria.seasonPackSeedTime" }; + var syncFields = new List { "baseUrl", "apiPath", "apiKey", "categories", "animeCategories", "animeStandardFormatSearch", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime", "seedCriteria.seasonPackSeedTime", "rejectBlocklistedTorrentHashesWhileGrabbing" }; if (id == 0) { @@ -249,8 +260,8 @@ namespace NzbDrone.Core.Applications.Sonarr sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/"; sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiPath").Value = "/api"; sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiKey").Value = _configFileProvider.ApiKey; - sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "categories").Value = JArray.FromObject(indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())); - sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "animeCategories").Value = JArray.FromObject(indexer.Capabilities.Categories.SupportedCategories(Settings.AnimeSyncCategories.ToArray())); + sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "categories").Value = JArray.FromObject(indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())); + sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "animeCategories").Value = JArray.FromObject(indexerCapabilities.Categories.SupportedCategories(Settings.AnimeSyncCategories.ToArray())); if (sonarrIndexer.Fields.Any(x => x.Name == "animeStandardFormatSearch")) { @@ -263,6 +274,11 @@ namespace NzbDrone.Core.Applications.Sonarr sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.SeedRatio; sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.SeedTime; sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seasonPackSeedTime").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.PackSeedTime ?? ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.SeedTime; + + if (sonarrIndexer.Fields.Any(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")) + { + sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing").Value = Settings.SyncRejectBlocklistedTorrentHashesWhileGrabbing; + } } return sonarrIndexer; diff --git a/src/NzbDrone.Core/Applications/Sonarr/SonarrField.cs b/src/NzbDrone.Core/Applications/Sonarr/SonarrField.cs index d0350c0ce..f9e5fe6f9 100644 --- a/src/NzbDrone.Core/Applications/Sonarr/SonarrField.cs +++ b/src/NzbDrone.Core/Applications/Sonarr/SonarrField.cs @@ -2,12 +2,7 @@ namespace NzbDrone.Core.Applications.Sonarr { public class SonarrField { - public int Order { get; set; } public string Name { get; set; } - public string Label { get; set; } - public string Unit { get; set; } - public string HelpText { get; set; } - public string HelpLink { get; set; } public object Value { get; set; } public string Type { get; set; } public bool Advanced { get; set; } diff --git a/src/NzbDrone.Core/Applications/Sonarr/SonarrIndexer.cs b/src/NzbDrone.Core/Applications/Sonarr/SonarrIndexer.cs index 03c4376c4..698c7ed6f 100644 --- a/src/NzbDrone.Core/Applications/Sonarr/SonarrIndexer.cs +++ b/src/NzbDrone.Core/Applications/Sonarr/SonarrIndexer.cs @@ -30,10 +30,13 @@ namespace NzbDrone.Core.Applications.Sonarr } var baseUrl = (string)Fields.FirstOrDefault(x => x.Name == "baseUrl").Value == (string)other.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value; - var apiKey = (string)Fields.FirstOrDefault(x => x.Name == "apiKey").Value == (string)other.Fields.FirstOrDefault(x => x.Name == "apiKey").Value; var cats = JToken.DeepEquals((JArray)Fields.FirstOrDefault(x => x.Name == "categories").Value, (JArray)other.Fields.FirstOrDefault(x => x.Name == "categories").Value); var animeCats = JToken.DeepEquals((JArray)Fields.FirstOrDefault(x => x.Name == "animeCategories").Value, (JArray)other.Fields.FirstOrDefault(x => x.Name == "animeCategories").Value); + var apiKey = (string)Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value; + var otherApiKey = (string)other.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value; + var apiKeyCompare = apiKey == otherApiKey || otherApiKey == "********"; + var apiPath = Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value == null ? null : Fields.FirstOrDefault(x => x.Name == "apiPath").Value; var otherApiPath = other.Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value == null ? null : other.Fields.FirstOrDefault(x => x.Name == "apiPath").Value; var apiPathCompare = apiPath.Equals(otherApiPath); @@ -58,6 +61,10 @@ namespace NzbDrone.Core.Applications.Sonarr var otherSeedRatio = other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio")?.Value == null ? null : (double?)Convert.ToDouble(other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio").Value); var seedRatioCompare = seedRatio == otherSeedRatio; + var rejectBlocklistedTorrentHashesWhileGrabbing = Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")?.Value == null ? null : (bool?)Convert.ToBoolean(Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing").Value); + var otherRejectBlocklistedTorrentHashesWhileGrabbing = other.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")?.Value == null ? null : (bool?)Convert.ToBoolean(other.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing").Value); + var rejectBlocklistedTorrentHashesWhileGrabbingCompare = rejectBlocklistedTorrentHashesWhileGrabbing == otherRejectBlocklistedTorrentHashesWhileGrabbing; + return other.EnableRss == EnableRss && other.EnableAutomaticSearch == EnableAutomaticSearch && other.EnableInteractiveSearch == EnableInteractiveSearch && @@ -65,7 +72,7 @@ namespace NzbDrone.Core.Applications.Sonarr other.Implementation == Implementation && other.Priority == Priority && other.Id == Id && - apiKey && apiPathCompare && baseUrl && cats && animeCats && animeStandardFormatSearchCompare && minimumSeedersCompare && seedRatioCompare && seedTimeCompare && seasonSeedTimeCompare; + apiKeyCompare && apiPathCompare && baseUrl && cats && animeCats && animeStandardFormatSearchCompare && minimumSeedersCompare && seedRatioCompare && seedTimeCompare && seasonSeedTimeCompare && rejectBlocklistedTorrentHashesWhileGrabbingCompare; } } } diff --git a/src/NzbDrone.Core/Applications/Sonarr/SonarrSettings.cs b/src/NzbDrone.Core/Applications/Sonarr/SonarrSettings.cs index 9c31df18c..95b52bab0 100644 --- a/src/NzbDrone.Core/Applications/Sonarr/SonarrSettings.cs +++ b/src/NzbDrone.Core/Applications/Sonarr/SonarrSettings.cs @@ -43,8 +43,11 @@ namespace NzbDrone.Core.Applications.Sonarr [FieldDefinition(4, Label = "Anime Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), Advanced = true, HelpText = "Only Indexers that support these categories will be synced")] public IEnumerable AnimeSyncCategories { get; set; } - [FieldDefinition(5, Label = "Sync Anime Standard Format Search", Type = FieldType.Checkbox, Advanced = true, HelpText = "Sync also searching for anime using the standard numbering")] - public bool SyncAnimeStandardFormatSearch { get; set; } + [FieldDefinition(5, Label = "Sync Anime Standard Format Search", Type = FieldType.Checkbox, HelpText = "Sync also searching for anime using the standard numbering", Advanced = true)] + public bool SyncAnimeStandardFormatSearch { get; set; } = true; + + [FieldDefinition(6, Type = FieldType.Checkbox, Label = "ApplicationSettingsSyncRejectBlocklistedTorrentHashes", HelpText = "ApplicationSettingsSyncRejectBlocklistedTorrentHashesHelpText", Advanced = true)] + public bool SyncRejectBlocklistedTorrentHashesWhileGrabbing { get; set; } public NzbDroneValidationResult Validate() { diff --git a/src/NzbDrone.Core/Applications/Sonarr/SonarrV3Proxy.cs b/src/NzbDrone.Core/Applications/Sonarr/SonarrV3Proxy.cs index 19eed7fae..f92043c99 100644 --- a/src/NzbDrone.Core/Applications/Sonarr/SonarrV3Proxy.cs +++ b/src/NzbDrone.Core/Applications/Sonarr/SonarrV3Proxy.cs @@ -84,6 +84,7 @@ namespace NzbDrone.Core.Applications.Sonarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Post); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); try { @@ -103,10 +104,21 @@ namespace NzbDrone.Core.Applications.Sonarr { var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put); - request.Url = request.Url.AddQueryParam("forceSave", "true"); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); - return ExecuteIndexerRequest(request); + try + { + return ExecuteIndexerRequest(request); + } + catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest) + { + _logger.Debug("Retrying to update indexer forcefully"); + + request.Url = request.Url.AddQueryParam("forceSave", "true"); + + return ExecuteIndexerRequest(request); + } } public ValidationFailure TestConnection(SonarrIndexer indexer, SonarrSettings settings) @@ -114,6 +126,7 @@ namespace NzbDrone.Core.Applications.Sonarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}/test", HttpMethod.Post); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version"); @@ -153,6 +166,7 @@ namespace NzbDrone.Core.Applications.Sonarr _logger.Error(ex, "Invalid Request"); break; case HttpStatusCode.SeeOther: + case HttpStatusCode.TemporaryRedirect: _logger.Warn(ex, "App returned redirect and is invalid. Check App URL"); break; case HttpStatusCode.NotFound: diff --git a/src/NzbDrone.Core/Applications/Whisparr/Whisparr.cs b/src/NzbDrone.Core/Applications/Whisparr/Whisparr.cs index 96076e61f..0c149fc7c 100644 --- a/src/NzbDrone.Core/Applications/Whisparr/Whisparr.cs +++ b/src/NzbDrone.Core/Applications/Whisparr/Whisparr.cs @@ -22,8 +22,8 @@ namespace NzbDrone.Core.Applications.Whisparr private readonly ICached> _schemaCache; private readonly IConfigFileProvider _configFileProvider; - public Whisparr(ICacheManager cacheManager, IWhisparrV3Proxy whisparrV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, Logger logger) - : base(appIndexerMapService, logger) + public Whisparr(ICacheManager cacheManager, IWhisparrV3Proxy whisparrV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger) + : base(appIndexerMapService, indexerFactory, logger) { _schemaCache = cacheManager.GetCache>(GetType()); _whisparrV3Proxy = whisparrV3Proxy; @@ -49,7 +49,7 @@ namespace NzbDrone.Core.Applications.Whisparr try { - failures.AddIfNotNull(_whisparrV3Proxy.TestConnection(BuildWhisparrIndexer(testIndexer, DownloadProtocol.Usenet), Settings)); + failures.AddIfNotNull(_whisparrV3Proxy.TestConnection(BuildWhisparrIndexer(testIndexer, testIndexer.Capabilities, DownloadProtocol.Usenet), Settings)); } catch (HttpException ex) { @@ -64,11 +64,12 @@ namespace NzbDrone.Core.Applications.Whisparr failures.AddIfNotNull(new ValidationFailure("ProwlarrUrl", "Prowlarr URL is invalid, Whisparr cannot connect to Prowlarr")); break; case HttpStatusCode.SeeOther: + case HttpStatusCode.TemporaryRedirect: _logger.Warn(ex, "Whisparr returned redirect and is invalid"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Whisparr URL is invalid, Prowlarr cannot connect to Whisparr - are you missing a URL base?")); break; default: - _logger.Error(ex, "Unable to complete application test"); + _logger.Warn(ex, "Unable to complete application test"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Whisparr. {ex.Message}")); break; } @@ -80,7 +81,7 @@ namespace NzbDrone.Core.Applications.Whisparr } catch (Exception ex) { - _logger.Error(ex, "Unable to complete application test"); + _logger.Warn(ex, "Unable to complete application test"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Whisparr. {ex.Message}")); } @@ -90,21 +91,26 @@ namespace NzbDrone.Core.Applications.Whisparr public override List GetIndexerMappings() { var indexers = _whisparrV3Proxy.GetIndexers(Settings) - .Where(i => i.Implementation == "Newznab" || i.Implementation == "Torznab"); + .Where(i => i.Implementation is "Newznab" or "Torznab"); var mappings = new List(); foreach (var indexer in indexers) { - if ((string)indexer.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value == _configFileProvider.ApiKey) - { - var match = AppIndexerRegex.Match((string)indexer.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value); + var baseUrl = (string)indexer.Fields.FirstOrDefault(x => x.Name == "baseUrl")?.Value ?? string.Empty; - if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId)) - { - // Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance - mappings.Add(new AppIndexerMap { IndexerId = indexerId, RemoteIndexerId = indexer.Id }); - } + if (!baseUrl.StartsWith(Settings.ProwlarrUrl.TrimEnd('/')) && + (string)indexer.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value != _configFileProvider.ApiKey) + { + continue; + } + + var match = AppIndexerRegex.Match(baseUrl); + + if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId)) + { + // Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance + mappings.Add(new AppIndexerMap { IndexerId = indexerId, RemoteIndexerId = indexer.Id }); } } @@ -113,7 +119,9 @@ namespace NzbDrone.Core.Applications.Whisparr public override void AddIndexer(IndexerDefinition indexer) { - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty()) + var indexerCapabilities = GetIndexerCapabilities(indexer); + + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Empty()) { _logger.Trace("Skipping add for indexer {0} [{1}] due to no app Sync Categories supported by the indexer", indexer.Name, indexer.Id); @@ -122,7 +130,7 @@ namespace NzbDrone.Core.Applications.Whisparr _logger.Trace("Adding indexer {0} [{1}]", indexer.Name, indexer.Id); - var whisparrIndexer = BuildWhisparrIndexer(indexer, indexer.Protocol); + var whisparrIndexer = BuildWhisparrIndexer(indexer, indexerCapabilities, indexer.Protocol); var remoteIndexer = _whisparrV3Proxy.AddIndexer(whisparrIndexer, Settings); @@ -150,24 +158,27 @@ namespace NzbDrone.Core.Applications.Whisparr } } - public override void UpdateIndexer(IndexerDefinition indexer) + public override void UpdateIndexer(IndexerDefinition indexer, bool forceSync = false) { _logger.Debug("Updating indexer {0} [{1}]", indexer.Name, indexer.Id); + var indexerCapabilities = GetIndexerCapabilities(indexer); var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id); var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id); - var whisparrIndexer = BuildWhisparrIndexer(indexer, indexer.Protocol, indexerMapping?.RemoteIndexerId ?? 0); + var whisparrIndexer = BuildWhisparrIndexer(indexer, indexerCapabilities, indexer.Protocol, indexerMapping?.RemoteIndexerId ?? 0); var remoteIndexer = _whisparrV3Proxy.GetIndexer(indexerMapping.RemoteIndexerId, Settings); if (remoteIndexer != null) { - _logger.Debug("Remote indexer found, syncing with current settings"); + _logger.Debug("Remote indexer {0} [{1}] found", remoteIndexer.Name, remoteIndexer.Id); - if (!whisparrIndexer.Equals(remoteIndexer)) + if (!whisparrIndexer.Equals(remoteIndexer) || forceSync) { - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + _logger.Debug("Syncing remote indexer with current settings"); + + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) { // Retain user fields not-affiliated with Prowlarr whisparrIndexer.Fields.AddRange(remoteIndexer.Fields.Where(f => whisparrIndexer.Fields.All(s => s.Name != f.Name))); @@ -175,6 +186,9 @@ namespace NzbDrone.Core.Applications.Whisparr // Retain user tags not-affiliated with Prowlarr whisparrIndexer.Tags.UnionWith(remoteIndexer.Tags); + // Retain user settings not-affiliated with Prowlarr + whisparrIndexer.DownloadClientId = remoteIndexer.DownloadClientId; + // Update the indexer if it still has categories that match _whisparrV3Proxy.UpdateIndexer(whisparrIndexer, Settings); } @@ -190,7 +204,7 @@ namespace NzbDrone.Core.Applications.Whisparr { _appIndexerMapService.Delete(indexerMapping.Id); - if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) + if (indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any()) { _logger.Debug("Remote indexer not found, re-adding {0} [{1}] to Whisparr", indexer.Name, indexer.Id); whisparrIndexer.Id = 0; @@ -204,11 +218,11 @@ namespace NzbDrone.Core.Applications.Whisparr } } - private WhisparrIndexer BuildWhisparrIndexer(IndexerDefinition indexer, DownloadProtocol protocol, int id = 0) + private WhisparrIndexer BuildWhisparrIndexer(IndexerDefinition indexer, IndexerCapabilities indexerCapabilities, DownloadProtocol protocol, int id = 0) { var cacheKey = $"{Settings.BaseUrl}"; var schemas = _schemaCache.Get(cacheKey, () => _whisparrV3Proxy.GetIndexerSchema(Settings), TimeSpan.FromDays(7)); - var syncFields = new[] { "baseUrl", "apiPath", "apiKey", "categories", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime" }; + var syncFields = new[] { "baseUrl", "apiPath", "apiKey", "categories", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime", "seedCriteria.seasonPackSeedTime", "rejectBlocklistedTorrentHashesWhileGrabbing" }; var newznab = schemas.First(i => i.Implementation == "Newznab"); var torznab = schemas.First(i => i.Implementation == "Torznab"); @@ -234,13 +248,23 @@ namespace NzbDrone.Core.Applications.Whisparr whisparrIndexer.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/"; whisparrIndexer.Fields.FirstOrDefault(x => x.Name == "apiPath").Value = "/api"; whisparrIndexer.Fields.FirstOrDefault(x => x.Name == "apiKey").Value = _configFileProvider.ApiKey; - whisparrIndexer.Fields.FirstOrDefault(x => x.Name == "categories").Value = JArray.FromObject(indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())); + whisparrIndexer.Fields.FirstOrDefault(x => x.Name == "categories").Value = JArray.FromObject(indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())); if (indexer.Protocol == DownloadProtocol.Torrent) { whisparrIndexer.Fields.FirstOrDefault(x => x.Name == "minimumSeeders").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.AppMinimumSeeders ?? indexer.AppProfile.Value.MinimumSeeders; whisparrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.SeedRatio; whisparrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.SeedTime; + + if (whisparrIndexer.Fields.Any(x => x.Name == "seedCriteria.seasonPackSeedTime")) + { + whisparrIndexer.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seasonPackSeedTime").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.PackSeedTime ?? ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.SeedTime; + } + + if (whisparrIndexer.Fields.Any(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")) + { + whisparrIndexer.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing").Value = Settings.SyncRejectBlocklistedTorrentHashesWhileGrabbing; + } } return whisparrIndexer; diff --git a/src/NzbDrone.Core/Applications/Whisparr/WhisparrField.cs b/src/NzbDrone.Core/Applications/Whisparr/WhisparrField.cs index e3b1139b1..2bdafe2f8 100644 --- a/src/NzbDrone.Core/Applications/Whisparr/WhisparrField.cs +++ b/src/NzbDrone.Core/Applications/Whisparr/WhisparrField.cs @@ -2,12 +2,7 @@ namespace NzbDrone.Core.Applications.Whisparr { public class WhisparrField { - public int Order { get; set; } public string Name { get; set; } - public string Label { get; set; } - public string Unit { get; set; } - public string HelpText { get; set; } - public string HelpLink { get; set; } public object Value { get; set; } public string Type { get; set; } public bool Advanced { get; set; } diff --git a/src/NzbDrone.Core/Applications/Whisparr/WhisparrIndexer.cs b/src/NzbDrone.Core/Applications/Whisparr/WhisparrIndexer.cs index b1d720360..e8e6f8150 100644 --- a/src/NzbDrone.Core/Applications/Whisparr/WhisparrIndexer.cs +++ b/src/NzbDrone.Core/Applications/Whisparr/WhisparrIndexer.cs @@ -17,6 +17,7 @@ namespace NzbDrone.Core.Applications.Whisparr public string Implementation { get; set; } public string ConfigContract { get; set; } public string InfoLink { get; set; } + public int? DownloadClientId { get; set; } public HashSet Tags { get; set; } public List Fields { get; set; } @@ -28,9 +29,12 @@ namespace NzbDrone.Core.Applications.Whisparr } var baseUrl = (string)Fields.FirstOrDefault(x => x.Name == "baseUrl").Value == (string)other.Fields.FirstOrDefault(x => x.Name == "baseUrl").Value; - var apiKey = (string)Fields.FirstOrDefault(x => x.Name == "apiKey").Value == (string)other.Fields.FirstOrDefault(x => x.Name == "apiKey").Value; var cats = JToken.DeepEquals((JArray)Fields.FirstOrDefault(x => x.Name == "categories").Value, (JArray)other.Fields.FirstOrDefault(x => x.Name == "categories").Value); + var apiKey = (string)Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value; + var otherApiKey = (string)other.Fields.FirstOrDefault(x => x.Name == "apiKey")?.Value; + var apiKeyCompare = apiKey == otherApiKey || otherApiKey == "********"; + var apiPath = Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value == null ? null : Fields.FirstOrDefault(x => x.Name == "apiPath").Value; var otherApiPath = other.Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value == null ? null : other.Fields.FirstOrDefault(x => x.Name == "apiPath").Value; var apiPathCompare = apiPath.Equals(otherApiPath); @@ -43,10 +47,18 @@ namespace NzbDrone.Core.Applications.Whisparr var otherSeedTime = other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime")?.Value == null ? null : (int?)Convert.ToInt32(other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedTime").Value); var seedTimeCompare = seedTime == otherSeedTime; + var seasonSeedTime = Fields.FirstOrDefault(x => x.Name == "seedCriteria.seasonPackSeedTime")?.Value == null ? null : (int?)Convert.ToInt32(Fields.FirstOrDefault(x => x.Name == "seedCriteria.seasonPackSeedTime").Value); + var otherSeasonSeedTime = other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seasonPackSeedTime")?.Value == null ? null : (int?)Convert.ToInt32(other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seasonPackSeedTime").Value); + var seasonSeedTimeCompare = seasonSeedTime == otherSeasonSeedTime; + var seedRatio = Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio")?.Value == null ? null : (double?)Convert.ToDouble(Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio").Value); var otherSeedRatio = other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio")?.Value == null ? null : (double?)Convert.ToDouble(other.Fields.FirstOrDefault(x => x.Name == "seedCriteria.seedRatio").Value); var seedRatioCompare = seedRatio == otherSeedRatio; + var rejectBlocklistedTorrentHashesWhileGrabbing = Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")?.Value == null ? null : (bool?)Convert.ToBoolean(Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing").Value); + var otherRejectBlocklistedTorrentHashesWhileGrabbing = other.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing")?.Value == null ? null : (bool?)Convert.ToBoolean(other.Fields.FirstOrDefault(x => x.Name == "rejectBlocklistedTorrentHashesWhileGrabbing").Value); + var rejectBlocklistedTorrentHashesWhileGrabbingCompare = rejectBlocklistedTorrentHashesWhileGrabbing == otherRejectBlocklistedTorrentHashesWhileGrabbing; + return other.EnableRss == EnableRss && other.EnableAutomaticSearch == EnableAutomaticSearch && other.EnableInteractiveSearch == EnableInteractiveSearch && @@ -54,7 +66,7 @@ namespace NzbDrone.Core.Applications.Whisparr other.Implementation == Implementation && other.Priority == Priority && other.Id == Id && - apiKey && apiPathCompare && baseUrl && cats && minimumSeedersCompare && seedRatioCompare && seedTimeCompare; + apiKeyCompare && apiPathCompare && baseUrl && cats && minimumSeedersCompare && seedRatioCompare && seedTimeCompare && seasonSeedTimeCompare && rejectBlocklistedTorrentHashesWhileGrabbingCompare; } } } diff --git a/src/NzbDrone.Core/Applications/Whisparr/WhisparrSettings.cs b/src/NzbDrone.Core/Applications/Whisparr/WhisparrSettings.cs index 5fe636747..0dfafc166 100644 --- a/src/NzbDrone.Core/Applications/Whisparr/WhisparrSettings.cs +++ b/src/NzbDrone.Core/Applications/Whisparr/WhisparrSettings.cs @@ -37,9 +37,12 @@ namespace NzbDrone.Core.Applications.Whisparr [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Whisparr in Settings/General")] public string ApiKey { get; set; } - [FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), Advanced = true, HelpText = "Only Indexers that support these categories will be synced")] + [FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), HelpText = "Only Indexers that support these categories will be synced", Advanced = true)] public IEnumerable SyncCategories { get; set; } + [FieldDefinition(4, Type = FieldType.Checkbox, Label = "ApplicationSettingsSyncRejectBlocklistedTorrentHashes", HelpText = "ApplicationSettingsSyncRejectBlocklistedTorrentHashesHelpText", Advanced = true)] + public bool SyncRejectBlocklistedTorrentHashesWhileGrabbing { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Applications/Whisparr/WhisparrV3Proxy.cs b/src/NzbDrone.Core/Applications/Whisparr/WhisparrV3Proxy.cs index c81a6e149..e2ee60524 100644 --- a/src/NzbDrone.Core/Applications/Whisparr/WhisparrV3Proxy.cs +++ b/src/NzbDrone.Core/Applications/Whisparr/WhisparrV3Proxy.cs @@ -81,6 +81,7 @@ namespace NzbDrone.Core.Applications.Whisparr var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Post); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); try { @@ -98,10 +99,21 @@ namespace NzbDrone.Core.Applications.Whisparr { var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put); - request.Url = request.Url.AddQueryParam("forceSave", "true"); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); - return ExecuteIndexerRequest(request); + try + { + return ExecuteIndexerRequest(request); + } + catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest) + { + _logger.Debug("Retrying to update indexer forcefully"); + + request.Url = request.Url.AddQueryParam("forceSave", "true"); + + return ExecuteIndexerRequest(request); + } } public ValidationFailure TestConnection(WhisparrIndexer indexer, WhisparrSettings settings) @@ -109,6 +121,7 @@ namespace NzbDrone.Core.Applications.Whisparr var request = BuildRequest(settings, $"{AppIndexerApiRoute}/test", HttpMethod.Post); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); _httpClient.Post(request); @@ -138,6 +151,7 @@ namespace NzbDrone.Core.Applications.Whisparr _logger.Error(ex, "Invalid Request"); break; case HttpStatusCode.SeeOther: + case HttpStatusCode.TemporaryRedirect: _logger.Warn(ex, "App returned redirect and is invalid. Check App URL"); break; case HttpStatusCode.NotFound: diff --git a/src/NzbDrone.Core/Backup/BackupService.cs b/src/NzbDrone.Core/Backup/BackupService.cs index 97f13ee15..051d045bb 100644 --- a/src/NzbDrone.Core/Backup/BackupService.cs +++ b/src/NzbDrone.Core/Backup/BackupService.cs @@ -66,12 +66,19 @@ namespace NzbDrone.Core.Backup { _logger.ProgressInfo("Starting Backup"); + var backupFolder = GetBackupFolder(backupType); + _diskProvider.EnsureFolder(_backupTempFolder); - _diskProvider.EnsureFolder(GetBackupFolder(backupType)); + _diskProvider.EnsureFolder(backupFolder); + + if (!_diskProvider.FolderWritable(backupFolder)) + { + throw new UnauthorizedAccessException($"Backup folder {backupFolder} is not writable"); + } var dateNow = DateTime.Now; var backupFilename = $"prowlarr_backup_v{BuildInfo.Version}_{dateNow:yyyy.MM.dd_HH.mm.ss}.zip"; - var backupPath = Path.Combine(GetBackupFolder(backupType), backupFilename); + var backupPath = Path.Combine(backupFolder, backupFilename); Cleanup(); @@ -89,7 +96,7 @@ namespace NzbDrone.Core.Backup // Delete journal file created during database backup _diskProvider.DeleteFile(Path.Combine(_backupTempFolder, "prowlarr.db-journal")); - _archiveService.CreateZip(backupPath, _diskProvider.GetFiles(_backupTempFolder, SearchOption.TopDirectoryOnly)); + _archiveService.CreateZip(backupPath, _diskProvider.GetFiles(_backupTempFolder, false)); Cleanup(); @@ -128,7 +135,7 @@ namespace NzbDrone.Core.Backup _archiveService.Extract(backupFileName, temporaryPath); - foreach (var file in _diskProvider.GetFiles(temporaryPath, SearchOption.TopDirectoryOnly)) + foreach (var file in _diskProvider.GetFiles(temporaryPath, false)) { var fileName = Path.GetFileName(file); @@ -243,7 +250,7 @@ namespace NzbDrone.Core.Backup private IEnumerable GetBackupFiles(string path) { - var files = _diskProvider.GetFiles(path, SearchOption.TopDirectoryOnly); + var files = _diskProvider.GetFiles(path, false); return files.Where(f => BackupFileRegex.IsMatch(f)); } diff --git a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs index c2731e084..f4715b203 100644 --- a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs +++ b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs @@ -10,6 +10,8 @@ using NzbDrone.Common.Cache; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation; +using NzbDrone.Common.Options; using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.Datastore; @@ -37,8 +39,10 @@ namespace NzbDrone.Core.Configuration bool AnalyticsEnabled { get; } string LogLevel { get; } string ConsoleLogLevel { get; } + ConsoleLogFormat ConsoleLogFormat { get; } bool LogSql { get; } int LogRotate { get; } + int LogSizeLimit { get; } bool FilterSentryEvents { get; } string Branch { get; } string ApiKey { get; } @@ -53,13 +57,15 @@ namespace NzbDrone.Core.Configuration string SyslogServer { get; } int SyslogPort { get; } string SyslogLevel { get; } + bool LogDbEnabled { get; } + string Theme { get; } string PostgresHost { get; } int PostgresPort { get; } string PostgresUser { get; } string PostgresPassword { get; } string PostgresMainDb { get; } string PostgresLogDb { get; } - string Theme { get; } + bool TrustCgnatIpAddresses { get; } } public class ConfigFileProvider : IConfigFileProvider @@ -72,6 +78,11 @@ namespace NzbDrone.Core.Configuration private readonly IDiskProvider _diskProvider; private readonly ICached _cache; private readonly PostgresOptions _postgresOptions; + private readonly AuthOptions _authOptions; + private readonly AppOptions _appOptions; + private readonly ServerOptions _serverOptions; + private readonly UpdateOptions _updateOptions; + private readonly LogOptions _logOptions; private readonly string _configFile; private static readonly Regex HiddenCharacterRegex = new Regex("[^a-z0-9]", RegexOptions.Compiled | RegexOptions.IgnoreCase); @@ -82,13 +93,23 @@ namespace NzbDrone.Core.Configuration ICacheManager cacheManager, IEventAggregator eventAggregator, IDiskProvider diskProvider, - IOptions postgresOptions) + IOptions postgresOptions, + IOptions authOptions, + IOptions appOptions, + IOptions serverOptions, + IOptions updateOptions, + IOptions logOptions) { _cache = cacheManager.GetCache(GetType()); _eventAggregator = eventAggregator; _diskProvider = diskProvider; _configFile = appFolderInfo.GetConfigPath(); _postgresOptions = postgresOptions.Value; + _authOptions = authOptions.Value; + _appOptions = appOptions.Value; + _serverOptions = serverOptions.Value; + _updateOptions = updateOptions.Value; + _logOptions = logOptions.Value; } public Dictionary GetConfigDictionary() @@ -144,7 +165,7 @@ namespace NzbDrone.Core.Configuration { const string defaultValue = "*"; - var bindAddress = GetValue("BindAddress", defaultValue); + var bindAddress = _serverOptions.BindAddress ?? GetValue("BindAddress", defaultValue); if (string.IsNullOrWhiteSpace(bindAddress)) { return defaultValue; @@ -154,19 +175,19 @@ namespace NzbDrone.Core.Configuration } } - public int Port => GetValueInt("Port", DEFAULT_PORT); + public int Port => _serverOptions.Port ?? GetValueInt("Port", DEFAULT_PORT); - public int SslPort => GetValueInt("SslPort", DEFAULT_SSL_PORT); + public int SslPort => _serverOptions.SslPort ?? GetValueInt("SslPort", DEFAULT_SSL_PORT); - public bool EnableSsl => GetValueBoolean("EnableSsl", false); + public bool EnableSsl => _serverOptions.EnableSsl ?? GetValueBoolean("EnableSsl", false); - public bool LaunchBrowser => GetValueBoolean("LaunchBrowser", true); + public bool LaunchBrowser => _appOptions.LaunchBrowser ?? GetValueBoolean("LaunchBrowser", true); public string ApiKey { get { - var apiKey = GetValue("ApiKey", GenerateApiKey()); + var apiKey = _authOptions.ApiKey ?? GetValue("ApiKey", GenerateApiKey()); if (apiKey.IsNullOrWhiteSpace()) { @@ -182,7 +203,7 @@ namespace NzbDrone.Core.Configuration { get { - var enabled = GetValueBoolean("AuthenticationEnabled", false, false); + var enabled = _authOptions.Enabled ?? GetValueBoolean("AuthenticationEnabled", false, false); if (enabled) { @@ -190,61 +211,92 @@ namespace NzbDrone.Core.Configuration return AuthenticationType.Basic; } - return GetValueEnum("AuthenticationMethod", AuthenticationType.None); + return Enum.TryParse(_authOptions.Method, out var enumValue) + ? enumValue + : GetValueEnum("AuthenticationMethod", AuthenticationType.None); } } - public AuthenticationRequiredType AuthenticationRequired => GetValueEnum("AuthenticationRequired", AuthenticationRequiredType.Enabled); + public AuthenticationRequiredType AuthenticationRequired => + Enum.TryParse(_authOptions.Required, out var enumValue) + ? enumValue + : GetValueEnum("AuthenticationRequired", AuthenticationRequiredType.Enabled); - public bool AnalyticsEnabled => GetValueBoolean("AnalyticsEnabled", true, persist: false); + public bool AnalyticsEnabled => _logOptions.AnalyticsEnabled ?? GetValueBoolean("AnalyticsEnabled", true, persist: false); - // TODO: Change back to "master" for the first stable release. - public string Branch => GetValue("Branch", "master").ToLowerInvariant(); + public string Branch => _updateOptions.Branch ?? GetValue("Branch", "master").ToLowerInvariant(); + + public string LogLevel => _logOptions.Level ?? GetValue("LogLevel", "debug").ToLowerInvariant(); + public string ConsoleLogLevel => _logOptions.ConsoleLevel ?? GetValue("ConsoleLogLevel", string.Empty, persist: false); + + public ConsoleLogFormat ConsoleLogFormat => + Enum.TryParse(_logOptions.ConsoleFormat, out var enumValue) + ? enumValue + : GetValueEnum("ConsoleLogFormat", ConsoleLogFormat.Standard, false); + + public string Theme => _appOptions.Theme ?? GetValue("Theme", "auto", persist: false); - public string LogLevel => GetValue("LogLevel", "info").ToLowerInvariant(); - public string ConsoleLogLevel => GetValue("ConsoleLogLevel", string.Empty, persist: false); public string PostgresHost => _postgresOptions?.Host ?? GetValue("PostgresHost", string.Empty, persist: false); public string PostgresUser => _postgresOptions?.User ?? GetValue("PostgresUser", string.Empty, persist: false); public string PostgresPassword => _postgresOptions?.Password ?? GetValue("PostgresPassword", string.Empty, persist: false); public string PostgresMainDb => _postgresOptions?.MainDb ?? GetValue("PostgresMainDb", "prowlarr-main", persist: false); public string PostgresLogDb => _postgresOptions?.LogDb ?? GetValue("PostgresLogDb", "prowlarr-log", persist: false); public int PostgresPort => (_postgresOptions?.Port ?? 0) != 0 ? _postgresOptions.Port : GetValueInt("PostgresPort", 5432, persist: false); - public string Theme => GetValue("Theme", "auto", persist: false); - public bool LogSql => GetValueBoolean("LogSql", false, persist: false); - public int LogRotate => GetValueInt("LogRotate", 50, persist: false); - public bool FilterSentryEvents => GetValueBoolean("FilterSentryEvents", true, persist: false); - public string SslCertPath => GetValue("SslCertPath", ""); - public string SslCertPassword => GetValue("SslCertPassword", ""); + + public bool LogDbEnabled => _logOptions.DbEnabled ?? GetValueBoolean("LogDbEnabled", true, persist: false); + public bool LogSql => _logOptions.Sql ?? GetValueBoolean("LogSql", false, persist: false); + public int LogRotate => _logOptions.Rotate ?? GetValueInt("LogRotate", 50, persist: false); + public int LogSizeLimit => Math.Min(Math.Max(_logOptions.SizeLimit ?? GetValueInt("LogSizeLimit", 1, persist: false), 0), 10); + public bool FilterSentryEvents => _logOptions.FilterSentryEvents ?? GetValueBoolean("FilterSentryEvents", true, persist: false); + public string SslCertPath => _serverOptions.SslCertPath ?? GetValue("SslCertPath", ""); + public string SslCertPassword => _serverOptions.SslCertPassword ?? GetValue("SslCertPassword", ""); public string UrlBase { get { - var urlBase = GetValue("UrlBase", "").Trim('/'); + var urlBase = (_serverOptions.UrlBase ?? GetValue("UrlBase", "")).Trim('/'); if (urlBase.IsNullOrWhiteSpace()) { return urlBase; } - return "/" + urlBase.Trim('/').ToLower(); + return "/" + urlBase; } } public string UiFolder => BuildInfo.IsDebug ? Path.Combine("..", "UI") : "UI"; - public string InstanceName => GetValue("InstanceName", BuildInfo.AppName); - public bool UpdateAutomatically => GetValueBoolean("UpdateAutomatically", false, false); + public string InstanceName + { + get + { + var instanceName = _appOptions.InstanceName ?? GetValue("InstanceName", BuildInfo.AppName); - public UpdateMechanism UpdateMechanism => GetValueEnum("UpdateMechanism", UpdateMechanism.BuiltIn, false); + if (instanceName.Contains(BuildInfo.AppName, StringComparison.OrdinalIgnoreCase)) + { + return instanceName; + } - public string UpdateScriptPath => GetValue("UpdateScriptPath", "", false); + return BuildInfo.AppName; + } + } - public string SyslogServer => GetValue("SyslogServer", "", persist: false); + public bool UpdateAutomatically => _updateOptions.Automatically ?? GetValueBoolean("UpdateAutomatically", OsInfo.IsWindows, false); - public int SyslogPort => GetValueInt("SyslogPort", 514, persist: false); + public UpdateMechanism UpdateMechanism => + Enum.TryParse(_updateOptions.Mechanism, out var enumValue) + ? enumValue + : GetValueEnum("UpdateMechanism", UpdateMechanism.BuiltIn, false); - public string SyslogLevel => GetValue("SyslogLevel", LogLevel, false).ToLowerInvariant(); + public string UpdateScriptPath => _updateOptions.ScriptPath ?? GetValue("UpdateScriptPath", "", false); + + public string SyslogServer => _logOptions.SyslogServer ?? GetValue("SyslogServer", "", persist: false); + + public int SyslogPort => _logOptions.SyslogPort ?? GetValueInt("SyslogPort", 514, persist: false); + + public string SyslogLevel => _logOptions.SyslogLevel ?? GetValue("SyslogLevel", LogLevel, persist: false).ToLowerInvariant(); public int GetValueInt(string key, int defaultValue, bool persist = true) { @@ -277,13 +329,13 @@ namespace NzbDrone.Core.Configuration return valueHolder.First().Value.Trim(); } - //Save the value + // Save the value if (persist) { SetValue(key, defaultValue); } - //return the default value + // return the default value return defaultValue.ToString(); }); } @@ -325,6 +377,20 @@ namespace NzbDrone.Core.Configuration } } + public void MigrateConfigFile() + { + if (!File.Exists(_configFile)) + { + return; + } + + // If SSL is enabled and a cert hash is still in the config file or cert path is empty disable SSL + if (EnableSsl && (GetValue("SslCertHash", string.Empty, false).IsNotNullOrWhiteSpace() || SslCertPath.IsNullOrWhiteSpace())) + { + SetValue("EnableSsl", false); + } + } + private void DeleteOldValues() { var xDoc = LoadConfigFile(); @@ -366,13 +432,21 @@ namespace NzbDrone.Core.Configuration throw new InvalidConfigFileException($"{_configFile} is corrupt. Please delete the config file and Prowlarr will recreate it."); } - return XDocument.Parse(_diskProvider.ReadAllText(_configFile)); + var xDoc = XDocument.Parse(_diskProvider.ReadAllText(_configFile)); + var config = xDoc.Descendants(CONFIG_ELEMENT_NAME).ToList(); + + if (config.Count != 1) + { + throw new InvalidConfigFileException($"{_configFile} is invalid. Please delete the config file and Prowlarr will recreate it."); + } + + return xDoc; } - var xDoc = new XDocument(new XDeclaration("1.0", "utf-8", "yes")); - xDoc.Add(new XElement(CONFIG_ELEMENT_NAME)); + var newXDoc = new XDocument(new XDeclaration("1.0", "utf-8", "yes")); + newXDoc.Add(new XElement(CONFIG_ELEMENT_NAME)); - return xDoc; + return newXDoc; } } catch (XmlException ex) @@ -396,6 +470,7 @@ namespace NzbDrone.Core.Configuration public void HandleAsync(ApplicationStartedEvent message) { + MigrateConfigFile(); EnsureDefaultConfigFile(); DeleteOldValues(); } @@ -405,5 +480,7 @@ namespace NzbDrone.Core.Configuration SetValue("ApiKey", GenerateApiKey()); _eventAggregator.PublishEvent(new ApiKeyChangedEvent()); } + + public bool TrustCgnatIpAddresses => _authOptions.TrustCgnatIpAddresses ?? GetValueBoolean("TrustCgnatIpAddresses", false, persist: false); } } diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 208692c97..27a953823 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -183,6 +183,12 @@ namespace NzbDrone.Core.Configuration public string ApplicationUrl => GetValue("ApplicationUrl", string.Empty); + public bool TrustCgnatIpAddresses + { + get { return GetValueBoolean("TrustCgnatIpAddresses", false); } + set { SetValue("TrustCgnatIpAddresses", value); } + } + private string GetValue(string key) { return GetValue(key, string.Empty); diff --git a/src/NzbDrone.Core/Datastore/BasicRepository.cs b/src/NzbDrone.Core/Datastore/BasicRepository.cs index dc76a5a31..796e277b7 100644 --- a/src/NzbDrone.Core/Datastore/BasicRepository.cs +++ b/src/NzbDrone.Core/Datastore/BasicRepository.cs @@ -254,7 +254,7 @@ namespace NzbDrone.Core.Datastore protected void Delete(SqlBuilder builder) { - var sql = builder.AddDeleteTemplate(typeof(TModel)).LogQuery(); + var sql = builder.AddDeleteTemplate(typeof(TModel)); using (var conn = _database.OpenConnection()) { diff --git a/src/NzbDrone.Core/Datastore/ConnectionStringFactory.cs b/src/NzbDrone.Core/Datastore/ConnectionStringFactory.cs index 961d060f8..19c938737 100644 --- a/src/NzbDrone.Core/Datastore/ConnectionStringFactory.cs +++ b/src/NzbDrone.Core/Datastore/ConnectionStringFactory.cs @@ -9,8 +9,8 @@ namespace NzbDrone.Core.Datastore { public interface IConnectionStringFactory { - string MainDbConnectionString { get; } - string LogDbConnectionString { get; } + DatabaseConnectionInfo MainDbConnection { get; } + DatabaseConnectionInfo LogDbConnection { get; } string GetDatabasePath(string connectionString); } @@ -22,15 +22,15 @@ namespace NzbDrone.Core.Datastore { _configFileProvider = configFileProvider; - MainDbConnectionString = _configFileProvider.PostgresHost.IsNotNullOrWhiteSpace() ? GetPostgresConnectionString(_configFileProvider.PostgresMainDb) : + MainDbConnection = _configFileProvider.PostgresHost.IsNotNullOrWhiteSpace() ? GetPostgresConnectionString(_configFileProvider.PostgresMainDb) : GetConnectionString(appFolderInfo.GetDatabase()); - LogDbConnectionString = _configFileProvider.PostgresHost.IsNotNullOrWhiteSpace() ? GetPostgresConnectionString(_configFileProvider.PostgresLogDb) : + LogDbConnection = _configFileProvider.PostgresHost.IsNotNullOrWhiteSpace() ? GetPostgresConnectionString(_configFileProvider.PostgresLogDb) : GetConnectionString(appFolderInfo.GetLogDatabase()); } - public string MainDbConnectionString { get; private set; } - public string LogDbConnectionString { get; private set; } + public DatabaseConnectionInfo MainDbConnection { get; private set; } + public DatabaseConnectionInfo LogDbConnection { get; private set; } public string GetDatabasePath(string connectionString) { @@ -39,37 +39,40 @@ namespace NzbDrone.Core.Datastore return connectionBuilder.DataSource; } - private static string GetConnectionString(string dbPath) + private static DatabaseConnectionInfo GetConnectionString(string dbPath) { - var connectionBuilder = new SQLiteConnectionStringBuilder(); - - connectionBuilder.DataSource = dbPath; - connectionBuilder.CacheSize = (int)-20000; - connectionBuilder.DateTimeKind = DateTimeKind.Utc; - connectionBuilder.JournalMode = OsInfo.IsOsx ? SQLiteJournalModeEnum.Truncate : SQLiteJournalModeEnum.Wal; - connectionBuilder.Pooling = true; - connectionBuilder.Version = 3; + var connectionBuilder = new SQLiteConnectionStringBuilder + { + DataSource = dbPath, + CacheSize = (int)-20000, + DateTimeKind = DateTimeKind.Utc, + JournalMode = OsInfo.IsOsx ? SQLiteJournalModeEnum.Truncate : SQLiteJournalModeEnum.Wal, + Pooling = true, + Version = 3, + BusyTimeout = 100 + }; if (OsInfo.IsOsx) { connectionBuilder.Add("Full FSync", true); } - return connectionBuilder.ConnectionString; + return new DatabaseConnectionInfo(DatabaseType.SQLite, connectionBuilder.ConnectionString); } - private string GetPostgresConnectionString(string dbName) + private DatabaseConnectionInfo GetPostgresConnectionString(string dbName) { - var connectionBuilder = new NpgsqlConnectionStringBuilder(); + var connectionBuilder = new NpgsqlConnectionStringBuilder + { + Database = dbName, + Host = _configFileProvider.PostgresHost, + Username = _configFileProvider.PostgresUser, + Password = _configFileProvider.PostgresPassword, + Port = _configFileProvider.PostgresPort, + Enlist = false + }; - connectionBuilder.Database = dbName; - connectionBuilder.Host = _configFileProvider.PostgresHost; - connectionBuilder.Username = _configFileProvider.PostgresUser; - connectionBuilder.Password = _configFileProvider.PostgresPassword; - connectionBuilder.Port = _configFileProvider.PostgresPort; - connectionBuilder.Enlist = false; - - return connectionBuilder.ConnectionString; + return new DatabaseConnectionInfo(DatabaseType.PostgreSQL, connectionBuilder.ConnectionString); } } } diff --git a/src/NzbDrone.Core/Datastore/Converters/TimeSpanConverter.cs b/src/NzbDrone.Core/Datastore/Converters/TimeSpanConverter.cs index 902a26009..fdcb227c6 100644 --- a/src/NzbDrone.Core/Datastore/Converters/TimeSpanConverter.cs +++ b/src/NzbDrone.Core/Datastore/Converters/TimeSpanConverter.cs @@ -2,18 +2,17 @@ using System; using System.Data; using Dapper; -namespace NzbDrone.Core.Datastore.Converters -{ - public class DapperTimeSpanConverter : SqlMapper.TypeHandler - { - public override void SetValue(IDbDataParameter parameter, TimeSpan value) - { - parameter.Value = value.ToString(); - } +namespace NzbDrone.Core.Datastore.Converters; - public override TimeSpan Parse(object value) - { - return TimeSpan.Parse((string)value); - } +public class TimeSpanConverter : SqlMapper.TypeHandler +{ + public override void SetValue(IDbDataParameter parameter, TimeSpan value) + { + parameter.Value = value.ToString(); + } + + public override TimeSpan Parse(object value) + { + return value is string str ? TimeSpan.Parse(str) : TimeSpan.Zero; } } diff --git a/src/NzbDrone.Core/Datastore/CorruptDatabaseException.cs b/src/NzbDrone.Core/Datastore/CorruptDatabaseException.cs index 82b4065f7..a8403187a 100644 --- a/src/NzbDrone.Core/Datastore/CorruptDatabaseException.cs +++ b/src/NzbDrone.Core/Datastore/CorruptDatabaseException.cs @@ -16,12 +16,12 @@ namespace NzbDrone.Core.Datastore } public CorruptDatabaseException(string message, Exception innerException, params object[] args) - : base(message, innerException, args) + : base(innerException, message, args) { } public CorruptDatabaseException(string message, Exception innerException) - : base(message, innerException) + : base(innerException, message) { } } diff --git a/src/NzbDrone.Core/Datastore/Database.cs b/src/NzbDrone.Core/Datastore/Database.cs index 887039bcb..741a22f0b 100644 --- a/src/NzbDrone.Core/Datastore/Database.cs +++ b/src/NzbDrone.Core/Datastore/Database.cs @@ -2,7 +2,6 @@ using System; using System.Data; using System.Data.Common; using System.Data.SQLite; -using System.Text.RegularExpressions; using Dapper; using NLog; using NzbDrone.Common.Instrumentation; @@ -52,9 +51,8 @@ namespace NzbDrone.Core.Datastore { using var db = _datamapperFactory(); var dbConnection = db as DbConnection; - var version = Regex.Replace(dbConnection.ServerVersion, @"\(.*?\)", ""); - return new Version(version); + return DatabaseVersionParser.ParseServerVersion(dbConnection.ServerVersion); } } diff --git a/src/NzbDrone.Core/Datastore/DatabaseConnectionInfo.cs b/src/NzbDrone.Core/Datastore/DatabaseConnectionInfo.cs new file mode 100644 index 000000000..5b53f086f --- /dev/null +++ b/src/NzbDrone.Core/Datastore/DatabaseConnectionInfo.cs @@ -0,0 +1,14 @@ +namespace NzbDrone.Core.Datastore +{ + public class DatabaseConnectionInfo + { + public DatabaseConnectionInfo(DatabaseType databaseType, string connectionString) + { + DatabaseType = databaseType; + ConnectionString = connectionString; + } + + public DatabaseType DatabaseType { get; internal set; } + public string ConnectionString { get; internal set; } + } +} diff --git a/src/NzbDrone.Core/Datastore/DatabaseVersionParser.cs b/src/NzbDrone.Core/Datastore/DatabaseVersionParser.cs new file mode 100644 index 000000000..ffc77cf18 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/DatabaseVersionParser.cs @@ -0,0 +1,16 @@ +using System; +using System.Text.RegularExpressions; + +namespace NzbDrone.Core.Datastore; + +public static class DatabaseVersionParser +{ + private static readonly Regex VersionRegex = new (@"^[^ ]+", RegexOptions.Compiled); + + public static Version ParseServerVersion(string serverVersion) + { + var match = VersionRegex.Match(serverVersion); + + return match.Success ? new Version(match.Value) : null; + } +} diff --git a/src/NzbDrone.Core/Datastore/DbFactory.cs b/src/NzbDrone.Core/Datastore/DbFactory.cs index c592d17f2..0122757a7 100644 --- a/src/NzbDrone.Core/Datastore/DbFactory.cs +++ b/src/NzbDrone.Core/Datastore/DbFactory.cs @@ -2,6 +2,7 @@ using System; using System.Data.Common; using System.Data.SQLite; using System.Net.Sockets; +using System.Threading; using NLog; using Npgsql; using NzbDrone.Common.Disk; @@ -60,22 +61,22 @@ namespace NzbDrone.Core.Datastore public IDatabase Create(MigrationContext migrationContext) { - string connectionString; + DatabaseConnectionInfo connectionInfo; switch (migrationContext.MigrationType) { case MigrationType.Main: { - connectionString = _connectionStringFactory.MainDbConnectionString; - CreateMain(connectionString, migrationContext); + connectionInfo = _connectionStringFactory.MainDbConnection; + CreateMain(connectionInfo.ConnectionString, migrationContext, connectionInfo.DatabaseType); break; } case MigrationType.Log: { - connectionString = _connectionStringFactory.LogDbConnectionString; - CreateLog(connectionString, migrationContext); + connectionInfo = _connectionStringFactory.LogDbConnection; + CreateLog(connectionInfo.ConnectionString, migrationContext, connectionInfo.DatabaseType); break; } @@ -90,14 +91,14 @@ namespace NzbDrone.Core.Datastore { DbConnection conn; - if (connectionString.Contains(".db")) + if (connectionInfo.DatabaseType == DatabaseType.SQLite) { conn = SQLiteFactory.Instance.CreateConnection(); - conn.ConnectionString = connectionString; + conn.ConnectionString = connectionInfo.ConnectionString; } else { - conn = new NpgsqlConnection(connectionString); + conn = new NpgsqlConnection(connectionInfo.ConnectionString); } conn.Open(); @@ -107,12 +108,12 @@ namespace NzbDrone.Core.Datastore return db; } - private void CreateMain(string connectionString, MigrationContext migrationContext) + private void CreateMain(string connectionString, MigrationContext migrationContext, DatabaseType databaseType) { try { _restoreDatabaseService.Restore(); - _migrationController.Migrate(connectionString, migrationContext); + _migrationController.Migrate(connectionString, migrationContext, databaseType); } catch (SQLiteException e) { @@ -135,15 +136,17 @@ namespace NzbDrone.Core.Datastore { Logger.Error(e, "Failure to connect to Postgres DB, {0} retries remaining", retryCount); + Thread.Sleep(5000); + try { - _migrationController.Migrate(connectionString, migrationContext); + _migrationController.Migrate(connectionString, migrationContext, databaseType); + return; } catch (Exception ex) { if (--retryCount > 0) { - System.Threading.Thread.Sleep(5000); continue; } @@ -162,11 +165,11 @@ namespace NzbDrone.Core.Datastore } } - private void CreateLog(string connectionString, MigrationContext migrationContext) + private void CreateLog(string connectionString, MigrationContext migrationContext, DatabaseType databaseType) { try { - _migrationController.Migrate(connectionString, migrationContext); + _migrationController.Migrate(connectionString, migrationContext, databaseType); } catch (SQLiteException e) { @@ -186,7 +189,7 @@ namespace NzbDrone.Core.Datastore Logger.Error("Unable to recreate logging database automatically. It will need to be removed manually."); } - _migrationController.Migrate(connectionString, migrationContext); + _migrationController.Migrate(connectionString, migrationContext, databaseType); } catch (Exception e) { diff --git a/src/NzbDrone.Core/Datastore/Extensions/CompositionExtensions.cs b/src/NzbDrone.Core/Datastore/Extensions/CompositionExtensions.cs index c5e31f92c..67e251805 100644 --- a/src/NzbDrone.Core/Datastore/Extensions/CompositionExtensions.cs +++ b/src/NzbDrone.Core/Datastore/Extensions/CompositionExtensions.cs @@ -8,6 +8,12 @@ namespace NzbDrone.Core.Datastore.Extensions public static IContainer AddDatabase(this IContainer container) { container.RegisterDelegate(f => new MainDatabase(f.Create()), Reuse.Singleton); + + return container; + } + + public static IContainer AddLogDatabase(this IContainer container) + { container.RegisterDelegate(f => new LogDatabase(f.Create(MigrationType.Log)), Reuse.Singleton); return container; @@ -16,6 +22,12 @@ namespace NzbDrone.Core.Datastore.Extensions public static IContainer AddDummyDatabase(this IContainer container) { container.RegisterInstance(new MainDatabase(null)); + + return container; + } + + public static IContainer AddDummyLogDatabase(this IContainer container) + { container.RegisterInstance(new LogDatabase(null)); return container; diff --git a/src/NzbDrone.Core/Datastore/Migration/000_database_engine_version_check.cs b/src/NzbDrone.Core/Datastore/Migration/000_database_engine_version_check.cs new file mode 100644 index 000000000..93bfc0afc --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/000_database_engine_version_check.cs @@ -0,0 +1,69 @@ +using System.Data; +using System.Text.RegularExpressions; +using FluentMigrator; +using NLog; +using NzbDrone.Common.Instrumentation; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Maintenance(MigrationStage.BeforeAll, TransactionBehavior.None)] + public class DatabaseEngineVersionCheck : FluentMigrator.Migration + { + protected readonly Logger _logger; + + public DatabaseEngineVersionCheck() + { + _logger = NzbDroneLogger.GetLogger(this); + } + + public override void Up() + { + IfDatabase("sqlite").Execute.WithConnection(LogSqliteVersion); + IfDatabase("postgres").Execute.WithConnection(LogPostgresVersion); + } + + public override void Down() + { + // No-op + } + + private void LogSqliteVersion(IDbConnection conn, IDbTransaction tran) + { + using (var versionCmd = conn.CreateCommand()) + { + versionCmd.Transaction = tran; + versionCmd.CommandText = "SELECT sqlite_version();"; + + using (var reader = versionCmd.ExecuteReader()) + { + while (reader.Read()) + { + var version = reader.GetString(0); + + _logger.Info("SQLite {0}", version); + } + } + } + } + + private void LogPostgresVersion(IDbConnection conn, IDbTransaction tran) + { + using (var versionCmd = conn.CreateCommand()) + { + versionCmd.Transaction = tran; + versionCmd.CommandText = "SHOW server_version"; + + using (var reader = versionCmd.ExecuteReader()) + { + while (reader.Read()) + { + var version = reader.GetString(0); + var cleanVersion = Regex.Replace(version, @"\(.*?\)", ""); + + _logger.Info("Postgres {0}", cleanVersion); + } + } + } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/036_postgres_update_timestamp_columns_to_with_timezone.cs b/src/NzbDrone.Core/Datastore/Migration/036_postgres_update_timestamp_columns_to_with_timezone.cs new file mode 100644 index 000000000..8a2d6fe9a --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/036_postgres_update_timestamp_columns_to_with_timezone.cs @@ -0,0 +1,39 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(036)] + public class postgres_update_timestamp_columns_to_with_timezone : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("ApplicationStatus").AlterColumn("InitialFailure").AsDateTimeOffset().Nullable(); + Alter.Table("ApplicationStatus").AlterColumn("MostRecentFailure").AsDateTimeOffset().Nullable(); + Alter.Table("ApplicationStatus").AlterColumn("DisabledTill").AsDateTimeOffset().Nullable(); + Alter.Table("Commands").AlterColumn("QueuedAt").AsDateTimeOffset().NotNullable(); + Alter.Table("Commands").AlterColumn("StartedAt").AsDateTimeOffset().Nullable(); + Alter.Table("Commands").AlterColumn("EndedAt").AsDateTimeOffset().Nullable(); + Alter.Table("DownloadClientStatus").AlterColumn("InitialFailure").AsDateTimeOffset().Nullable(); + Alter.Table("DownloadClientStatus").AlterColumn("MostRecentFailure").AsDateTimeOffset().Nullable(); + Alter.Table("DownloadClientStatus").AlterColumn("DisabledTill").AsDateTimeOffset().Nullable(); + Alter.Table("History").AlterColumn("Date").AsDateTimeOffset().NotNullable(); + Alter.Table("IndexerDefinitionVersions").AlterColumn("LastUpdated").AsDateTimeOffset().Nullable(); + Alter.Table("IndexerStatus").AlterColumn("InitialFailure").AsDateTimeOffset().Nullable(); + Alter.Table("IndexerStatus").AlterColumn("MostRecentFailure").AsDateTimeOffset().Nullable(); + Alter.Table("IndexerStatus").AlterColumn("DisabledTill").AsDateTimeOffset().Nullable(); + Alter.Table("IndexerStatus").AlterColumn("CookiesExpirationDate").AsDateTimeOffset().Nullable(); + Alter.Table("Indexers").AlterColumn("Added").AsDateTimeOffset().NotNullable(); + Alter.Table("ScheduledTasks").AlterColumn("LastExecution").AsDateTimeOffset().NotNullable(); + Alter.Table("ScheduledTasks").AlterColumn("LastStartTime").AsDateTimeOffset().Nullable(); + Alter.Table("VersionInfo").AlterColumn("AppliedOn").AsDateTimeOffset().Nullable(); + } + + protected override void LogDbUpgrade() + { + Alter.Table("Logs").AlterColumn("Time").AsDateTimeOffset().NotNullable(); + Alter.Table("UpdateHistory").AlterColumn("Date").AsDateTimeOffset().NotNullable(); + Alter.Table("VersionInfo").AlterColumn("AppliedOn").AsDateTimeOffset().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/037_add_notification_status.cs b/src/NzbDrone.Core/Datastore/Migration/037_add_notification_status.cs new file mode 100644 index 000000000..478b00103 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/037_add_notification_status.cs @@ -0,0 +1,19 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(037)] + public class add_notification_status : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("NotificationStatus") + .WithColumn("ProviderId").AsInt32().NotNullable().Unique() + .WithColumn("InitialFailure").AsDateTimeOffset().Nullable() + .WithColumn("MostRecentFailure").AsDateTimeOffset().Nullable() + .WithColumn("EscalationLevel").AsInt32().NotNullable() + .WithColumn("DisabledTill").AsDateTimeOffset().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/038_indexers_freeleech_only_config_contract.cs b/src/NzbDrone.Core/Datastore/Migration/038_indexers_freeleech_only_config_contract.cs new file mode 100644 index 000000000..7832dbf71 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/038_indexers_freeleech_only_config_contract.cs @@ -0,0 +1,16 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(038)] + public class indexers_freeleech_only_config_contract : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Update.Table("Indexers").Set(new { ConfigContract = "HDSpaceSettings" }).Where(new { Implementation = "HDSpace" }); + Update.Table("Indexers").Set(new { ConfigContract = "ImmortalSeedSettings" }).Where(new { Implementation = "ImmortalSeed" }); + Update.Table("Indexers").Set(new { ConfigContract = "XSpeedsSettings" }).Where(new { Implementation = "XSpeeds" }); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/039_email_encryption.cs b/src/NzbDrone.Core/Datastore/Migration/039_email_encryption.cs new file mode 100644 index 000000000..f275bbd70 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/039_email_encryption.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Data; +using Dapper; +using FluentMigrator; +using Newtonsoft.Json.Linq; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(039)] + public class email_encryption : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.WithConnection(ChangeEncryption); + } + + private void ChangeEncryption(IDbConnection conn, IDbTransaction tran) + { + var updated = new List(); + using (var getEmailCmd = conn.CreateCommand()) + { + getEmailCmd.Transaction = tran; + getEmailCmd.CommandText = "SELECT \"Id\", \"Settings\" FROM \"Notifications\" WHERE \"Implementation\" = 'Email'"; + + using (var reader = getEmailCmd.ExecuteReader()) + { + while (reader.Read()) + { + var id = reader.GetInt32(0); + var settings = Json.Deserialize(reader.GetString(1)); + + settings["useEncryption"] = settings.Value("requireEncryption") ?? false ? 1 : 0; + settings["requireEncryption"] = null; + + updated.Add(new + { + Settings = settings.ToJson(), + Id = id + }); + } + } + } + + var updateSql = "UPDATE \"Notifications\" SET \"Settings\" = @Settings WHERE \"Id\" = @Id"; + conn.Execute(updateSql, updated, transaction: tran); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/040_newznab_category_to_capabilities_settings.cs b/src/NzbDrone.Core/Datastore/Migration/040_newznab_category_to_capabilities_settings.cs new file mode 100644 index 000000000..ad573bd9c --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/040_newznab_category_to_capabilities_settings.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Data; +using Dapper; +using FluentMigrator; +using Newtonsoft.Json.Linq; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(40)] + public class newznab_category_to_capabilities_settings : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.WithConnection(MoveCategoriesToCapabilities); + } + + private void MoveCategoriesToCapabilities(IDbConnection conn, IDbTransaction tran) + { + var updated = new List(); + + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = tran; + cmd.CommandText = "SELECT \"Id\", \"Settings\" FROM \"Indexers\" WHERE \"Implementation\" IN ('Newznab', 'Torznab')"; + + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var id = reader.GetInt32(0); + var settings = Json.Deserialize(reader.GetString(1)); + + if ((settings.Value("capabilities")?.ContainsKey("categories") ?? false) == false + && settings.ContainsKey("categories") + && settings.TryGetValue("categories", out var categories)) + { + if (!settings.ContainsKey("capabilities")) + { + settings.Add("capabilities", new JObject()); + } + + settings.Value("capabilities")?.Add(new JProperty("categories", JArray.FromObject(categories))); + + if (settings.ContainsKey("categories")) + { + settings.Remove("categories"); + } + } + + updated.Add(new + { + Settings = settings.ToJson(), + Id = id + }); + } + } + } + + var updateSql = "UPDATE \"Indexers\" SET \"Settings\" = @Settings WHERE \"Id\" = @Id"; + conn.Execute(updateSql, updated, transaction: tran); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/041_gazelle_freeleech_token_options.cs b/src/NzbDrone.Core/Datastore/Migration/041_gazelle_freeleech_token_options.cs new file mode 100644 index 000000000..56a11c732 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/041_gazelle_freeleech_token_options.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Data; +using Dapper; +using FluentMigrator; +using Newtonsoft.Json.Linq; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(041)] + public class gazelle_freeleech_token_options : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.WithConnection(MigrateIndexersToTokenOptions); + } + + private void MigrateIndexersToTokenOptions(IDbConnection conn, IDbTransaction tran) + { + var updated = new List(); + + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = tran; + cmd.CommandText = "SELECT \"Id\", \"Settings\" FROM \"Indexers\" WHERE \"Implementation\" IN ('Orpheus', 'Redacted', 'AlphaRatio', 'BrokenStones', 'CGPeers', 'DICMusic', 'GreatPosterWall', 'SecretCinema')"; + + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var id = reader.GetInt32(0); + var settings = Json.Deserialize(reader.GetString(1)); + + if (settings.ContainsKey("useFreeleechToken") && settings.Value("useFreeleechToken").Type == JTokenType.Boolean) + { + var optionValue = settings.Value("useFreeleechToken") switch + { + true => 2, // Required + _ => 0 // Never + }; + + settings.Remove("useFreeleechToken"); + settings.Add("useFreeleechToken", optionValue); + } + + updated.Add(new + { + Id = id, + Settings = settings.ToJson() + }); + } + } + } + + var updateSql = "UPDATE \"Indexers\" SET \"Settings\" = @Settings WHERE \"Id\" = @Id"; + conn.Execute(updateSql, updated, transaction: tran); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/042_myanonamouse_freeleech_wedge_options.cs b/src/NzbDrone.Core/Datastore/Migration/042_myanonamouse_freeleech_wedge_options.cs new file mode 100644 index 000000000..5a93488d5 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/042_myanonamouse_freeleech_wedge_options.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Data; +using Dapper; +using FluentMigrator; +using Newtonsoft.Json.Linq; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(042)] + public class myanonamouse_freeleech_wedge_options : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.WithConnection(MigrateIndexersToWedgeOptions); + } + + private void MigrateIndexersToWedgeOptions(IDbConnection conn, IDbTransaction tran) + { + var updated = new List(); + + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = tran; + cmd.CommandText = "SELECT \"Id\", \"Settings\" FROM \"Indexers\" WHERE \"Implementation\" = 'MyAnonamouse'"; + + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var id = reader.GetInt32(0); + var settings = Json.Deserialize(reader.GetString(1)); + + if (settings.ContainsKey("freeleech") && settings.Value("freeleech").Type == JTokenType.Boolean) + { + var optionValue = settings.Value("freeleech") switch + { + true => 2, // Required + _ => 0 // Never + }; + + settings.Remove("freeleech"); + settings.Add("useFreeleechWedge", optionValue); + } + + updated.Add(new + { + Id = id, + Settings = settings.ToJson() + }); + } + } + } + + var updateSql = "UPDATE \"Indexers\" SET \"Settings\" = @Settings WHERE \"Id\" = @Id"; + conn.Execute(updateSql, updated, transaction: tran); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs b/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs index 1249dfd8b..74151de7d 100644 --- a/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs +++ b/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs @@ -14,7 +14,7 @@ namespace NzbDrone.Core.Datastore.Migration.Framework { public interface IMigrationController { - void Migrate(string connectionString, MigrationContext migrationContext); + void Migrate(string connectionString, MigrationContext migrationContext, DatabaseType databaseType); } public class MigrationController : IMigrationController @@ -29,7 +29,7 @@ namespace NzbDrone.Core.Datastore.Migration.Framework _migrationLoggerProvider = migrationLoggerProvider; } - public void Migrate(string connectionString, MigrationContext migrationContext) + public void Migrate(string connectionString, MigrationContext migrationContext, DatabaseType databaseType) { var sw = Stopwatch.StartNew(); @@ -37,22 +37,23 @@ namespace NzbDrone.Core.Datastore.Migration.Framework ServiceProvider serviceProvider; - var db = connectionString.Contains(".db") ? "sqlite" : "postgres"; + var db = databaseType == DatabaseType.SQLite ? "sqlite" : "postgres"; serviceProvider = new ServiceCollection() .AddLogging(b => b.AddNLog()) .AddFluentMigratorCore() + .Configure(cfg => cfg.IncludeUntaggedMaintenances = true) .ConfigureRunner( builder => builder .AddPostgres() .AddNzbDroneSQLite() .WithGlobalConnectionString(connectionString) - .WithMigrationsIn(Assembly.GetExecutingAssembly())) + .ScanIn(Assembly.GetExecutingAssembly()).For.All()) .Configure(opt => opt.Namespace = "NzbDrone.Core.Datastore.Migration") .Configure(opt => { opt.PreviewOnly = false; - opt.Timeout = TimeSpan.FromSeconds(60); + opt.Timeout = TimeSpan.FromMinutes(10); }) .Configure(cfg => { diff --git a/src/NzbDrone.Core/Datastore/Migration/Framework/SqliteSchemaDumper.cs b/src/NzbDrone.Core/Datastore/Migration/Framework/SqliteSchemaDumper.cs index d0a661751..a56384466 100644 --- a/src/NzbDrone.Core/Datastore/Migration/Framework/SqliteSchemaDumper.cs +++ b/src/NzbDrone.Core/Datastore/Migration/Framework/SqliteSchemaDumper.cs @@ -219,7 +219,7 @@ namespace NzbDrone.Core.Datastore.Migration.Framework protected virtual IList ReadTables() { - const string sqlCommand = @"SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name;"; + const string sqlCommand = @"SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_litestream_%' ORDER BY name;"; var dtTable = Read(sqlCommand).Tables[0]; var tableDefinitionList = new List(); diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index aa87b40f2..637f61b99 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -91,10 +91,9 @@ namespace NzbDrone.Core.Datastore .Ignore(c => c.Message); Mapper.Entity("IndexerStatus").RegisterModel(); - Mapper.Entity("DownloadClientStatus").RegisterModel(); - Mapper.Entity("ApplicationStatus").RegisterModel(); + Mapper.Entity("NotificationStatus").RegisterModel(); Mapper.Entity("CustomFilters").RegisterModel(); Mapper.Entity("UpdateHistory").RegisterModel(); @@ -110,7 +109,6 @@ namespace NzbDrone.Core.Datastore SqlMapper.RemoveTypeMap(typeof(DateTime)); SqlMapper.AddTypeHandler(new DapperUtcConverter()); - SqlMapper.AddTypeHandler(new DapperTimeSpanConverter()); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>()); SqlMapper.AddTypeHandler(new CookieConverter()); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>()); @@ -124,6 +122,9 @@ namespace NzbDrone.Core.Datastore SqlMapper.RemoveTypeMap(typeof(Guid)); SqlMapper.RemoveTypeMap(typeof(Guid?)); SqlMapper.AddTypeHandler(new GuidConverter()); + SqlMapper.RemoveTypeMap(typeof(TimeSpan)); + SqlMapper.RemoveTypeMap(typeof(TimeSpan?)); + SqlMapper.AddTypeHandler(new TimeSpanConverter()); SqlMapper.AddTypeHandler(new CommandConverter()); SqlMapper.AddTypeHandler(new SystemVersionConverter()); } diff --git a/src/NzbDrone.Core/Download/Clients/Aria2/Aria2.cs b/src/NzbDrone.Core/Download/Clients/Aria2/Aria2.cs index ae076c14b..3085dbf63 100644 --- a/src/NzbDrone.Core/Download/Clients/Aria2/Aria2.cs +++ b/src/NzbDrone.Core/Download/Clients/Aria2/Aria2.cs @@ -7,6 +7,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Validation; @@ -25,8 +26,9 @@ namespace NzbDrone.Core.Download.Clients.Aria2 ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxy = proxy; } diff --git a/src/NzbDrone.Core/Download/Clients/Aria2/Aria2Proxy.cs b/src/NzbDrone.Core/Download/Clients/Aria2/Aria2Proxy.cs index 9cc2d9793..74f653f76 100644 --- a/src/NzbDrone.Core/Download/Clients/Aria2/Aria2Proxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Aria2/Aria2Proxy.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Xml.Linq; using System.Xml.XPath; +using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Download.Extensions; @@ -95,8 +96,14 @@ namespace NzbDrone.Core.Download.Clients.Aria2 public string AddUri(Aria2Settings settings, string magnet) { - var response = ExecuteRequest(settings, "aria2.addUri", GetToken(settings), new List { magnet }); + var options = new Dictionary(); + if (settings.Directory.IsNotNullOrWhiteSpace()) + { + options.Add("dir", settings.Directory); + } + + var response = ExecuteRequest(settings, "aria2.addUri", GetToken(settings), new List { magnet }, options); var gid = response.GetStringResponse(); return gid; @@ -104,8 +111,16 @@ namespace NzbDrone.Core.Download.Clients.Aria2 public string AddTorrent(Aria2Settings settings, byte[] torrent) { - var response = ExecuteRequest(settings, "aria2.addTorrent", GetToken(settings), torrent); + // Aria2's second parameter is an array of URIs and needs to be sent if options are provided, this satisfies that requirement. + var emptyListOfUris = new List(); + var options = new Dictionary(); + if (settings.Directory.IsNotNullOrWhiteSpace()) + { + options.Add("dir", settings.Directory); + } + + var response = ExecuteRequest(settings, "aria2.addTorrent", GetToken(settings), torrent, emptyListOfUris, options); var gid = response.GetStringResponse(); return gid; diff --git a/src/NzbDrone.Core/Download/Clients/Aria2/Aria2Settings.cs b/src/NzbDrone.Core/Download/Clients/Aria2/Aria2Settings.cs index e88bc4cc1..f90ea6306 100644 --- a/src/NzbDrone.Core/Download/Clients/Aria2/Aria2Settings.cs +++ b/src/NzbDrone.Core/Download/Clients/Aria2/Aria2Settings.cs @@ -32,15 +32,18 @@ namespace NzbDrone.Core.Download.Clients.Aria2 [FieldDefinition(1, Label = "Port", Type = FieldType.Number)] public int Port { get; set; } - [FieldDefinition(2, Label = "XML RPC Path", Type = FieldType.Textbox)] + [FieldDefinition(2, Label = "XmlRpcPath", Type = FieldType.Textbox)] public string RpcPath { get; set; } - [FieldDefinition(3, Label = "Use SSL", Type = FieldType.Checkbox)] + [FieldDefinition(3, Label = "UseSsl", Type = FieldType.Checkbox)] public bool UseSsl { get; set; } - [FieldDefinition(4, Label = "Secret token", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] + [FieldDefinition(4, Label = "SecretToken", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] public string SecretToken { get; set; } + [FieldDefinition(5, Label = "Directory", Type = FieldType.Textbox, HelpText = "DownloadClientAriaSettingsDirectoryHelpText")] + public string Directory { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackhole.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackhole.cs index f8fd44e97..9e22a7460 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackhole.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackhole.cs @@ -8,6 +8,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.Download.Clients.Blackhole @@ -20,8 +21,9 @@ namespace NzbDrone.Core.Download.Clients.Blackhole ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { } diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackholeSettings.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackholeSettings.cs index a25d0fd4d..81793f947 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackholeSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackholeSettings.cs @@ -27,15 +27,16 @@ namespace NzbDrone.Core.Download.Clients.Blackhole private static readonly TorrentBlackholeSettingsValidator Validator = new TorrentBlackholeSettingsValidator(); - [FieldDefinition(0, Label = "Torrent Folder", Type = FieldType.Path, HelpText = "Folder in which Prowlarr will store the .torrent file")] + [FieldDefinition(0, Label = "TorrentBlackholeTorrentFolder", Type = FieldType.Path, HelpText = "BlackholeFolderHelpText")] + [FieldToken(TokenField.HelpText, "TorrentBlackholeTorrentFolder", "extension", ".torrent")] public string TorrentFolder { get; set; } [DefaultValue(false)] [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] - [FieldDefinition(1, Label = "Save Magnet Files", Type = FieldType.Checkbox, HelpText = "Save a .magnet file with the magnet link if no .torrent file is available (only useful if the download client supports .magnet files)")] + [FieldDefinition(1, Label = "TorrentBlackholeSaveMagnetFiles", Type = FieldType.Checkbox, HelpText = "TorrentBlackholeSaveMagnetFilesHelpText")] public bool SaveMagnetFiles { get; set; } - [FieldDefinition(2, Label = "Save Magnet Files", Type = FieldType.Textbox, HelpText = "Extension to use for magnet links, defaults to '.magnet'")] + [FieldDefinition(2, Label = "TorrentBlackholeSaveMagnetFilesExtension", Type = FieldType.Textbox, HelpText = "TorrentBlackholeSaveMagnetFilesExtensionHelpText")] public string MagnetFileExtension { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs index 0f0364e61..0b64150ee 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs @@ -7,6 +7,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.Download.Clients.Blackhole @@ -16,8 +17,9 @@ namespace NzbDrone.Core.Download.Clients.Blackhole public UsenetBlackhole(IHttpClient httpClient, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(httpClient, configService, diskProvider, logger) + : base(httpClient, configService, diskProvider, localizationService, logger) { } diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackholeSettings.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackholeSettings.cs index cb53385e5..d4b011d47 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackholeSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackholeSettings.cs @@ -18,7 +18,8 @@ namespace NzbDrone.Core.Download.Clients.Blackhole { private static readonly UsenetBlackholeSettingsValidator Validator = new UsenetBlackholeSettingsValidator(); - [FieldDefinition(0, Label = "Nzb Folder", Type = FieldType.Path, HelpText = "Folder in which Prowlarr will store the .nzb file")] + [FieldDefinition(0, Label = "UsenetBlackholeNzbFolder", Type = FieldType.Path, HelpText = "BlackholeFolderHelpText")] + [FieldToken(TokenField.HelpText, "UsenetBlackholeNzbFolder", "extension", ".nzb")] public string NzbFolder { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs b/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs index ca63ba0e7..90bd6ba1f 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs @@ -9,6 +9,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Validation; @@ -23,8 +24,9 @@ namespace NzbDrone.Core.Download.Clients.Deluge ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxy = proxy; } diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs index bf463eb81..64ff55a10 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs @@ -34,22 +34,24 @@ namespace NzbDrone.Core.Download.Clients.Deluge [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] public int Port { get; set; } - [FieldDefinition(2, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to Deluge")] + [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "DownloadClientSettingsUseSslHelpText")] + [FieldToken(TokenField.HelpText, "UseSsl", "clientName", "Deluge")] public bool UseSsl { get; set; } - [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the deluge json url, see http://[host]:[port]/[urlBase]/json")] + [FieldDefinition(3, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientDelugeSettingsUrlBaseHelpText")] + [FieldToken(TokenField.HelpText, "UrlBase", "url", "http://[host]:[port]/[urlBase]/json")] public string UrlBase { get; set; } [FieldDefinition(4, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] public string Password { get; set; } - [FieldDefinition(5, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback Category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")] + [FieldDefinition(5, Label = "DefaultCategory", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsDefaultCategoryHelpText")] public string Category { get; set; } - [FieldDefinition(6, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(DelugePriority), HelpText = "Priority to use when grabbing items")] + [FieldDefinition(6, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(DelugePriority), HelpText = "DownloadClientSettingsPriorityItemHelpText")] public int Priority { get; set; } - [FieldDefinition(7, Label = "Add Paused", Type = FieldType.Checkbox)] + [FieldDefinition(7, Label = "DownloadClientSettingsAddPaused", Type = FieldType.Checkbox)] public bool AddPaused { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApi.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApi.cs index 8fcefdd51..9b3cbfc33 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApi.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApi.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.Download.Clients.DownloadStation +namespace NzbDrone.Core.Download.Clients.DownloadStation { public enum DiskStationApi { @@ -6,6 +6,7 @@ Auth, DownloadStationInfo, DownloadStationTask, + DownloadStation2Task, FileStationList, DSMInfo, } diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApiInfo.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApiInfo.cs index 60f84c672..b507747b3 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApiInfo.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApiInfo.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.Download.Clients.DownloadStation +namespace NzbDrone.Core.Download.Clients.DownloadStation { public class DiskStationApiInfo { diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStation2Task.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStation2Task.cs new file mode 100644 index 000000000..ef52bb7e0 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStation2Task.cs @@ -0,0 +1,27 @@ +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public class DownloadStation2Task + { + public string Username { get; set; } + + public string Id { get; set; } + + public string Title { get; set; } + + public long Size { get; set; } + + /// + /// /// Possible values are: BT, NZB, http, ftp, eMule and https + /// + public string Type { get; set; } + + public int Status { get; set; } + + public DownloadStationTaskAdditional Additional { get; set; } + + public override string ToString() + { + return this.Title; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationSettings.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationSettings.cs index bc3e8ca1c..fea6d8fc6 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationSettings.cs @@ -36,7 +36,8 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] public int Port { get; set; } - [FieldDefinition(2, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to Download Station")] + [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "DownloadClientSettingsUseSslHelpText")] + [FieldToken(TokenField.HelpText, "UseSsl", "clientName", "Download Station")] public bool UseSsl { get; set; } [FieldDefinition(3, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] @@ -45,10 +46,10 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation [FieldDefinition(4, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] public string Password { get; set; } - [FieldDefinition(5, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional. Creates a [category] subdirectory in the output directory.")] + [FieldDefinition(5, Label = "DefaultCategory", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsDefaultCategoryHelpText")] public string Category { get; set; } - [FieldDefinition(6, Label = "Directory", Type = FieldType.Textbox, HelpText = "Optional shared folder to put downloads into, leave blank to use the default Download Station location")] + [FieldDefinition(6, Label = "Directory", Type = FieldType.Textbox, HelpText = "DownloadClientDownloadStationSettingsDirectoryHelpText")] public string TvDirectory { get; set; } public DownloadStationSettings() diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTask.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTask.cs index 41faac633..3c5de2fb9 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTask.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTask.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Newtonsoft.Json; using NzbDrone.Common.Serializer; diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTaskAdditional.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTaskAdditional.cs index 81e55569e..79c893ab5 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTaskAdditional.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTaskAdditional.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.DownloadStation diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTaskFile.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTaskFile.cs index 2538e3e10..6cc396cd7 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTaskFile.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTaskFile.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; using Newtonsoft.Json.Converters; namespace NzbDrone.Core.Download.Clients.DownloadStation diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DSMInfoProxy.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DSMInfoProxy.cs index 322296c06..46808fcbc 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DSMInfoProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DSMInfoProxy.cs @@ -1,4 +1,4 @@ -using NLog; +using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Http; using NzbDrone.Core.Download.Clients.DownloadStation.Responses; @@ -13,7 +13,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies public class DSMInfoProxy : DiskStationProxyBase, IDSMInfoProxy { public DSMInfoProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) - : base(DiskStationApi.DSMInfo, "SYNO.DSM.Info", httpClient, cacheManager, logger) + : base(DiskStationApi.DSMInfo, "SYNO.DSM.Info", httpClient, cacheManager, logger) { } diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs index 213b3e505..c1507aa2e 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs @@ -172,7 +172,14 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies { if (apiInfo.NeedsAuthentication) { - requestBuilder.AddFormParameter("_sid", _sessionCache.Get(GenerateSessionCacheKey(settings), () => AuthenticateClient(settings), TimeSpan.FromHours(6))); + if (_apiType == DiskStationApi.DownloadStation2Task) + { + requestBuilder.AddQueryParam("_sid", _sessionCache.Get(GenerateSessionCacheKey(settings), () => AuthenticateClient(settings), TimeSpan.FromHours(6))); + } + else + { + requestBuilder.AddFormParameter("_sid", _sessionCache.Get(GenerateSessionCacheKey(settings), () => AuthenticateClient(settings), TimeSpan.FromHours(6))); + } } requestBuilder.AddFormParameter("api", apiInfo.Name); @@ -242,7 +249,14 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies if (info == null) { - throw new DownloadClientException("Info of {0} not found on {1}:{2}", api, settings.Host, settings.Port); + if (api == DiskStationApi.DownloadStation2Task) + { + _logger.Warn("Info of {0} not found on {1}:{2}", api, settings.Host, settings.Port); + } + else + { + throw new DownloadClientException("Info of {0} not found on {1}:{2}", api, settings.Host, settings.Port); + } } } diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationInfoProxy.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationInfoProxy.cs index 1723fcc80..5fe44ddfd 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationInfoProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationInfoProxy.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Http; @@ -13,7 +13,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies public class DownloadStationInfoProxy : DiskStationProxyBase, IDownloadStationInfoProxy { public DownloadStationInfoProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) - : base(DiskStationApi.DownloadStationInfo, "SYNO.DownloadStation.Info", httpClient, cacheManager, logger) + : base(DiskStationApi.DownloadStationInfo, "SYNO.DownloadStation.Info", httpClient, cacheManager, logger) { } diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxySelector.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxySelector.cs new file mode 100644 index 000000000..1eae7930e --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxySelector.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.Cache; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies +{ + public interface IDownloadStationTaskProxy : IDiskStationProxy + { + bool IsApiSupported(DownloadStationSettings settings); + IEnumerable GetTasks(DownloadStationSettings settings); + void RemoveTask(string downloadId, DownloadStationSettings settings); + void AddTaskFromUrl(string url, string downloadDirectory, DownloadStationSettings settings); + void AddTaskFromData(byte[] data, string filename, string downloadDirectory, DownloadStationSettings settings); + } + + public interface IDownloadStationTaskProxySelector + { + IDownloadStationTaskProxy GetProxy(DownloadStationSettings settings); + } + + public class DownloadStationTaskProxySelector : IDownloadStationTaskProxySelector + { + private readonly ICached _proxyCache; + private readonly Logger _logger; + + private readonly IDownloadStationTaskProxy _proxyV1; + private readonly IDownloadStationTaskProxy _proxyV2; + + public DownloadStationTaskProxySelector(DownloadStationTaskProxyV1 proxyV1, DownloadStationTaskProxyV2 proxyV2, ICacheManager cacheManager, Logger logger) + { + _proxyCache = cacheManager.GetCache(GetType(), "taskProxy"); + _logger = logger; + + _proxyV1 = proxyV1; + _proxyV2 = proxyV2; + } + + public IDownloadStationTaskProxy GetProxy(DownloadStationSettings settings) + { + return GetProxyCache(settings); + } + + private IDownloadStationTaskProxy GetProxyCache(DownloadStationSettings settings) + { + var propKey = $"{settings.Host}_{settings.Port}"; + + return _proxyCache.Get(propKey, () => FetchProxy(settings), TimeSpan.FromMinutes(10.0)); + } + + private IDownloadStationTaskProxy FetchProxy(DownloadStationSettings settings) + { + if (_proxyV2.IsApiSupported(settings)) + { + _logger.Trace("Using DownloadStation Task API v2"); + return _proxyV2; + } + + if (_proxyV1.IsApiSupported(settings)) + { + _logger.Trace("Using DownloadStation Task API v1"); + return _proxyV1; + } + + throw new DownloadClientException("Unable to determine DownloadStations Task API version"); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxy.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxyV1.cs similarity index 79% rename from src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxy.cs rename to src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxyV1.cs index 5ae04a152..13e5131fb 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxyV1.cs @@ -9,21 +9,18 @@ using NzbDrone.Core.Download.Clients.DownloadStation.Responses; namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies { - public interface IDownloadStationTaskProxy : IDiskStationProxy + public class DownloadStationTaskProxyV1 : DiskStationProxyBase, IDownloadStationTaskProxy { - IEnumerable GetTasks(DownloadStationSettings settings); - void RemoveTask(string downloadId, DownloadStationSettings settings); - void AddTaskFromUrl(string url, string downloadDirectory, DownloadStationSettings settings); - void AddTaskFromData(byte[] data, string filename, string downloadDirectory, DownloadStationSettings settings); - } - - public class DownloadStationTaskProxy : DiskStationProxyBase, IDownloadStationTaskProxy - { - public DownloadStationTaskProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + public DownloadStationTaskProxyV1(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) : base(DiskStationApi.DownloadStationTask, "SYNO.DownloadStation.Task", httpClient, cacheManager, logger) { } + public bool IsApiSupported(DownloadStationSettings settings) + { + return GetApiInfo(settings) != null; + } + public void AddTaskFromData(byte[] data, string filename, string downloadDirectory, DownloadStationSettings settings) { var requestBuilder = BuildRequest(settings, "create", 2, HttpMethod.Post); diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxyV2.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxyV2.cs new file mode 100644 index 000000000..6f81c3ac3 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxyV2.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Download.Clients.DownloadStation.Responses; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies +{ + public class DownloadStationTaskProxyV2 : DiskStationProxyBase, IDownloadStationTaskProxy + { + public DownloadStationTaskProxyV2(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + : base(DiskStationApi.DownloadStation2Task, "SYNO.DownloadStation2.Task", httpClient, cacheManager, logger) + { + } + + public bool IsApiSupported(DownloadStationSettings settings) + { + return GetApiInfo(settings) != null; + } + + public void AddTaskFromData(byte[] data, string filename, string downloadDirectory, DownloadStationSettings settings) + { + var requestBuilder = BuildRequest(settings, "create", 2, HttpMethod.Post); + + requestBuilder.AddFormParameter("type", "\"file\""); + requestBuilder.AddFormParameter("file", "[\"fileData\"]"); + requestBuilder.AddFormParameter("create_list", "false"); + + if (downloadDirectory.IsNotNullOrWhiteSpace()) + { + requestBuilder.AddFormParameter("destination", $"\"{downloadDirectory}\""); + } + + requestBuilder.AddFormUpload("fileData", filename, data); + + ProcessRequest(requestBuilder, $"add task from data {filename}", settings); + } + + public void AddTaskFromUrl(string url, string downloadDirectory, DownloadStationSettings settings) + { + var requestBuilder = BuildRequest(settings, "create", 2); + + requestBuilder.AddQueryParam("type", "url"); + requestBuilder.AddQueryParam("url", url); + requestBuilder.AddQueryParam("create_list", "false"); + + if (downloadDirectory.IsNotNullOrWhiteSpace()) + { + requestBuilder.AddQueryParam("destination", downloadDirectory); + } + + ProcessRequest(requestBuilder, $"add task from url {url}", settings); + } + + public IEnumerable GetTasks(DownloadStationSettings settings) + { + try + { + var result = new List(); + + var requestBuilder = BuildRequest(settings, "list", 1); + requestBuilder.AddQueryParam("additional", "detail"); + + var response = ProcessRequest(requestBuilder, "get tasks with additional detail", settings); + + if (response.Success && response.Data.Total > 0) + { + requestBuilder.AddQueryParam("additional", "transfer"); + var responseTransfer = ProcessRequest(requestBuilder, "get tasks with additional transfer", settings); + + if (responseTransfer.Success) + { + foreach (var task in response.Data.Task) + { + var taskTransfer = responseTransfer.Data.Task.Where(t => t.Id == task.Id).First(); + + var combinedTask = new DownloadStationTask + { + Username = task.Username, + Id = task.Id, + Title = task.Title, + Size = task.Size, + Status = (DownloadStationTaskStatus)task.Status, + Type = task.Type, + Additional = new DownloadStationTaskAdditional + { + Detail = task.Additional.Detail, + Transfer = taskTransfer.Additional.Transfer + } + }; + + result.Add(combinedTask); + } + } + } + + return result; + } + catch (DownloadClientException e) + { + _logger.Error(e); + return Array.Empty(); + } + } + + public void RemoveTask(string downloadId, DownloadStationSettings settings) + { + var requestBuilder = BuildRequest(settings, "delete", 2); + requestBuilder.AddQueryParam("id", downloadId); + requestBuilder.AddQueryParam("force_complete", "false"); + + ProcessRequest(requestBuilder, $"remove item {downloadId}", settings); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/FileStationProxy.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/FileStationProxy.cs index a07cc1b47..fbfa3b4ae 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/FileStationProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/FileStationProxy.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Http; diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DSMInfoResponse.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DSMInfoResponse.cs index 0848bba70..e2e81c4ca 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DSMInfoResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DSMInfoResponse.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses { diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationAuthResponse.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationAuthResponse.cs index d02503a25..04d6444ac 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationAuthResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationAuthResponse.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses +namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses { public class DiskStationAuthResponse { diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationError.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationError.cs index 50758d3af..c3f9b1090 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationError.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationError.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses { @@ -20,16 +20,22 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses { 104, "The requested version does not support the functionality" }, { 105, "The logged in session does not have permission" }, { 106, "Session timeout" }, - { 107, "Session interrupted by duplicate login" } + { 107, "Session interrupted by duplicate login" }, + { 119, "SID not found" } }; AuthMessages = new Dictionary { { 400, "No such account or incorrect password" }, - { 401, "Account disabled" }, - { 402, "Permission denied" }, - { 403, "2-step verification code required" }, - { 404, "Failed to authenticate 2-step verification code" } + { 401, "Disabled account" }, + { 402, "Denied permission" }, + { 403, "2-step authentication code required" }, + { 404, "Failed to authenticate 2-step authentication code" }, + { 406, "Enforce to authenticate with 2-factor authentication code" }, + { 407, "Blocked IP source" }, + { 408, "Expired password cannot change" }, + { 409, "Expired password" }, + { 410, "Password must be changed" } }; DownloadStationTaskMessages = new Dictionary @@ -76,7 +82,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses public int Code { get; set; } - public bool SessionError => Code == 105 || Code == 106 || Code == 107; + public bool SessionError => Code == 105 || Code == 106 || Code == 107 || Code == 119; public string GetMessage(DiskStationApi api) { @@ -85,7 +91,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses return AuthMessages[Code]; } - if (api == DiskStationApi.DownloadStationTask && DownloadStationTaskMessages.ContainsKey(Code)) + if ((api == DiskStationApi.DownloadStationTask || api == DiskStationApi.DownloadStation2Task) && DownloadStationTaskMessages.ContainsKey(Code)) { return DownloadStationTaskMessages[Code]; } diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationInfoResponse.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationInfoResponse.cs index 6c40ae75c..c80b213e0 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationInfoResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationInfoResponse.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses { diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationResponse.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationResponse.cs index 43c981669..6354adeb3 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationResponse.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses +namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses { public class DiskStationResponse where T : new() diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DownloadStation2TaskInfoResponse.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DownloadStation2TaskInfoResponse.cs new file mode 100644 index 000000000..4d98c16d7 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DownloadStation2TaskInfoResponse.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses +{ + public class DownloadStation2TaskInfoResponse + { + public int Offset { get; set; } + public List Task { get; set; } + public int Total { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DownloadStationTaskInfoResponse.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DownloadStationTaskInfoResponse.cs index ebd79f3d7..877b1890a 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DownloadStationTaskInfoResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DownloadStationTaskInfoResponse.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses { diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/FileStationListFileInfoResponse.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/FileStationListFileInfoResponse.cs index f31d51a68..927fd242b 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/FileStationListFileInfoResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/FileStationListFileInfoResponse.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses { diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/FileStationListResponse.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/FileStationListResponse.cs index e12c60094..823465bbe 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/FileStationListResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/FileStationListResponse.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses { diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/SerialNumberProvider.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/SerialNumberProvider.cs index 88a419d22..dfeb227a2 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/SerialNumberProvider.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/SerialNumberProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Crypto; @@ -16,6 +16,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation { private readonly IDSMInfoProxy _proxy; private readonly ILogger _logger; + private ICached _cache; public SerialNumberProvider(ICacheManager cacheManager, diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/SharedFolderMapping.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/SharedFolderMapping.cs index 15946e861..354e1d50b 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/SharedFolderMapping.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/SharedFolderMapping.cs @@ -1,4 +1,4 @@ -using NzbDrone.Common.Disk; +using NzbDrone.Common.Disk; namespace NzbDrone.Core.Download.Clients.DownloadStation { diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/SharedFolderResolver.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/SharedFolderResolver.cs index 25ff176f6..b5a308a37 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/SharedFolderResolver.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/SharedFolderResolver.cs @@ -1,4 +1,4 @@ -using System; +using System; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Disk; @@ -15,6 +15,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation { private readonly IFileStationProxy _proxy; private readonly ILogger _logger; + private ICached _cache; public SharedFolderResolver(ICacheManager cacheManager, diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs index 09db5a136..97afe9480 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs @@ -10,6 +10,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Download.Clients.DownloadStation.Proxies; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -19,7 +20,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation public class TorrentDownloadStation : TorrentClientBase { protected readonly IDownloadStationInfoProxy _dsInfoProxy; - protected readonly IDownloadStationTaskProxy _dsTaskProxy; + protected readonly IDownloadStationTaskProxySelector _dsTaskProxySelector; protected readonly ISharedFolderResolver _sharedFolderResolver; protected readonly ISerialNumberProvider _serialNumberProvider; protected readonly IFileStationProxy _fileStationProxy; @@ -28,16 +29,17 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation ISerialNumberProvider serialNumberProvider, IFileStationProxy fileStationProxy, IDownloadStationInfoProxy dsInfoProxy, - IDownloadStationTaskProxy dsTaskProxy, + IDownloadStationTaskProxySelector dsTaskProxySelector, ITorrentFileInfoReader torrentFileInfoReader, ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _dsInfoProxy = dsInfoProxy; - _dsTaskProxy = dsTaskProxy; + _dsTaskProxySelector = dsTaskProxySelector; _fileStationProxy = fileStationProxy; _sharedFolderResolver = sharedFolderResolver; _serialNumberProvider = serialNumberProvider; @@ -48,16 +50,18 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation public override ProviderMessage Message => new ProviderMessage("Prowlarr is unable to connect to Download Station if 2-Factor Authentication is enabled on your DSM account", ProviderMessageType.Warning); + private IDownloadStationTaskProxy DsTaskProxy => _dsTaskProxySelector.GetProxy(Settings); + protected IEnumerable GetTasks() { - return _dsTaskProxy.GetTasks(Settings).Where(v => v.Type.ToLower() == DownloadStationTaskType.BT.ToString().ToLower()); + return DsTaskProxy.GetTasks(Settings).Where(v => v.Type.ToLower() == DownloadStationTaskType.BT.ToString().ToLower()); } protected override string AddFromMagnetLink(TorrentInfo release, string hash, string magnetLink) { var hashedSerialNumber = _serialNumberProvider.GetSerialNumber(Settings); - _dsTaskProxy.AddTaskFromUrl(magnetLink, GetDownloadDirectory(), Settings); + DsTaskProxy.AddTaskFromUrl(magnetLink, GetDownloadDirectory(), Settings); var item = GetTasks().SingleOrDefault(t => t.Additional.Detail["uri"] == magnetLink); @@ -76,7 +80,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation { var hashedSerialNumber = _serialNumberProvider.GetSerialNumber(Settings); - _dsTaskProxy.AddTaskFromData(fileContent, filename, GetDownloadDirectory(), Settings); + DsTaskProxy.AddTaskFromData(fileContent, filename, GetDownloadDirectory(), Settings); var items = GetTasks().Where(t => t.Additional.Detail["uri"] == Path.GetFileNameWithoutExtension(filename)); @@ -217,6 +221,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation } catch (DownloadClientAuthenticationException ex) { + // User could not have permission to access to downloadstation _logger.Error(ex, ex.Message); return new NzbDroneValidationFailure(string.Empty, ex.Message); } @@ -268,7 +273,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation protected ValidationFailure ValidateVersion() { - var info = _dsTaskProxy.GetApiInfo(Settings); + var info = DsTaskProxy.GetApiInfo(Settings); _logger.Debug("Download Station api version information: Min {0} - Max {1}", info.MinVersion, info.MaxVersion); @@ -305,14 +310,15 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation { return Settings.TvDirectory.TrimStart('/'); } - else if (Settings.Category.IsNotNullOrWhiteSpace()) - { - var destDir = GetDefaultDir(); + var destDir = GetDefaultDir(); + + if (destDir.IsNotNullOrWhiteSpace() && Settings.Category.IsNotNullOrWhiteSpace()) + { return $"{destDir.TrimEnd('/')}/{Settings.Category}"; } - return null; + return destDir.TrimEnd('/'); } protected override string AddFromTorrentLink(TorrentInfo release, string hash, string torrentLink) diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs index 1759cf600..e78f5f5d2 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs @@ -9,6 +9,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Download.Clients.DownloadStation.Proxies; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -18,7 +19,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation public class UsenetDownloadStation : UsenetClientBase { protected readonly IDownloadStationInfoProxy _dsInfoProxy; - protected readonly IDownloadStationTaskProxy _dsTaskProxy; + protected readonly IDownloadStationTaskProxySelector _dsTaskProxySelector; protected readonly ISharedFolderResolver _sharedFolderResolver; protected readonly ISerialNumberProvider _serialNumberProvider; protected readonly IFileStationProxy _fileStationProxy; @@ -27,15 +28,16 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation ISerialNumberProvider serialNumberProvider, IFileStationProxy fileStationProxy, IDownloadStationInfoProxy dsInfoProxy, - IDownloadStationTaskProxy dsTaskProxy, + IDownloadStationTaskProxySelector dsTaskProxySelector, IHttpClient httpClient, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(httpClient, configService, diskProvider, logger) + : base(httpClient, configService, diskProvider, localizationService, logger) { _dsInfoProxy = dsInfoProxy; - _dsTaskProxy = dsTaskProxy; + _dsTaskProxySelector = dsTaskProxySelector; _fileStationProxy = fileStationProxy; _sharedFolderResolver = sharedFolderResolver; _serialNumberProvider = serialNumberProvider; @@ -46,16 +48,18 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation public override ProviderMessage Message => new ProviderMessage("Prowlarr is unable to connect to Download Station if 2-Factor Authentication is enabled on your DSM account", ProviderMessageType.Warning); + private IDownloadStationTaskProxy DsTaskProxy => _dsTaskProxySelector.GetProxy(Settings); + protected IEnumerable GetTasks() { - return _dsTaskProxy.GetTasks(Settings).Where(v => v.Type.ToLower() == DownloadStationTaskType.NZB.ToString().ToLower()); + return DsTaskProxy.GetTasks(Settings).Where(v => v.Type.ToLower() == DownloadStationTaskType.NZB.ToString().ToLower()); } protected override string AddFromNzbFile(ReleaseInfo release, string filename, byte[] fileContent) { var hashedSerialNumber = _serialNumberProvider.GetSerialNumber(Settings); - _dsTaskProxy.AddTaskFromData(fileContent, filename, GetDownloadDirectory(), Settings); + DsTaskProxy.AddTaskFromData(fileContent, filename, GetDownloadDirectory(), Settings); var items = GetTasks().Where(t => t.Additional.Detail["uri"] == filename); @@ -127,6 +131,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation } catch (DownloadClientAuthenticationException ex) { + // User could not have permission to access to downloadstation _logger.Error(ex, ex.Message); return new NzbDroneValidationFailure(string.Empty, ex.Message); } @@ -178,7 +183,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation protected ValidationFailure ValidateVersion() { - var info = _dsTaskProxy.GetApiInfo(Settings); + var info = DsTaskProxy.GetApiInfo(Settings); _logger.Debug("Download Station api version information: Min {0} - Max {1}", info.MinVersion, info.MaxVersion); @@ -271,14 +276,15 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation { return Settings.TvDirectory.TrimStart('/'); } - else if (Settings.Category.IsNotNullOrWhiteSpace()) - { - var destDir = GetDefaultDir(); + var destDir = GetDefaultDir(); + + if (destDir.IsNotNullOrWhiteSpace() && Settings.Category.IsNotNullOrWhiteSpace()) + { return $"{destDir.TrimEnd('/')}/{Settings.Category}"; } - return null; + return destDir.TrimEnd('/'); } protected override string AddFromLink(ReleaseInfo release) diff --git a/src/NzbDrone.Core/Download/Clients/Flood/Flood.cs b/src/NzbDrone.Core/Download/Clients/Flood/Flood.cs index 73c6de8a2..5d39d7b5f 100644 --- a/src/NzbDrone.Core/Download/Clients/Flood/Flood.cs +++ b/src/NzbDrone.Core/Download/Clients/Flood/Flood.cs @@ -4,9 +4,11 @@ using System.Linq; using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Download.Clients.Flood.Models; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.ThingiProvider; @@ -21,8 +23,9 @@ namespace NzbDrone.Core.Download.Clients.Flood ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxy = proxy; } @@ -56,7 +59,7 @@ namespace NzbDrone.Core.Download.Clients.Flood } } - return result; + return result.Where(t => t.IsNotNullOrWhiteSpace()); } public override string Name => "Flood"; diff --git a/src/NzbDrone.Core/Download/Clients/Flood/FloodSettings.cs b/src/NzbDrone.Core/Download/Clients/Flood/FloodSettings.cs index 46f6f19cb..1026e93e9 100644 --- a/src/NzbDrone.Core/Download/Clients/Flood/FloodSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Flood/FloodSettings.cs @@ -40,10 +40,12 @@ namespace NzbDrone.Core.Download.Clients.Flood [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] public int Port { get; set; } - [FieldDefinition(2, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to Flood")] + [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "DownloadClientSettingsUseSslHelpText")] + [FieldToken(TokenField.HelpText, "UseSsl", "clientName", "Flood")] public bool UseSsl { get; set; } - [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, HelpText = "Optionally adds a prefix to Flood API, such as [protocol]://[host]:[port]/[urlBase]api")] + [FieldDefinition(3, Label = "UrlBase", Type = FieldType.Textbox, HelpText = "DownloadClientFloodSettingsUrlBaseHelpText")] + [FieldToken(TokenField.HelpText, "UrlBase", "url", "[protocol]://[host]:[port]/[urlBase]/api")] public string UrlBase { get; set; } [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] @@ -52,16 +54,16 @@ namespace NzbDrone.Core.Download.Clients.Flood [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] public string Password { get; set; } - [FieldDefinition(6, Label = "Destination", Type = FieldType.Textbox, HelpText = "Manually specifies download destination")] + [FieldDefinition(6, Label = "Destination", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsDestinationHelpText")] public string Destination { get; set; } - [FieldDefinition(7, Label = "Tags", Type = FieldType.Tag, HelpText = "Initial tags of a download. To be recognized, a download must have all initial tags. This avoids conflicts with unrelated downloads.")] + [FieldDefinition(7, Label = "Tags", Type = FieldType.Tag, HelpText = "DownloadClientFloodSettingsTagsHelpText")] public IEnumerable Tags { get; set; } - [FieldDefinition(8, Label = "Additional Tags", Type = FieldType.Select, SelectOptions = typeof(AdditionalTags), HelpText = "Adds properties of media as tags. Hints are examples.", Advanced = true)] + [FieldDefinition(8, Label = "DownloadClientFloodSettingsAdditionalTags", Type = FieldType.Select, SelectOptions = typeof(AdditionalTags), HelpText = "DownloadClientFloodSettingsAdditionalTagsHelpText", Advanced = true)] public IEnumerable AdditionalTags { get; set; } - [FieldDefinition(9, Label = "Add Paused", Type = FieldType.Checkbox)] + [FieldDefinition(9, Label = "DownloadClientSettingsAddPaused", Type = FieldType.Checkbox)] public bool AddPaused { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadSettings.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadSettings.cs index ee0fff8e1..effdf37d9 100644 --- a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadSettings.cs @@ -46,34 +46,39 @@ namespace NzbDrone.Core.Download.Clients.FreeboxDownload ApiUrl = "/api/v1/"; } - [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox, HelpText = "Hostname or host IP address of the Freebox, defaults to 'mafreebox.freebox.fr' (will only work if on same network)")] + [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox, HelpText = "DownloadClientFreeboxSettingsHostHelpText")] + [FieldToken(TokenField.HelpText, "Host", "url", "mafreebox.freebox.fr")] public string Host { get; set; } - [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox, HelpText = "Port used to access Freebox interface, defaults to '443'")] + [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox, HelpText = "DownloadClientFreeboxSettingsPortHelpText")] + [FieldToken(TokenField.HelpText, "Port", "port", 443)] public int Port { get; set; } - [FieldDefinition(2, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secured connection when connecting to Freebox API")] + [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "DownloadClientSettingsUseSslHelpText")] + [FieldToken(TokenField.HelpText, "UseSsl", "clientName", "Freebox API")] public bool UseSsl { get; set; } - [FieldDefinition(3, Label = "API URL", Type = FieldType.Textbox, Advanced = true, HelpText = "Define Freebox API base URL with API version, eg http://[host]:[port]/[api_base_url]/[api_version]/, defaults to '/api/v1/'")] + [FieldDefinition(3, Label = "DownloadClientFreeboxSettingsApiUrl", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientFreeboxSettingsApiUrlHelpText")] + [FieldToken(TokenField.HelpText, "DownloadClientFreeboxSettingsApiUrl", "url", "http://[host]:[port]/[api_base_url]/[api_version]/")] + [FieldToken(TokenField.HelpText, "DownloadClientFreeboxSettingsApiUrl", "defaultApiUrl", "/api/v1/")] public string ApiUrl { get; set; } - [FieldDefinition(4, Label = "App ID", Type = FieldType.Textbox, HelpText = "App ID given when creating access to Freebox API (ie 'app_id')")] + [FieldDefinition(4, Label = "DownloadClientFreeboxSettingsAppId", Type = FieldType.Textbox, HelpText = "DownloadClientFreeboxSettingsAppIdHelpText")] public string AppId { get; set; } - [FieldDefinition(5, Label = "App Token", Type = FieldType.Password, Privacy = PrivacyLevel.Password, HelpText = "App token retrieved when creating access to Freebox API (ie 'app_token')")] + [FieldDefinition(5, Label = "DownloadClientFreeboxSettingsAppToken", Type = FieldType.Password, Privacy = PrivacyLevel.Password, HelpText = "DownloadClientFreeboxSettingsAppTokenHelpText")] public string AppToken { get; set; } - [FieldDefinition(6, Label = "Destination Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "Optional location to put downloads in, leave blank to use the default Freebox download location")] + [FieldDefinition(6, Label = "Destination", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsDestinationHelpText")] public string DestinationDirectory { get; set; } - [FieldDefinition(7, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Prowlarr avoids conflicts with unrelated non-Prowlarr downloads (will create a [category] subdirectory in the output directory)")] + [FieldDefinition(7, Label = "DefaultCategory", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsDefaultCategoryHelpText")] public string Category { get; set; } - [FieldDefinition(8, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(FreeboxDownloadPriority), HelpText = "Priority to use when grabbing")] + [FieldDefinition(8, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(FreeboxDownloadPriority), HelpText = "DownloadClientSettingsPriorityItemHelpText")] public int Priority { get; set; } - [FieldDefinition(9, Label = "Add Paused", Type = FieldType.Checkbox)] + [FieldDefinition(9, Label = "DownloadClientSettingsAddPaused", Type = FieldType.Checkbox)] public bool AddPaused { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/TorrentFreeboxDownload.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/TorrentFreeboxDownload.cs index cec808592..00e7e06b4 100644 --- a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/TorrentFreeboxDownload.cs +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/TorrentFreeboxDownload.cs @@ -6,6 +6,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.Download.Clients.FreeboxDownload @@ -19,8 +20,9 @@ namespace NzbDrone.Core.Download.Clients.FreeboxDownload ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxy = proxy; } diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs index b5aa1eb06..560c40eb3 100644 --- a/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs @@ -6,6 +6,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Validation; @@ -20,8 +21,9 @@ namespace NzbDrone.Core.Download.Clients.Hadouken ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxy = proxy; } diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettings.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettings.cs index c8e6f7763..c26923afb 100644 --- a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettings.cs @@ -39,10 +39,13 @@ namespace NzbDrone.Core.Download.Clients.Hadouken [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] public int Port { get; set; } - [FieldDefinition(2, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to Hadouken")] + [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "DownloadClientSettingsUseSslHelpText")] + [FieldToken(TokenField.HelpText, "UseSsl", "clientName", "Hadouken")] public bool UseSsl { get; set; } - [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the Hadouken url, e.g. http://[host]:[port]/[urlBase]/api")] + [FieldDefinition(3, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsUrlBaseHelpText")] + [FieldToken(TokenField.HelpText, "UrlBase", "clientName", "Hadouken")] + [FieldToken(TokenField.HelpText, "UrlBase", "url", "http://[host]:[port]/[urlBase]/api")] public string UrlBase { get; set; } [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] @@ -51,7 +54,7 @@ namespace NzbDrone.Core.Download.Clients.Hadouken [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] public string Password { get; set; } - [FieldDefinition(6, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release.")] + [FieldDefinition(6, Label = "DefaultCategory", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsDefaultCategoryHelpText")] public string Category { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs index e1088780d..a87460d71 100644 --- a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs @@ -7,6 +7,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Validation; @@ -20,8 +21,9 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex IHttpClient httpClient, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(httpClient, configService, diskProvider, logger) + : base(httpClient, configService, diskProvider, localizationService, logger) { _proxy = proxy; } diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexSettings.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexSettings.cs index 2dae1ac49..1f276fa25 100644 --- a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexSettings.cs @@ -41,16 +41,18 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] public int Port { get; set; } - [FieldDefinition(2, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the NZBVortex url, e.g. http://[host]:[port]/[urlBase]/api")] + [FieldDefinition(2, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsUrlBaseHelpText")] + [FieldToken(TokenField.HelpText, "UrlBase", "clientName", "NZBVortex")] + [FieldToken(TokenField.HelpText, "UrlBase", "url", "http://[host]:[port]/[urlBase]/api")] public string UrlBase { get; set; } - [FieldDefinition(3, Label = "API Key", Type = FieldType.Textbox, Privacy = PrivacyLevel.ApiKey)] + [FieldDefinition(3, Label = "ApiKey", Type = FieldType.Textbox, Privacy = PrivacyLevel.ApiKey)] public string ApiKey { get; set; } - [FieldDefinition(4, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")] + [FieldDefinition(4, Label = "DefaultCategory", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsDefaultCategoryHelpText")] public string Category { get; set; } - [FieldDefinition(5, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(NzbVortexPriority), HelpText = "Priority to use when grabbing items")] + [FieldDefinition(5, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(NzbVortexPriority), HelpText = "DownloadClientSettingsPriorityItemHelpText")] public int Priority { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs index b286bf922..49ada68e6 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs @@ -10,6 +10,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Validation; @@ -18,15 +19,14 @@ namespace NzbDrone.Core.Download.Clients.Nzbget public class Nzbget : UsenetClientBase { private readonly INzbgetProxy _proxy; - private readonly string[] _successStatus = { "SUCCESS", "NONE" }; - private readonly string[] _deleteFailedStatus = { "HEALTH", "DUPE", "SCAN", "COPY", "BAD" }; public Nzbget(INzbgetProxy proxy, IHttpClient httpClient, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(httpClient, configService, diskProvider, logger) + : base(httpClient, configService, diskProvider, localizationService, logger) { _proxy = proxy; } diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs index 0e1cbc912..7f968b512 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs @@ -41,10 +41,13 @@ namespace NzbDrone.Core.Download.Clients.Nzbget [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] public int Port { get; set; } - [FieldDefinition(2, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to NZBGet")] + [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "DownloadClientSettingsUseSslHelpText")] + [FieldToken(TokenField.HelpText, "UseSsl", "clientName", "NZBGet")] public bool UseSsl { get; set; } - [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the NZBGet url, e.g. http://[host]:[port]/[urlBase]/jsonrpc")] + [FieldDefinition(3, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsUrlBaseHelpText")] + [FieldToken(TokenField.HelpText, "UrlBase", "clientName", "NZBGet")] + [FieldToken(TokenField.HelpText, "UrlBase", "url", "http://[host]:[port]/[urlBase]/jsonrpc")] public string UrlBase { get; set; } [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] @@ -53,13 +56,13 @@ namespace NzbDrone.Core.Download.Clients.Nzbget [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] public string Password { get; set; } - [FieldDefinition(6, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")] + [FieldDefinition(6, Label = "DefaultCategory", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsDefaultCategoryHelpText")] public string Category { get; set; } - [FieldDefinition(7, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(NzbgetPriority), HelpText = "Priority for items added from Prowlarr")] + [FieldDefinition(7, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(NzbgetPriority), HelpText = "DownloadClientSettingsPriorityItemHelpText")] public int Priority { get; set; } - [FieldDefinition(8, Label = "Add Paused", Type = FieldType.Checkbox, HelpText = "This option requires at least NZBGet version 16.0")] + [FieldDefinition(8, Label = "DownloadClientSettingsAddPaused", Type = FieldType.Checkbox, HelpText = "DownloadClientNzbgetSettingsAddPausedHelpText")] public bool AddPaused { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs index 1e98734f7..99f80ab22 100644 --- a/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs +++ b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs @@ -8,6 +8,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; @@ -17,8 +18,9 @@ namespace NzbDrone.Core.Download.Clients.Pneumatic { public Pneumatic(IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(configService, diskProvider, logger) + : base(configService, diskProvider, localizationService, logger) { } @@ -39,9 +41,9 @@ namespace NzbDrone.Core.Download.Clients.Pneumatic _logger.Debug("Downloading NZB from: {0} to: {1}", url, nzbFile); - var nzbData = await indexer.Download(url); + var downloadResponse = await indexer.Download(url); - File.WriteAllBytes(nzbFile, nzbData); + await File.WriteAllBytesAsync(nzbFile, downloadResponse.Data); _logger.Debug("NZB Download succeeded, saved to: {0}", nzbFile); diff --git a/src/NzbDrone.Core/Download/Clients/Pneumatic/PneumaticSettings.cs b/src/NzbDrone.Core/Download/Clients/Pneumatic/PneumaticSettings.cs index 741021a3f..6cd8a2b89 100644 --- a/src/NzbDrone.Core/Download/Clients/Pneumatic/PneumaticSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Pneumatic/PneumaticSettings.cs @@ -19,10 +19,10 @@ namespace NzbDrone.Core.Download.Clients.Pneumatic { private static readonly PneumaticSettingsValidator Validator = new PneumaticSettingsValidator(); - [FieldDefinition(0, Label = "Nzb Folder", Type = FieldType.Path, HelpText = "This folder will need to be reachable from XBMC")] + [FieldDefinition(0, Label = "DownloadClientPneumaticSettingsNzbFolder", Type = FieldType.Path, HelpText = "DownloadClientPneumaticSettingsNzbFolderHelpText")] public string NzbFolder { get; set; } - [FieldDefinition(1, Label = "Strm Folder", Type = FieldType.Path, HelpText = ".strm files in this folder will be import by drone")] + [FieldDefinition(1, Label = "DownloadClientPneumaticSettingsStrmFolder", Type = FieldType.Path, HelpText = "DownloadClientPneumaticSettingsStrmFolderHelpText")] public string StrmFolder { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index 6555ce6cc..3d1863784 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -8,6 +8,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Validation; @@ -30,8 +31,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent IConfigService configService, IDiskProvider diskProvider, ICacheManager cacheManager, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxySelector = proxySelector; diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentContentLayout.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentContentLayout.cs new file mode 100644 index 000000000..874fbff7a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentContentLayout.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + public enum QBittorrentContentLayout + { + Default = 0, + Original = 1, + Subfolder = 2 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs index 14bee89fc..33fcfc5ca 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs @@ -25,8 +25,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent Dictionary GetLabels(QBittorrentSettings settings); void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings); void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings); - void PauseTorrent(string hash, QBittorrentSettings settings); - void ResumeTorrent(string hash, QBittorrentSettings settings); void SetForceStart(string hash, bool enabled, QBittorrentSettings settings); } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs index cdf161c7f..7c9cc8768 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs @@ -146,7 +146,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { request.AddFormParameter("paused", false); } - else if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) + else if ((QBittorrentState)settings.InitialState == QBittorrentState.Stop) { request.AddFormParameter("paused", true); } @@ -176,7 +176,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { request.AddFormParameter("paused", false); } - else if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) + else if ((QBittorrentState)settings.InitialState == QBittorrentState.Stop) { request.AddFormParameter("paused", true); } @@ -212,7 +212,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent catch (DownloadClientException ex) { // if setCategory fails due to method not being found, then try older setLabel command for qBittorrent < v.3.3.5 - if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.NotFound) + if (ex.InnerException is HttpException httpException && httpException.Response.StatusCode == HttpStatusCode.NotFound) { var setLabelRequest = BuildRequest(settings).Resource("/command/setLabel") .Post() @@ -254,7 +254,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent catch (DownloadClientException ex) { // qBittorrent rejects all Prio commands with 403: Forbidden if Options -> BitTorrent -> Torrent Queueing is not enabled - if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.Forbidden) + if (ex.InnerException is HttpException httpException && httpException.Response.StatusCode == HttpStatusCode.Forbidden) { return; } @@ -263,22 +263,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent } } - public void PauseTorrent(string hash, QBittorrentSettings settings) - { - var request = BuildRequest(settings).Resource("/command/pause") - .Post() - .AddFormParameter("hash", hash); - ProcessRequest(request, settings); - } - - public void ResumeTorrent(string hash, QBittorrentSettings settings) - { - var request = BuildRequest(settings).Resource("/command/resume") - .Post() - .AddFormParameter("hash", hash); - ProcessRequest(request, settings); - } - public void SetForceStart(string hash, bool enabled, QBittorrentSettings settings) { var request = BuildRequest(settings).Resource("/command/setForceStart") diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs index 5e967d095..cfa7c9934 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs @@ -246,14 +246,20 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent request.AddFormParameter("category", category); } - // Note: ForceStart is handled by separate api call - if ((QBittorrentState)settings.InitialState == QBittorrentState.Start) + // Avoid extraneous API version check if initial state is ForceStart + if ((QBittorrentState)settings.InitialState is QBittorrentState.Start or QBittorrentState.Stop) { - request.AddFormParameter("paused", false); - } - else if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) - { - request.AddFormParameter("paused", true); + var stoppedParameterName = GetApiVersion(settings) >= new Version(2, 11, 0) ? "stopped" : "paused"; + + // Note: ForceStart is handled by separate api call + if ((QBittorrentState)settings.InitialState == QBittorrentState.Start) + { + request.AddFormParameter(stoppedParameterName, false); + } + else if ((QBittorrentState)settings.InitialState == QBittorrentState.Stop) + { + request.AddFormParameter(stoppedParameterName, true); + } } if (settings.SequentialOrder) @@ -265,6 +271,15 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { request.AddFormParameter("firstLastPiecePrio", true); } + + if ((QBittorrentContentLayout)settings.ContentLayout == QBittorrentContentLayout.Original) + { + request.AddFormParameter("contentLayout", "Original"); + } + else if ((QBittorrentContentLayout)settings.ContentLayout == QBittorrentContentLayout.Subfolder) + { + request.AddFormParameter("contentLayout", "Subfolder"); + } } public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) @@ -282,7 +297,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent catch (DownloadClientException ex) { // setShareLimits was added in api v2.0.1 so catch it case of the unlikely event that someone has api v2.0 - if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.NotFound) + if (ex.InnerException is HttpException httpException && httpException.Response.StatusCode == HttpStatusCode.NotFound) { return; } @@ -304,7 +319,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent catch (DownloadClientException ex) { // qBittorrent rejects all Prio commands with 409: Conflict if Options -> BitTorrent -> Torrent Queueing is not enabled - if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.Conflict) + if (ex.InnerException is HttpException httpException && httpException.Response.StatusCode == HttpStatusCode.Conflict) { return; } @@ -313,22 +328,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent } } - public void PauseTorrent(string hash, QBittorrentSettings settings) - { - var request = BuildRequest(settings).Resource("/api/v2/torrents/pause") - .Post() - .AddFormParameter("hashes", hash); - ProcessRequest(request, settings); - } - - public void ResumeTorrent(string hash, QBittorrentSettings settings) - { - var request = BuildRequest(settings).Resource("/api/v2/torrents/resume") - .Post() - .AddFormParameter("hashes", hash); - ProcessRequest(request, settings); - } - public void SetForceStart(string hash, bool enabled, QBittorrentSettings settings) { var request = BuildRequest(settings).Resource("/api/v2/torrents/setForceStart") diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs index 4427472ae..8d157dc30 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs @@ -35,10 +35,12 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] public int Port { get; set; } - [FieldDefinition(2, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use a secure connection. See Options -> Web UI -> 'Use HTTPS instead of HTTP' in qBittorrent.")] + [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "DownloadClientQbittorrentSettingsUseSslHelpText")] public bool UseSsl { get; set; } - [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the qBittorrent url, e.g. http://[host]:[port]/[urlBase]/api")] + [FieldDefinition(3, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsUrlBaseHelpText")] + [FieldToken(TokenField.HelpText, "UrlBase", "clientName", "qBittorrent")] + [FieldToken(TokenField.HelpText, "UrlBase", "url", "http://[host]:[port]/[urlBase]/api")] public string UrlBase { get; set; } [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] @@ -47,21 +49,24 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] public string Password { get; set; } - [FieldDefinition(6, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")] + [FieldDefinition(6, Label = "DefaultCategory", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsDefaultCategoryHelpText")] public string Category { get; set; } - [FieldDefinition(7, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "Priority to use when grabbing items")] + [FieldDefinition(7, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "DownloadClientSettingsPriorityItemHelpText")] public int Priority { get; set; } - [FieldDefinition(8, Label = "Initial State", Type = FieldType.Select, SelectOptions = typeof(QBittorrentState), HelpText = "Initial state for torrents added to qBittorrent")] + [FieldDefinition(8, Label = "DownloadClientSettingsInitialState", Type = FieldType.Select, SelectOptions = typeof(QBittorrentState), HelpText = "DownloadClientQbittorrentSettingsInitialStateHelpText")] public int InitialState { get; set; } - [FieldDefinition(9, Label = "Sequential Order", Type = FieldType.Checkbox, HelpText = "Download in sequential order (qBittorrent 4.1.0+)")] + [FieldDefinition(9, Label = "DownloadClientQbittorrentSettingsSequentialOrder", Type = FieldType.Checkbox, HelpText = "DownloadClientQbittorrentSettingsSequentialOrderHelpText")] public bool SequentialOrder { get; set; } - [FieldDefinition(10, Label = "First and Last First", Type = FieldType.Checkbox, HelpText = "Download first and last pieces first (qBittorrent 4.1.0+)")] + [FieldDefinition(10, Label = "DownloadClientQbittorrentSettingsFirstAndLastFirst", Type = FieldType.Checkbox, HelpText = "DownloadClientQbittorrentSettingsFirstAndLastFirstHelpText")] public bool FirstAndLast { get; set; } + [FieldDefinition(11, Label = "DownloadClientQbittorrentSettingsContentLayout", Type = FieldType.Select, SelectOptions = typeof(QBittorrentContentLayout), HelpText = "DownloadClientQbittorrentSettingsContentLayoutHelpText")] + public int ContentLayout { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentState.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentState.cs index 56c5ddf1a..b8fddbc11 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentState.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentState.cs @@ -1,9 +1,16 @@ +using NzbDrone.Core.Annotations; + namespace NzbDrone.Core.Download.Clients.QBittorrent { public enum QBittorrentState { + [FieldOption(Label = "Started")] Start = 0, + + [FieldOption(Label = "Force Started")] ForceStart = 1, - Pause = 2 + + [FieldOption(Label = "Stopped")] + Stop = 2 } } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs index 584e392b2..246262527 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs @@ -9,6 +9,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Validation; @@ -22,8 +23,9 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd IHttpClient httpClient, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(httpClient, configService, diskProvider, logger) + : base(httpClient, configService, diskProvider, localizationService, logger) { _proxy = proxy; } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs index 61b5f9228..e25a91701 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Newtonsoft.Json; using NzbDrone.Common.Disk; using NzbDrone.Core.Download.Clients.Sabnzbd.JsonConverters; @@ -7,10 +7,14 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd { public class SabnzbdConfig { + public SabnzbdConfig() + { + Categories = new List(); + Servers = new List(); + } + public SabnzbdConfigMisc Misc { get; set; } - public List Categories { get; set; } - public List Servers { get; set; } } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs index 37df94bd5..70dba78b1 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs @@ -50,13 +50,16 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] public int Port { get; set; } - [FieldDefinition(2, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to Sabnzbd")] + [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "DownloadClientSettingsUseSslHelpText")] + [FieldToken(TokenField.HelpText, "UseSsl", "clientName", "Sabnzbd")] public bool UseSsl { get; set; } - [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the Sabnzbd url, e.g. http://[host]:[port]/[urlBase]/api")] + [FieldDefinition(3, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsUrlBaseHelpText")] + [FieldToken(TokenField.HelpText, "UrlBase", "clientName", "Sabnzbd")] + [FieldToken(TokenField.HelpText, "UrlBase", "url", "http://[host]:[port]/[urlBase]/api")] public string UrlBase { get; set; } - [FieldDefinition(4, Label = "API Key", Type = FieldType.Textbox, Privacy = PrivacyLevel.ApiKey)] + [FieldDefinition(4, Label = "ApiKey", Type = FieldType.Textbox, Privacy = PrivacyLevel.ApiKey)] public string ApiKey { get; set; } [FieldDefinition(5, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] @@ -65,10 +68,10 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd [FieldDefinition(6, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] public string Password { get; set; } - [FieldDefinition(7, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")] + [FieldDefinition(7, Label = "DefaultCategory", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsDefaultCategoryHelpText")] public string Category { get; set; } - [FieldDefinition(8, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(SabnzbdPriority), HelpText = "Priority to use when grabbing items")] + [FieldDefinition(8, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(SabnzbdPriority), HelpText = "DownloadClientSettingsPriorityItemHelpText")] public int Priority { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs b/src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs index a2bd2bf91..ad14de894 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs @@ -5,6 +5,7 @@ using NLog; using NzbDrone.Common.Disk; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; namespace NzbDrone.Core.Download.Clients.Transmission { @@ -15,8 +16,9 @@ namespace NzbDrone.Core.Download.Clients.Transmission ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(proxy, torrentFileInfoReader, seedConfigProvider, configService, diskProvider, logger) + : base(proxy, torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { } @@ -38,6 +40,6 @@ namespace NzbDrone.Core.Download.Clients.Transmission } public override string Name => "Transmission"; - public override bool SupportsCategories => false; + public override bool SupportsCategories => true; } } diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs index 6e723a32a..977e3bfee 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs @@ -6,6 +6,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Validation; @@ -20,15 +21,19 @@ namespace NzbDrone.Core.Download.Clients.Transmission ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxy = proxy; } protected override string AddFromMagnetLink(TorrentInfo release, string hash, string magnetLink) { - _proxy.AddTorrentFromUrl(magnetLink, GetDownloadDirectory(), Settings); + var category = GetCategoryForRelease(release) ?? Settings.Category; + var downloadDirectory = GetDownloadDirectory(category); + + _proxy.AddTorrentFromUrl(magnetLink, downloadDirectory, Settings); _proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings); if (Settings.Priority == (int)TransmissionPriority.First) @@ -41,7 +46,10 @@ namespace NzbDrone.Core.Download.Clients.Transmission protected override string AddFromTorrentFile(TorrentInfo release, string hash, string filename, byte[] fileContent) { - _proxy.AddTorrentFromData(fileContent, GetDownloadDirectory(), Settings); + var category = GetCategoryForRelease(release) ?? Settings.Category; + var downloadDirectory = GetDownloadDirectory(category); + + _proxy.AddTorrentFromData(fileContent, downloadDirectory, Settings); _proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings); if (Settings.Priority == (int)TransmissionPriority.First) @@ -73,14 +81,14 @@ namespace NzbDrone.Core.Download.Clients.Transmission return outputPath + torrent.Name.Replace(":", "_"); } - protected string GetDownloadDirectory() + protected string GetDownloadDirectory(string category) { if (Settings.Directory.IsNotNullOrWhiteSpace()) { return Settings.Directory; } - if (!Settings.Category.IsNotNullOrWhiteSpace()) + if (category.IsNullOrWhiteSpace()) { return null; } @@ -88,7 +96,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission var config = _proxy.GetConfig(Settings); var destDir = config.DownloadDir; - return $"{destDir.TrimEnd('/')}/{Settings.Category}"; + return $"{destDir.TrimEnd('/')}/{category}"; } protected ValidationFailure TestConnection() diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs index 2c2334f44..76f8684e0 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs @@ -4,6 +4,7 @@ using System.Net; using Newtonsoft.Json.Linq; using NLog; using NzbDrone.Common.Cache; +using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; @@ -208,7 +209,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission private void AuthenticateClient(HttpRequestBuilder requestBuilder, TransmissionSettings settings, bool reauthenticate = false) { - var authKey = string.Format("{0}:{1}", requestBuilder.BaseUrl, settings.Password); + var authKey = $"{requestBuilder.BaseUrl}:{settings.Password}"; var sessionId = _authSessionIDCache.Find(authKey); @@ -220,24 +221,26 @@ namespace NzbDrone.Core.Download.Clients.Transmission authLoginRequest.SuppressHttpError = true; var response = _httpClient.Execute(authLoginRequest); - if (response.StatusCode == HttpStatusCode.MovedPermanently) - { - var url = response.Headers.GetSingleValue("Location"); - throw new DownloadClientException("Remote site redirected to " + url); - } - else if (response.StatusCode == HttpStatusCode.Conflict) + switch (response.StatusCode) { - sessionId = response.Headers.GetSingleValue("X-Transmission-Session-Id"); + case HttpStatusCode.MovedPermanently: + var url = response.Headers.GetSingleValue("Location"); - if (sessionId == null) - { - throw new DownloadClientException("Remote host did not return a Session Id."); - } - } - else - { - throw new DownloadClientAuthenticationException("Failed to authenticate with Transmission."); + throw new DownloadClientException("Remote site redirected to " + url); + case HttpStatusCode.Forbidden: + throw new DownloadClientException($"Failed to authenticate with Transmission. It may be necessary to add {BuildInfo.AppName}'s IP address to RPC whitelist."); + case HttpStatusCode.Conflict: + sessionId = response.Headers.GetSingleValue("X-Transmission-Session-Id"); + + if (sessionId == null) + { + throw new DownloadClientException("Remote host did not return a Session Id."); + } + + break; + default: + throw new DownloadClientAuthenticationException("Failed to authenticate with Transmission."); } _logger.Debug("Transmission authentication succeeded."); diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs index 4d2682ee6..2a510b5e0 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs @@ -41,10 +41,14 @@ namespace NzbDrone.Core.Download.Clients.Transmission [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] public int Port { get; set; } - [FieldDefinition(2, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to Transmission")] + [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "DownloadClientSettingsUseSslHelpText")] + [FieldToken(TokenField.HelpText, "UseSsl", "clientName", "Transmission")] public bool UseSsl { get; set; } - [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the transmission rpc url, eg http://[host]:[port]/[urlBase]/rpc, defaults to '/transmission/'")] + [FieldDefinition(3, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientTransmissionSettingsUrlBaseHelpText")] + [FieldToken(TokenField.HelpText, "UrlBase", "clientName", "Transmission")] + [FieldToken(TokenField.HelpText, "UrlBase", "url", "http://[host]:[port]/[urlBase]/rpc")] + [FieldToken(TokenField.HelpText, "UrlBase", "defaultUrl", "/transmission/")] public string UrlBase { get; set; } [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] @@ -53,16 +57,16 @@ namespace NzbDrone.Core.Download.Clients.Transmission [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] public string Password { get; set; } - [FieldDefinition(6, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional. Creates a [category] subdirectory in the output directory.")] + [FieldDefinition(6, Label = "DefaultCategory", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsDefaultCategorySubFolderHelpText")] public string Category { get; set; } - [FieldDefinition(7, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "Optional location to put downloads in, leave blank to use the default Transmission location")] + [FieldDefinition(7, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientTransmissionSettingsDirectoryHelpText")] public string Directory { get; set; } - [FieldDefinition(8, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "Priority to use when grabbing items")] + [FieldDefinition(8, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "DownloadClientSettingsPriorityItemHelpText")] public int Priority { get; set; } - [FieldDefinition(9, Label = "Add Paused", Type = FieldType.Checkbox)] + [FieldDefinition(9, Label = "DownloadClientSettingsAddPaused", Type = FieldType.Checkbox)] public bool AddPaused { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs b/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs index 79f5df0e4..3b87962bb 100644 --- a/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs +++ b/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs @@ -4,6 +4,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Core.Configuration; using NzbDrone.Core.Download.Clients.Transmission; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; namespace NzbDrone.Core.Download.Clients.Vuze { @@ -16,8 +17,9 @@ namespace NzbDrone.Core.Download.Clients.Vuze ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(proxy, torrentFileInfoReader, seedConfigProvider, configService, diskProvider, logger) + : base(proxy, torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { } diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs index 5218f9ebe..628ebdf52 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs @@ -10,6 +10,7 @@ using NzbDrone.Core.Configuration; using NzbDrone.Core.Download.Clients.rTorrent; using NzbDrone.Core.Exceptions; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -27,8 +28,9 @@ namespace NzbDrone.Core.Download.Clients.RTorrent IConfigService configService, IDiskProvider diskProvider, IRTorrentDirectoryValidator rTorrentDirectoryValidator, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxy = proxy; _rTorrentDirectoryValidator = rTorrentDirectoryValidator; diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs index e2ab37fe2..8e3cd6827 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs @@ -36,10 +36,13 @@ namespace NzbDrone.Core.Download.Clients.RTorrent [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] public int Port { get; set; } - [FieldDefinition(2, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to ruTorrent")] + [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "DownloadClientSettingsUseSslHelpText")] + [FieldToken(TokenField.HelpText, "UseSsl", "clientName", "rTorrent")] public bool UseSsl { get; set; } - [FieldDefinition(3, Label = "Url Path", Type = FieldType.Textbox, HelpText = "Path to the XMLRPC endpoint, see http(s)://[host]:[port]/[urlPath]. When using ruTorrent this usually is RPC2 or (path to ruTorrent)/plugins/rpc/rpc.php")] + [FieldDefinition(3, Label = "DownloadClientRTorrentSettingsUrlPath", Type = FieldType.Textbox, HelpText = "DownloadClientRTorrentSettingsUrlPathHelpText")] + [FieldToken(TokenField.HelpText, "DownloadClientRTorrentSettingsUrlPath", "url", "http(s)://[host]:[port]/[urlPath]")] + [FieldToken(TokenField.HelpText, "DownloadClientRTorrentSettingsUrlPath", "url2", "/plugins/rpc/rpc.php")] public string UrlBase { get; set; } [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] @@ -48,16 +51,16 @@ namespace NzbDrone.Core.Download.Clients.RTorrent [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] public string Password { get; set; } - [FieldDefinition(6, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional.")] + [FieldDefinition(6, Label = "DefaultCategory", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsDefaultCategoryHelpText")] public string Category { get; set; } - [FieldDefinition(7, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "Optional location to put downloads in, leave blank to use the default rTorrent location")] + [FieldDefinition(7, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientRTorrentSettingsDirectoryHelpText")] public string Directory { get; set; } - [FieldDefinition(8, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(RTorrentPriority), HelpText = "Priority to use when grabbing items")] + [FieldDefinition(8, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(RTorrentPriority), HelpText = "DownloadClientSettingsPriorityItemHelpText")] public int Priority { get; set; } - [FieldDefinition(9, Label = "Add Stopped", Type = FieldType.Checkbox, HelpText = "Enabling will add torrents and magnets to ruTorrent in a stopped state")] + [FieldDefinition(9, Label = "DownloadClientRTorrentSettingsAddStopped", Type = FieldType.Checkbox, HelpText = "DownloadClientRTorrentSettingsAddStoppedHelpText")] public bool AddStopped { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs index ef12042fb..98ad41eec 100644 --- a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs @@ -7,7 +7,9 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; namespace NzbDrone.Core.Download.Clients.UTorrent @@ -21,8 +23,9 @@ namespace NzbDrone.Core.Download.Clients.UTorrent ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxy = proxy; } @@ -72,6 +75,9 @@ namespace NzbDrone.Core.Download.Clients.UTorrent } public override string Name => "uTorrent"; + + public override ProviderMessage Message => new (_localizationService.GetLocalizedString("DownloadClientUTorrentProviderMessage"), ProviderMessageType.Warning); + public override bool SupportsCategories => true; protected override void Test(List failures) diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs index d3b72c04d..573fa2f1d 100644 --- a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs @@ -34,10 +34,13 @@ namespace NzbDrone.Core.Download.Clients.UTorrent [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] public int Port { get; set; } - [FieldDefinition(2, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to uTorrent")] + [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "DownloadClientSettingsUseSslHelpText")] + [FieldToken(TokenField.HelpText, "UseSsl", "clientName", "uTorrent")] public bool UseSsl { get; set; } - [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the uTorrent url, e.g. http://[host]:[port]/[urlBase]/api")] + [FieldDefinition(3, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsUrlBaseHelpText")] + [FieldToken(TokenField.HelpText, "UrlBase", "clientName", "uTorrent")] + [FieldToken(TokenField.HelpText, "UrlBase", "url", "http://[host]:[port]/[urlBase]/api")] public string UrlBase { get; set; } [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] @@ -46,13 +49,14 @@ namespace NzbDrone.Core.Download.Clients.UTorrent [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] public string Password { get; set; } - [FieldDefinition(6, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")] + [FieldDefinition(6, Label = "DefaultCategory", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsDefaultCategoryHelpText")] public string Category { get; set; } - [FieldDefinition(7, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(UTorrentPriority), HelpText = "Priority to use when grabbing items")] + [FieldDefinition(7, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(UTorrentPriority), HelpText = "DownloadClientSettingsPriorityItemHelpText")] public int Priority { get; set; } - [FieldDefinition(8, Label = "Initial State", Type = FieldType.Select, SelectOptions = typeof(UTorrentState), HelpText = "Initial state for torrents added to uTorrent")] + [FieldDefinition(8, Label = "DownloadClientSettingsInitialState", Type = FieldType.Select, SelectOptions = typeof(UTorrentState), HelpText = "DownloadClientSettingsInitialStateHelpText")] + [FieldToken(TokenField.HelpText, "DownloadClientSettingsInitialState", "clientName", "uTorrent")] public int IntialState { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/DownloadClientBase.cs b/src/NzbDrone.Core/Download/DownloadClientBase.cs index 89fce8f3c..9a7da6fd6 100644 --- a/src/NzbDrone.Core/Download/DownloadClientBase.cs +++ b/src/NzbDrone.Core/Download/DownloadClientBase.cs @@ -8,6 +8,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -19,6 +20,7 @@ namespace NzbDrone.Core.Download { protected readonly IConfigService _configService; protected readonly IDiskProvider _diskProvider; + protected readonly ILocalizationService _localizationService; protected readonly Logger _logger; public abstract string Name { get; } @@ -27,20 +29,7 @@ namespace NzbDrone.Core.Download public virtual ProviderMessage Message => null; - public IEnumerable DefaultDefinitions - { - get - { - var config = (IProviderConfig)new TSettings(); - - yield return new DownloadClientDefinition - { - Name = GetType().Name, - Implementation = GetType().Name, - Settings = config - }; - } - } + public IEnumerable DefaultDefinitions => new List(); public ProviderDefinition Definition { get; set; } @@ -53,10 +42,12 @@ namespace NzbDrone.Core.Download protected DownloadClientBase(IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) { _configService = configService; _diskProvider = diskProvider; + _localizationService = localizationService; _logger = logger; } diff --git a/src/NzbDrone.Core/Download/DownloadClientFactory.cs b/src/NzbDrone.Core/Download/DownloadClientFactory.cs index d692d36ee..19aedf751 100644 --- a/src/NzbDrone.Core/Download/DownloadClientFactory.cs +++ b/src/NzbDrone.Core/Download/DownloadClientFactory.cs @@ -57,11 +57,11 @@ namespace NzbDrone.Core.Download private IEnumerable FilterBlockedClients(IEnumerable clients) { - var blockedIndexers = _downloadClientStatusService.GetBlockedProviders().ToDictionary(v => v.ProviderId, v => v); + var blockedClients = _downloadClientStatusService.GetBlockedProviders().ToDictionary(v => v.ProviderId, v => v); foreach (var client in clients) { - if (blockedIndexers.TryGetValue(client.Definition.Id, out var downloadClientStatus)) + if (blockedClients.TryGetValue(client.Definition.Id, out var downloadClientStatus) && downloadClientStatus.DisabledTill.HasValue) { _logger.Debug("Temporarily ignoring download client {0} till {1} due to recent failures.", client.Definition.Name, downloadClientStatus.DisabledTill.Value.ToLocalTime()); continue; @@ -75,10 +75,19 @@ namespace NzbDrone.Core.Download { var result = base.Test(definition); - if ((result == null || result.IsValid) && definition.Id != 0) + if (definition.Id == 0) + { + return result; + } + + if (result == null || result.IsValid) { _downloadClientStatusService.RecordSuccess(definition.Id); } + else + { + _downloadClientStatusService.RecordFailure(definition.Id); + } return result; } diff --git a/src/NzbDrone.Core/Download/DownloadService.cs b/src/NzbDrone.Core/Download/DownloadService.cs index d5e6368f1..223c24384 100644 --- a/src/NzbDrone.Core/Download/DownloadService.cs +++ b/src/NzbDrone.Core/Download/DownloadService.cs @@ -1,10 +1,8 @@ using System; using System.Threading.Tasks; using NLog; -using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Common.Instrumentation.Extensions; -using NzbDrone.Common.TPL; using NzbDrone.Core.Download.Clients; using NzbDrone.Core.Exceptions; using NzbDrone.Core.Indexers; @@ -27,7 +25,6 @@ namespace NzbDrone.Core.Download private readonly IDownloadClientStatusService _downloadClientStatusService; private readonly IIndexerFactory _indexerFactory; private readonly IIndexerStatusService _indexerStatusService; - private readonly IRateLimitService _rateLimitService; private readonly IEventAggregator _eventAggregator; private readonly Logger _logger; @@ -35,7 +32,6 @@ namespace NzbDrone.Core.Download IDownloadClientStatusService downloadClientStatusService, IIndexerFactory indexerFactory, IIndexerStatusService indexerStatusService, - IRateLimitService rateLimitService, IEventAggregator eventAggregator, Logger logger) { @@ -43,7 +39,6 @@ namespace NzbDrone.Core.Download _downloadClientStatusService = downloadClientStatusService; _indexerFactory = indexerFactory; _indexerStatusService = indexerStatusService; - _rateLimitService = rateLimitService; _eventAggregator = eventAggregator; _logger = logger; } @@ -74,6 +69,7 @@ namespace NzbDrone.Core.Download DownloadClientId = downloadClient.Definition.Id, DownloadClientName = downloadClient.Definition.Name, Redirect = redirect, + Indexer = indexer, GrabTrigger = source == "Prowlarr" ? GrabTrigger.Manual : GrabTrigger.Api }; @@ -131,15 +127,7 @@ namespace NzbDrone.Core.Download _logger.Trace("Attempting download of {0}", link); var url = new Uri(link); - // Limit grabs to 2 per second. - if (link.IsNotNullOrWhiteSpace() && !link.StartsWith("magnet:")) - { - await _rateLimitService.WaitAndPulseAsync(url.Host, TimeSpan.FromSeconds(2)); - } - var indexer = _indexerFactory.GetInstance(_indexerFactory.Get(indexerId)); - var success = false; - var downloadedBytes = Array.Empty(); var release = new ReleaseInfo { @@ -150,16 +138,21 @@ namespace NzbDrone.Core.Download DownloadProtocol = indexer.Protocol }; - var grabEvent = new IndexerDownloadEvent(release, success, source, host, release.Title, release.DownloadUrl) + var grabEvent = new IndexerDownloadEvent(release, false, source, host, release.Title, release.DownloadUrl) { + Indexer = indexer, GrabTrigger = source == "Prowlarr" ? GrabTrigger.Manual : GrabTrigger.Api }; + byte[] downloadedBytes; + try { - downloadedBytes = await indexer.Download(url); + var downloadResponse = await indexer.Download(url); + downloadedBytes = downloadResponse.Data; _indexerStatusService.RecordSuccess(indexerId); grabEvent.Successful = true; + grabEvent.ElapsedTime = downloadResponse.ElapsedTime; } catch (ReleaseUnavailableException) { @@ -204,6 +197,7 @@ namespace NzbDrone.Core.Download var grabEvent = new IndexerDownloadEvent(release, true, source, host, release.Title, release.DownloadUrl) { Redirect = true, + Indexer = indexer, GrabTrigger = source == "Prowlarr" ? GrabTrigger.Manual : GrabTrigger.Api }; diff --git a/src/NzbDrone.Core/Download/TorrentClientBase.cs b/src/NzbDrone.Core/Download/TorrentClientBase.cs index d9d7f7133..217e70a31 100644 --- a/src/NzbDrone.Core/Download/TorrentClientBase.cs +++ b/src/NzbDrone.Core/Download/TorrentClientBase.cs @@ -8,6 +8,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Exceptions; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.ThingiProvider; @@ -24,8 +25,9 @@ namespace NzbDrone.Core.Download ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(configService, diskProvider, logger) + : base(configService, diskProvider, localizationService, logger) { _torrentFileInfoReader = torrentFileInfoReader; _seedConfigProvider = seedConfigProvider; @@ -127,9 +129,8 @@ namespace NzbDrone.Core.Download private async Task DownloadFromWebUrl(TorrentInfo release, IIndexer indexer, string torrentUrl) { - byte[] torrentFile = null; - - torrentFile = await indexer.Download(new Uri(torrentUrl)); + var downloadResponse = await indexer.Download(new Uri(torrentUrl)); + var torrentFile = downloadResponse.Data; // handle magnet URLs if (torrentFile.Length >= 7 @@ -171,9 +172,7 @@ namespace NzbDrone.Core.Download } catch (FormatException ex) { - _logger.Error(ex, "Failed to parse magnetlink for release '{0}': '{1}'", release.Title, magnetUrl); - - return null; + throw new ReleaseDownloadException("Failed to parse magnetlink for release '{0}': '{1}'", ex, release.Title, magnetUrl); } if (hash != null) diff --git a/src/NzbDrone.Core/Download/UsenetClientBase.cs b/src/NzbDrone.Core/Download/UsenetClientBase.cs index 2541c6059..1e85ffbb9 100644 --- a/src/NzbDrone.Core/Download/UsenetClientBase.cs +++ b/src/NzbDrone.Core/Download/UsenetClientBase.cs @@ -5,6 +5,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.ThingiProvider; @@ -19,8 +20,9 @@ namespace NzbDrone.Core.Download protected UsenetClientBase(IHttpClient httpClient, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(configService, diskProvider, logger) + : base(configService, diskProvider, localizationService, logger) { _httpClient = httpClient; } @@ -41,12 +43,10 @@ namespace NzbDrone.Core.Download var filename = StringUtil.CleanFileName(release.Title) + ".nzb"; - byte[] nzbData; - - nzbData = await indexer.Download(url); + var downloadResponse = await indexer.Download(url); _logger.Info("Adding report [{0}] to the queue.", release.Title); - return AddFromNzbFile(release, filename, nzbData); + return AddFromNzbFile(release, filename, downloadResponse.Data); } } } diff --git a/src/NzbDrone.Core/Fluent.cs b/src/NzbDrone.Core/Fluent.cs index 37a775e96..483f52b6b 100644 --- a/src/NzbDrone.Core/Fluent.cs +++ b/src/NzbDrone.Core/Fluent.cs @@ -20,26 +20,6 @@ namespace NzbDrone.Core return actual; } - public static long Megabytes(this int megabytes) - { - return Convert.ToInt64(megabytes * 1024L * 1024L); - } - - public static long Gigabytes(this int gigabytes) - { - return Convert.ToInt64(gigabytes * 1024L * 1024L * 1024L); - } - - public static long Megabytes(this double megabytes) - { - return Convert.ToInt64(megabytes * 1024L * 1024L); - } - - public static long Gigabytes(this double gigabytes) - { - return Convert.ToInt64(gigabytes * 1024L * 1024L * 1024L); - } - public static long Round(this long number, long level) { return Convert.ToInt64(Math.Floor((decimal)number / level) * level); diff --git a/src/NzbDrone.Core/HealthCheck/Checks/ApiKeyValidationCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/ApiKeyValidationCheck.cs index e65fe0972..d3198b111 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/ApiKeyValidationCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/ApiKeyValidationCheck.cs @@ -1,4 +1,5 @@ -using NLog; +using System.Collections.Generic; +using NLog; using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.Lifecycle; @@ -28,7 +29,7 @@ namespace NzbDrone.Core.HealthCheck.Checks { _logger.Warn("Please update your API key to be at least {0} characters long. You can do this via settings or the config file", MinimumLength); - return new HealthCheck(GetType(), HealthCheckResult.Warning, string.Format(_localizationService.GetLocalizedString("ApiKeyValidationHealthCheckMessage"), MinimumLength), "#invalid-api-key"); + return new HealthCheck(GetType(), HealthCheckResult.Warning, _localizationService.GetLocalizedString("ApiKeyValidationHealthCheckMessage", new Dictionary { { "length", MinimumLength } }), "#invalid-api-key"); } return new HealthCheck(GetType()); diff --git a/src/NzbDrone.Core/HealthCheck/Checks/ApplicationLongTermStatusCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/ApplicationLongTermStatusCheck.cs index 48fafadae..59dc79c5b 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/ApplicationLongTermStatusCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/ApplicationLongTermStatusCheck.cs @@ -9,6 +9,8 @@ namespace NzbDrone.Core.HealthCheck.Checks { [CheckOn(typeof(ProviderUpdatedEvent))] [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderBulkUpdatedEvent))] + [CheckOn(typeof(ProviderBulkDeletedEvent))] [CheckOn(typeof(ProviderStatusChangedEvent))] public class ApplicationLongTermStatusCheck : HealthCheckBase { diff --git a/src/NzbDrone.Core/HealthCheck/Checks/ApplicationStatusCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/ApplicationStatusCheck.cs index 2f7c76dfe..5d9c87184 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/ApplicationStatusCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/ApplicationStatusCheck.cs @@ -9,6 +9,8 @@ namespace NzbDrone.Core.HealthCheck.Checks { [CheckOn(typeof(ProviderUpdatedEvent))] [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderBulkUpdatedEvent))] + [CheckOn(typeof(ProviderBulkDeletedEvent))] [CheckOn(typeof(ProviderStatusChangedEvent))] public class ApplicationStatusCheck : HealthCheckBase { @@ -26,13 +28,12 @@ namespace NzbDrone.Core.HealthCheck.Checks { var enabledProviders = _providerFactory.GetAvailableProviders(); var backOffProviders = enabledProviders.Join(_providerStatusService.GetBlockedProviders(), - i => i.Definition.Id, - s => s.ProviderId, - (i, s) => new { Provider = i, Status = s }) - .Where(p => p.Status.InitialFailure.HasValue && - p.Status.InitialFailure.Value.After( - DateTime.UtcNow.AddHours(-6))) - .ToList(); + i => i.Definition.Id, + s => s.ProviderId, + (i, s) => new { Provider = i, Status = s }) + .Where(p => p.Status.InitialFailure.HasValue && + p.Status.InitialFailure.Value.After(DateTime.UtcNow.AddHours(-6))) + .ToList(); if (backOffProviders.Empty()) { diff --git a/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientStatusCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientStatusCheck.cs index 9582ddcab..6be6f572d 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientStatusCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientStatusCheck.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using NzbDrone.Common.Extensions; using NzbDrone.Core.Download; @@ -8,6 +9,8 @@ namespace NzbDrone.Core.HealthCheck.Checks { [CheckOn(typeof(ProviderUpdatedEvent))] [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderBulkUpdatedEvent))] + [CheckOn(typeof(ProviderBulkDeletedEvent))] [CheckOn(typeof(ProviderStatusChangedEvent))] public class DownloadClientStatusCheck : HealthCheckBase { @@ -37,10 +40,19 @@ namespace NzbDrone.Core.HealthCheck.Checks if (backOffProviders.Count == enabledProviders.Count) { - return new HealthCheck(GetType(), HealthCheckResult.Error, _localizationService.GetLocalizedString("DownloadClientStatusCheckAllClientMessage"), "#download-clients-are-unavailable-due-to-failures"); + return new HealthCheck(GetType(), + HealthCheckResult.Error, + _localizationService.GetLocalizedString("DownloadClientStatusAllClientHealthCheckMessage"), + "#download-clients-are-unavailable-due-to-failures"); } - return new HealthCheck(GetType(), HealthCheckResult.Warning, string.Format(_localizationService.GetLocalizedString("DownloadClientStatusCheckSingleClientMessage"), string.Join(", ", backOffProviders.Select(v => v.Provider.Definition.Name))), "#download-clients-are-unavailable-due-to-failures"); + return new HealthCheck(GetType(), + HealthCheckResult.Warning, + _localizationService.GetLocalizedString("DownloadClientStatusSingleClientHealthCheckMessage", new Dictionary + { + { "downloadClientNames", string.Join(", ", backOffProviders.Select(v => v.Provider.Definition.Name)) } + }), + "#download-clients-are-unavailable-due-to-failures"); } } } diff --git a/src/NzbDrone.Core/HealthCheck/Checks/IndexerDownloadClientCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/IndexerDownloadClientCheck.cs new file mode 100644 index 000000000..592304606 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/IndexerDownloadClientCheck.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Download; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; +using NzbDrone.Core.ThingiProvider.Events; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderBulkUpdatedEvent))] + [CheckOn(typeof(ProviderBulkDeletedEvent))] + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderBulkUpdatedEvent))] + [CheckOn(typeof(ProviderBulkDeletedEvent))] + public class IndexerDownloadClientCheck : HealthCheckBase + { + private readonly IIndexerFactory _indexerFactory; + private readonly IDownloadClientFactory _downloadClientFactory; + + public IndexerDownloadClientCheck(IIndexerFactory indexerFactory, + IDownloadClientFactory downloadClientFactory, + ILocalizationService localizationService) + : base(localizationService) + { + _indexerFactory = indexerFactory; + _downloadClientFactory = downloadClientFactory; + } + + public override HealthCheck Check() + { + var downloadClientsIds = _downloadClientFactory.All().Where(v => v.Enable).Select(v => v.Id).ToList(); + var invalidIndexers = _indexerFactory.All() + .Where(v => v.Enable && v.DownloadClientId > 0 && !downloadClientsIds.Contains(v.DownloadClientId)) + .ToList(); + + if (invalidIndexers.Any()) + { + return new HealthCheck(GetType(), + HealthCheckResult.Warning, + _localizationService.GetLocalizedString("IndexerDownloadClientHealthCheckMessage", new Dictionary + { + { "indexerNames", string.Join(", ", invalidIndexers.Select(v => v.Name).ToArray()) } + }), + "#invalid-indexer-download-client-setting"); + } + + return new HealthCheck(GetType()); + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/IndexerLongTermStatusCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/IndexerLongTermStatusCheck.cs index 758259a13..f65f44962 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/IndexerLongTermStatusCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/IndexerLongTermStatusCheck.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using NzbDrone.Common.Extensions; using NzbDrone.Core.Indexers; @@ -17,9 +18,7 @@ namespace NzbDrone.Core.HealthCheck.Checks private readonly IIndexerFactory _providerFactory; private readonly IIndexerStatusService _providerStatusService; - public IndexerLongTermStatusCheck(IIndexerFactory providerFactory, - IIndexerStatusService providerStatusService, - ILocalizationService localizationService) + public IndexerLongTermStatusCheck(IIndexerFactory providerFactory, IIndexerStatusService providerStatusService, ILocalizationService localizationService) : base(localizationService) { _providerFactory = providerFactory; @@ -46,14 +45,16 @@ namespace NzbDrone.Core.HealthCheck.Checks { return new HealthCheck(GetType(), HealthCheckResult.Error, - _localizationService.GetLocalizedString("IndexerLongTermStatusCheckAllClientMessage"), + _localizationService.GetLocalizedString("IndexerLongTermStatusAllUnavailableHealthCheckMessage"), "#indexers-are-unavailable-due-to-failures"); } return new HealthCheck(GetType(), HealthCheckResult.Warning, - string.Format(_localizationService.GetLocalizedString("IndexerLongTermStatusCheckSingleClientMessage"), - string.Join(", ", backOffProviders.Select(v => v.Provider.Definition.Name))), + _localizationService.GetLocalizedString("IndexerLongTermStatusUnavailableHealthCheckMessage", new Dictionary + { + { "indexerNames", string.Join(", ", backOffProviders.Select(v => v.Provider.Definition.Name)) } + }), "#indexers-are-unavailable-due-to-failures"); } } diff --git a/src/NzbDrone.Core/HealthCheck/Checks/NoDefinitionCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/IndexerNoDefinitionCheck.cs similarity index 53% rename from src/NzbDrone.Core/HealthCheck/Checks/NoDefinitionCheck.cs rename to src/NzbDrone.Core/HealthCheck/Checks/IndexerNoDefinitionCheck.cs index 73d217c25..d6f0ad90c 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/NoDefinitionCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/IndexerNoDefinitionCheck.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers.Definitions.Cardigann; @@ -9,12 +10,12 @@ namespace NzbDrone.Core.HealthCheck.Checks { [CheckOn(typeof(ProviderDeletedEvent))] [CheckOn(typeof(ProviderBulkDeletedEvent))] - public class NoDefinitionCheck : HealthCheckBase + public class IndexerNoDefinitionCheck : HealthCheckBase { private readonly IIndexerDefinitionUpdateService _indexerDefinitionUpdateService; private readonly IIndexerFactory _indexerFactory; - public NoDefinitionCheck(IIndexerDefinitionUpdateService indexerDefinitionUpdateService, IIndexerFactory indexerFactory, ILocalizationService localizationService) + public IndexerNoDefinitionCheck(IIndexerDefinitionUpdateService indexerDefinitionUpdateService, IIndexerFactory indexerFactory, ILocalizationService localizationService) : base(localizationService) { _indexerDefinitionUpdateService = indexerDefinitionUpdateService; @@ -23,23 +24,22 @@ namespace NzbDrone.Core.HealthCheck.Checks public override HealthCheck Check() { - var currentDefs = _indexerDefinitionUpdateService.All(); + var currentDefinitions = _indexerDefinitionUpdateService.All(); + var noDefinitionIndexers = _indexerFactory.AllProviders(false) + .Where(i => i.Definition.Implementation == "Cardigann" && currentDefinitions.All(d => d.File != ((CardigannSettings)i.Definition.Settings).DefinitionFile)) + .ToList(); - var noDefIndexers = _indexerFactory.AllProviders(false) - .Where(i => i.Definition.Implementation == "Cardigann" && currentDefs.All(d => d.File != ((CardigannSettings)i.Definition.Settings).DefinitionFile)).ToList(); - - if (noDefIndexers.Count == 0) + if (noDefinitionIndexers.Count == 0) { return new HealthCheck(GetType()); } - var healthType = HealthCheckResult.Error; - var healthMessage = string.Format(_localizationService.GetLocalizedString("IndexerNoDefCheckMessage"), - string.Join(", ", noDefIndexers.Select(v => v.Definition.Name))); - return new HealthCheck(GetType(), - healthType, - healthMessage, + HealthCheckResult.Error, + _localizationService.GetLocalizedString("IndexerNoDefinitionCheckHealthCheckMessage", new Dictionary + { + { "indexerNames", string.Join(", ", noDefinitionIndexers.Select(v => v.Definition.Name).ToArray()) } + }), "#indexers-have-no-definition"); } diff --git a/src/NzbDrone.Core/HealthCheck/Checks/IndexerProxyCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/IndexerProxyStatusCheck.cs similarity index 69% rename from src/NzbDrone.Core/HealthCheck/Checks/IndexerProxyCheck.cs rename to src/NzbDrone.Core/HealthCheck/Checks/IndexerProxyStatusCheck.cs index 484f2bc19..39aeac49e 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/IndexerProxyCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/IndexerProxyStatusCheck.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using NzbDrone.Common.Extensions; using NzbDrone.Core.IndexerProxies; @@ -9,11 +10,11 @@ namespace NzbDrone.Core.HealthCheck.Checks [CheckOn(typeof(ProviderDeletedEvent))] [CheckOn(typeof(ProviderAddedEvent))] [CheckOn(typeof(ProviderUpdatedEvent))] - public class IndexerProxyCheck : HealthCheckBase + public class IndexerProxyStatusCheck : HealthCheckBase { private readonly IIndexerProxyFactory _proxyFactory; - public IndexerProxyCheck(IIndexerProxyFactory proxyFactory, + public IndexerProxyStatusCheck(IIndexerProxyFactory proxyFactory, ILocalizationService localizationService) : base(localizationService) { @@ -37,15 +38,17 @@ namespace NzbDrone.Core.HealthCheck.Checks { return new HealthCheck(GetType(), HealthCheckResult.Error, - _localizationService.GetLocalizedString("IndexerProxyStatusCheckAllClientMessage"), - "#proxies-are-unavailable-due-to-failures"); + _localizationService.GetLocalizedString("IndexerProxyStatusAllUnavailableHealthCheckMessage"), + "#indexer-proxies-are-unavailable-due-to-failures"); } return new HealthCheck(GetType(), HealthCheckResult.Warning, - string.Format(_localizationService.GetLocalizedString("IndexerProxyStatusCheckSingleClientMessage"), - string.Join(", ", badProxies.Select(v => v.Definition.Name))), - "#proxies-are-unavailable-due-to-failures"); + _localizationService.GetLocalizedString("IndexerProxyStatusUnavailableHealthCheckMessage", new Dictionary + { + { "indexerProxyNames", string.Join(", ", badProxies.Select(v => v.Definition.Name)) } + }), + "#indexer-proxies-are-unavailable-due-to-failures"); } } } diff --git a/src/NzbDrone.Core/HealthCheck/Checks/IndexerStatusCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/IndexerStatusCheck.cs index 968cb5e8f..2e8846a75 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/IndexerStatusCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/IndexerStatusCheck.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using NzbDrone.Common.Extensions; using NzbDrone.Core.Indexers; @@ -44,14 +45,16 @@ namespace NzbDrone.Core.HealthCheck.Checks { return new HealthCheck(GetType(), HealthCheckResult.Error, - _localizationService.GetLocalizedString("IndexerStatusCheckAllClientMessage"), + _localizationService.GetLocalizedString("IndexerStatusAllUnavailableHealthCheckMessage"), "#indexers-are-unavailable-due-to-failures"); } return new HealthCheck(GetType(), HealthCheckResult.Warning, - string.Format(_localizationService.GetLocalizedString("IndexerStatusCheckSingleClientMessage"), - string.Join(", ", backOffProviders.Select(v => v.Provider.Definition.Name))), + _localizationService.GetLocalizedString("IndexerStatusUnavailableHealthCheckMessage", new Dictionary + { + { "indexerNames", string.Join(", ", backOffProviders.Select(v => v.Provider.Definition.Name)) } + }), "#indexers-are-unavailable-due-to-failures"); } } diff --git a/src/NzbDrone.Core/HealthCheck/Checks/IndexerVIPCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/IndexerVIPCheck.cs index 4265b5963..2337403b8 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/IndexerVIPCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/IndexerVIPCheck.cs @@ -11,6 +11,7 @@ namespace NzbDrone.Core.HealthCheck.Checks [CheckOn(typeof(ProviderAddedEvent))] [CheckOn(typeof(ProviderUpdatedEvent))] [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderBulkUpdatedEvent))] [CheckOn(typeof(ProviderBulkDeletedEvent))] public class IndexerVIPCheck : HealthCheckBase { @@ -24,7 +25,7 @@ namespace NzbDrone.Core.HealthCheck.Checks public override HealthCheck Check() { - var indexers = _indexerFactory.AllProviders(false); + var indexers = _indexerFactory.Enabled(false); var expiringProviders = new List(); foreach (var provider in indexers) @@ -39,12 +40,8 @@ namespace NzbDrone.Core.HealthCheck.Checks var expiration = (string)vipProp.GetValue(provider.Definition.Settings); - if (expiration.IsNullOrWhiteSpace()) - { - continue; - } - - if (DateTime.Parse(expiration).Between(DateTime.Now, DateTime.Now.AddDays(7))) + if (expiration.IsNotNullOrWhiteSpace() && + DateTime.Parse(expiration).Between(DateTime.Now, DateTime.Now.AddDays(7))) { expiringProviders.Add(provider); } @@ -53,10 +50,12 @@ namespace NzbDrone.Core.HealthCheck.Checks if (!expiringProviders.Empty()) { return new HealthCheck(GetType(), - HealthCheckResult.Warning, - string.Format(_localizationService.GetLocalizedString("IndexerVipCheckExpiringClientMessage"), - string.Join(", ", expiringProviders.Select(v => v.Definition.Name))), - "#indexer-vip-expiring"); + HealthCheckResult.Warning, + _localizationService.GetLocalizedString("IndexerVipExpiringHealthCheckMessage", new Dictionary + { + { "indexerNames", string.Join(", ", expiringProviders.Select(v => v.Definition.Name).ToArray()) } + }), + "#indexer-vip-expiring"); } return new HealthCheck(GetType()); diff --git a/src/NzbDrone.Core/HealthCheck/Checks/IndexerVIPExpiredCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/IndexerVIPExpiredCheck.cs index 8b0dd06e7..0f3dffc1e 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/IndexerVIPExpiredCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/IndexerVIPExpiredCheck.cs @@ -11,6 +11,7 @@ namespace NzbDrone.Core.HealthCheck.Checks [CheckOn(typeof(ProviderAddedEvent))] [CheckOn(typeof(ProviderUpdatedEvent))] [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderBulkUpdatedEvent))] [CheckOn(typeof(ProviderBulkDeletedEvent))] public class IndexerVIPExpiredCheck : HealthCheckBase { @@ -24,7 +25,7 @@ namespace NzbDrone.Core.HealthCheck.Checks public override HealthCheck Check() { - var indexers = _indexerFactory.AllProviders(false); + var indexers = _indexerFactory.Enabled(false); var expiredProviders = new List(); foreach (var provider in indexers) @@ -39,12 +40,8 @@ namespace NzbDrone.Core.HealthCheck.Checks var expiration = (string)vipProp.GetValue(provider.Definition.Settings); - if (expiration.IsNullOrWhiteSpace()) - { - continue; - } - - if (DateTime.Parse(expiration).Before(DateTime.Now)) + if (expiration.IsNotNullOrWhiteSpace() && + DateTime.Parse(expiration).Before(DateTime.Now)) { expiredProviders.Add(provider); } @@ -53,10 +50,12 @@ namespace NzbDrone.Core.HealthCheck.Checks if (!expiredProviders.Empty()) { return new HealthCheck(GetType(), - HealthCheckResult.Error, - string.Format(_localizationService.GetLocalizedString("IndexerVipCheckExpiredClientMessage"), - string.Join(", ", expiredProviders.Select(v => v.Definition.Name))), - "#indexer-vip-expired"); + HealthCheckResult.Error, + _localizationService.GetLocalizedString("IndexerVipExpiredHealthCheckMessage", new Dictionary + { + { "indexerNames", string.Join(", ", expiredProviders.Select(v => v.Definition.Name).ToArray()) } + }), + "#indexer-vip-expired"); } return new HealthCheck(GetType()); diff --git a/src/NzbDrone.Core/HealthCheck/Checks/NotificationStatusCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/NotificationStatusCheck.cs new file mode 100644 index 000000000..daf5ee725 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/NotificationStatusCheck.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Localization; +using NzbDrone.Core.Notifications; +using NzbDrone.Core.ThingiProvider.Events; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderStatusChangedEvent))] + public class NotificationStatusCheck : HealthCheckBase + { + private readonly INotificationFactory _providerFactory; + private readonly INotificationStatusService _providerStatusService; + + public NotificationStatusCheck(INotificationFactory providerFactory, INotificationStatusService providerStatusService, ILocalizationService localizationService) + : base(localizationService) + { + _providerFactory = providerFactory; + _providerStatusService = providerStatusService; + } + + public override HealthCheck Check() + { + var enabledProviders = _providerFactory.GetAvailableProviders(); + var backOffProviders = enabledProviders.Join(_providerStatusService.GetBlockedProviders(), + i => i.Definition.Id, + s => s.ProviderId, + (i, s) => new { Provider = i, Status = s }) + .ToList(); + + if (backOffProviders.Empty()) + { + return new HealthCheck(GetType()); + } + + if (backOffProviders.Count == enabledProviders.Count) + { + return new HealthCheck(GetType(), + HealthCheckResult.Error, + _localizationService.GetLocalizedString("NotificationStatusAllClientHealthCheckMessage"), + "#notifications-are-unavailable-due-to-failures"); + } + + return new HealthCheck(GetType(), + HealthCheckResult.Warning, + _localizationService.GetLocalizedString("NotificationStatusSingleClientHealthCheckMessage", new Dictionary + { + { "notificationNames", string.Join(", ", backOffProviders.Select(v => v.Provider.Definition.Name)) } + }), + "#notifications-are-unavailable-due-to-failures"); + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/ProxyCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/ProxyCheck.cs index f8f7df340..ba1e40657 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/ProxyCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/ProxyCheck.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net; using NLog; @@ -19,7 +20,7 @@ namespace NzbDrone.Core.HealthCheck.Checks private readonly IHttpRequestBuilderFactory _cloudRequestBuilder; - public ProxyCheck(IProwlarrCloudRequestBuilder cloudRequestBuilder, IConfigService configService, IHttpClient client, ILocalizationService localizationService, Logger logger) + public ProxyCheck(IProwlarrCloudRequestBuilder cloudRequestBuilder, IConfigService configService, IHttpClient client, Logger logger, ILocalizationService localizationService) : base(localizationService) { _configService = configService; @@ -31,35 +32,58 @@ namespace NzbDrone.Core.HealthCheck.Checks public override HealthCheck Check() { - if (_configService.ProxyEnabled) + if (!_configService.ProxyEnabled) { - var addresses = Dns.GetHostAddresses(_configService.ProxyHostname); - if (!addresses.Any()) - { - return new HealthCheck(GetType(), HealthCheckResult.Error, string.Format(_localizationService.GetLocalizedString("ProxyCheckResolveIpMessage"), _configService.ProxyHostname)); - } + return new HealthCheck(GetType()); + } - var request = _cloudRequestBuilder.Create() - .Resource("/ping") - .Build(); + var addresses = Dns.GetHostAddresses(_configService.ProxyHostname); - try - { - var response = _client.Execute(request); - - // We only care about 400 responses, other error codes can be ignored - if (response.StatusCode == HttpStatusCode.BadRequest) + if (!addresses.Any()) + { + return new HealthCheck(GetType(), + HealthCheckResult.Error, + _localizationService.GetLocalizedString("ProxyResolveIpHealthCheckMessage", new Dictionary { - _logger.Error("Proxy Health Check failed: {0}", response.StatusCode); - return new HealthCheck(GetType(), HealthCheckResult.Error, string.Format(_localizationService.GetLocalizedString("ProxyCheckBadRequestMessage"), response.StatusCode)); - } - } - catch (Exception ex) + { "proxyHostName", _configService.ProxyHostname } + }), + "#proxy-failed-resolve-ip"); + } + + var request = _cloudRequestBuilder.Create() + .Resource("/ping") + .Build(); + + try + { + var response = _client.Execute(request); + + // We only care about 400 responses, other error codes can be ignored + if (response.StatusCode == HttpStatusCode.BadRequest) { - _logger.Error(ex, "Proxy Health Check failed"); - return new HealthCheck(GetType(), HealthCheckResult.Error, string.Format(_localizationService.GetLocalizedString("ProxyCheckFailedToTestMessage"), request.Url)); + _logger.Error("Proxy Health Check failed: {0}", response.StatusCode); + + return new HealthCheck(GetType(), + HealthCheckResult.Error, + _localizationService.GetLocalizedString("ProxyBadRequestHealthCheckMessage", new Dictionary + { + { "statusCode", response.StatusCode } + }), + "#proxy-failed-test"); } } + catch (Exception ex) + { + _logger.Error(ex, "Proxy Health Check failed"); + + return new HealthCheck(GetType(), + HealthCheckResult.Error, + _localizationService.GetLocalizedString("ProxyFailedToTestHealthCheckMessage", new Dictionary + { + { "url", request.Url } + }), + "#proxy-failed-test"); + } return new HealthCheck(GetType()); } diff --git a/src/NzbDrone.Core/HealthCheck/Checks/SystemTimeCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/SystemTimeCheck.cs index 8ee1f5a30..d2b3acf13 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/SystemTimeCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/SystemTimeCheck.cs @@ -14,7 +14,7 @@ namespace NzbDrone.Core.HealthCheck.Checks private readonly IHttpRequestBuilderFactory _cloudRequestBuilder; private readonly Logger _logger; - public SystemTimeCheck(IHttpClient client, IProwlarrCloudRequestBuilder cloudRequestBuilder, ILocalizationService localizationService, Logger logger) + public SystemTimeCheck(IHttpClient client, IProwlarrCloudRequestBuilder cloudRequestBuilder, Logger logger, ILocalizationService localizationService) : base(localizationService) { _client = client; @@ -29,19 +29,26 @@ namespace NzbDrone.Core.HealthCheck.Checks return new HealthCheck(GetType()); } - var request = _cloudRequestBuilder.Create() - .Resource("/time") - .Build(); - - var response = _client.Execute(request); - var result = Json.Deserialize(response.Content); - var systemTime = DateTime.UtcNow; - - // +/- more than 1 day - if (Math.Abs(result.DateTimeUtc.Subtract(systemTime).TotalDays) >= 1) + try { - _logger.Error("System time mismatch. SystemTime: {0} Expected Time: {1}. Update system time", systemTime, result.DateTimeUtc); - return new HealthCheck(GetType(), HealthCheckResult.Error, _localizationService.GetLocalizedString("SystemTimeCheckMessage")); + var request = _cloudRequestBuilder.Create() + .Resource("/time") + .Build(); + + var response = _client.Execute(request); + var result = Json.Deserialize(response.Content); + var systemTime = DateTime.UtcNow; + + // +/- more than 1 day + if (Math.Abs(result.DateTimeUtc.Subtract(systemTime).TotalDays) >= 1) + { + _logger.Error("System time mismatch. SystemTime: {0} Expected Time: {1}. Update system time", systemTime, result.DateTimeUtc); + return new HealthCheck(GetType(), HealthCheckResult.Error, _localizationService.GetLocalizedString("SystemTimeHealthCheckMessage"), "#system-time-off"); + } + } + catch (Exception e) + { + _logger.Warn(e, "Unable to verify system time"); } return new HealthCheck(GetType()); diff --git a/src/NzbDrone.Core/HealthCheck/Checks/UpdateCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/UpdateCheck.cs index f92e29b80..684d7f60a 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/UpdateCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/UpdateCheck.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; @@ -39,7 +40,7 @@ namespace NzbDrone.Core.HealthCheck.Checks var startupFolder = _appFolderInfo.StartUpFolder; var uiFolder = Path.Combine(startupFolder, "UI"); - if ((OsInfo.IsWindows || _configFileProvider.UpdateAutomatically) && + if (_configFileProvider.UpdateAutomatically && _configFileProvider.UpdateMechanism == UpdateMechanism.BuiltIn && !_osInfo.IsDocker) { @@ -47,7 +48,12 @@ namespace NzbDrone.Core.HealthCheck.Checks { return new HealthCheck(GetType(), HealthCheckResult.Error, - string.Format(_localizationService.GetLocalizedString("UpdateCheckStartupTranslocationMessage"), startupFolder), + _localizationService.GetLocalizedString( + "UpdateStartupTranslocationHealthCheckMessage", + new Dictionary + { + { "startupFolder", startupFolder } + }), "#cannot-install-update-because-startup-folder-is-in-an-app-translocation-folder."); } @@ -55,7 +61,13 @@ namespace NzbDrone.Core.HealthCheck.Checks { return new HealthCheck(GetType(), HealthCheckResult.Error, - string.Format(_localizationService.GetLocalizedString("UpdateCheckStartupNotWritableMessage"), startupFolder, Environment.UserName), + _localizationService.GetLocalizedString( + "UpdateStartupNotWritableHealthCheckMessage", + new Dictionary + { + { "startupFolder", startupFolder }, + { "userName", Environment.UserName } + }), "#cannot-install-update-because-startup-folder-is-not-writable-by-the-user"); } @@ -63,14 +75,31 @@ namespace NzbDrone.Core.HealthCheck.Checks { return new HealthCheck(GetType(), HealthCheckResult.Error, - string.Format(_localizationService.GetLocalizedString("UpdateCheckUINotWritableMessage"), uiFolder, Environment.UserName), + _localizationService.GetLocalizedString( + "UpdateUiNotWritableHealthCheckMessage", + new Dictionary + { + { "uiFolder", uiFolder }, + { "userName", Environment.UserName } + }), "#cannot-install-update-because-ui-folder-is-not-writable-by-the-user"); } } - if (BuildInfo.BuildDateTime < DateTime.UtcNow.AddDays(-14) && _checkUpdateService.AvailableUpdate() != null) + if (BuildInfo.BuildDateTime < DateTime.UtcNow.AddDays(-14)) { - return new HealthCheck(GetType(), HealthCheckResult.Warning, _localizationService.GetLocalizedString("UpdateAvailable"), "#new-update-is-available"); + var latestAvailable = _checkUpdateService.AvailableUpdate(); + + if (latestAvailable != null) + { + return new HealthCheck(GetType(), + BuildInfo.BuildDateTime.Before(DateTime.UtcNow.AddDays(-180)) ? HealthCheckResult.Error : HealthCheckResult.Warning, + _localizationService.GetLocalizedString("UpdateAvailableHealthCheckMessage", new Dictionary + { + { "version", $"v{latestAvailable.Version}" } + }), + "#new-update-is-available"); + } } return new HealthCheck(GetType()); diff --git a/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs b/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs index 78e60e314..a9a89dc48 100644 --- a/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs +++ b/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs @@ -6,6 +6,7 @@ using NzbDrone.Common.Cache; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Messaging; using NzbDrone.Common.Reflection; +using NzbDrone.Common.TPL; using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; @@ -27,35 +28,35 @@ namespace NzbDrone.Core.HealthCheck private readonly IProvideHealthCheck[] _startupHealthChecks; private readonly IProvideHealthCheck[] _scheduledHealthChecks; private readonly Dictionary _eventDrivenHealthChecks; - private readonly IServerSideNotificationService _serverSideNotificationService; private readonly IEventAggregator _eventAggregator; - private readonly ICacheManager _cacheManager; private readonly Logger _logger; private readonly ICached _healthCheckResults; + private readonly HashSet _pendingHealthChecks; + private readonly Debouncer _debounce; private bool _hasRunHealthChecksAfterGracePeriod; private bool _isRunningHealthChecksAfterGracePeriod; public HealthCheckService(IEnumerable healthChecks, - IServerSideNotificationService serverSideNotificationService, IEventAggregator eventAggregator, ICacheManager cacheManager, + IDebounceManager debounceManager, IRuntimeInfo runtimeInfo, Logger logger) { _healthChecks = healthChecks.ToArray(); - _serverSideNotificationService = serverSideNotificationService; _eventAggregator = eventAggregator; - _cacheManager = cacheManager; _logger = logger; - _healthCheckResults = _cacheManager.GetCache(GetType()); + _healthCheckResults = cacheManager.GetCache(GetType()); + _pendingHealthChecks = new HashSet(); + _debounce = debounceManager.CreateDebouncer(ProcessHealthChecks, TimeSpan.FromSeconds(5)); _startupHealthChecks = _healthChecks.Where(v => v.CheckOnStartup).ToArray(); _scheduledHealthChecks = _healthChecks.Where(v => v.CheckOnSchedule).ToArray(); _eventDrivenHealthChecks = GetEventDrivenHealthChecks(); - _startupGracePeriodEndTime = runtimeInfo.StartTime.AddMinutes(15); + _startupGracePeriodEndTime = runtimeInfo.StartTime + TimeSpan.FromMinutes(15); } public List Results() @@ -77,63 +78,93 @@ namespace NzbDrone.Core.HealthCheck .ToDictionary(g => g.Key, g => g.ToArray()); } - private void PerformHealthCheck(IProvideHealthCheck[] healthChecks, bool performServerChecks = false) + private void ProcessHealthChecks() { - var results = healthChecks.Select(c => c.Check()) - .ToList(); + List healthChecks; - if (performServerChecks) + lock (_pendingHealthChecks) { - results.AddRange(_serverSideNotificationService.GetServerChecks()); + healthChecks = _pendingHealthChecks.ToList(); + _pendingHealthChecks.Clear(); } - foreach (var result in results) + _debounce.Pause(); + + try { - if (result.Type == HealthCheckResult.Ok) - { - var previous = _healthCheckResults.Find(result.Source.Name); - - if (previous != null) + var results = healthChecks.Select(c => { - _eventAggregator.PublishEvent(new HealthCheckRestoredEvent(previous, !_hasRunHealthChecksAfterGracePeriod)); - } + _logger.Trace("Check health -> {0}", c.GetType().Name); + var result = c.Check(); + _logger.Trace("Check health <- {0}", c.GetType().Name); - _healthCheckResults.Remove(result.Source.Name); - } - else + return result; + }) + .ToList(); + + foreach (var result in results) { - if (_healthCheckResults.Find(result.Source.Name) == null) + if (result.Type == HealthCheckResult.Ok) { - _eventAggregator.PublishEvent(new HealthCheckFailedEvent(result, !_hasRunHealthChecksAfterGracePeriod)); - } + var previous = _healthCheckResults.Find(result.Source.Name); - _healthCheckResults.Set(result.Source.Name, result); + if (previous != null) + { + _eventAggregator.PublishEvent(new HealthCheckRestoredEvent(previous, !_hasRunHealthChecksAfterGracePeriod)); + } + + _healthCheckResults.Remove(result.Source.Name); + } + else + { + if (_healthCheckResults.Find(result.Source.Name) == null) + { + _eventAggregator.PublishEvent(new HealthCheckFailedEvent(result, !_hasRunHealthChecksAfterGracePeriod)); + } + + _healthCheckResults.Set(result.Source.Name, result); + } } } + finally + { + _debounce.Resume(); + } _eventAggregator.PublishEvent(new HealthCheckCompleteEvent()); } public void Execute(CheckHealthCommand message) { - if (message.Trigger == CommandTrigger.Manual) + var healthChecks = message.Trigger == CommandTrigger.Manual ? _healthChecks : _scheduledHealthChecks; + + lock (_pendingHealthChecks) { - PerformHealthCheck(_healthChecks, true); - } - else - { - PerformHealthCheck(_scheduledHealthChecks, true); + foreach (var healthCheck in healthChecks) + { + _pendingHealthChecks.Add(healthCheck); + } } + + ProcessHealthChecks(); } public void HandleAsync(ApplicationStartedEvent message) { - PerformHealthCheck(_startupHealthChecks, true); + lock (_pendingHealthChecks) + { + foreach (var healthCheck in _startupHealthChecks) + { + _pendingHealthChecks.Add(healthCheck); + } + } + + ProcessHealthChecks(); } public void HandleAsync(IEvent message) { - if (message is HealthCheckCompleteEvent) + if (message is HealthCheckCompleteEvent || message is ApplicationStartedEvent) { return; } @@ -144,7 +175,16 @@ namespace NzbDrone.Core.HealthCheck { _isRunningHealthChecksAfterGracePeriod = true; - PerformHealthCheck(_startupHealthChecks); + lock (_pendingHealthChecks) + { + foreach (var healthCheck in _startupHealthChecks) + { + _pendingHealthChecks.Add(healthCheck); + } + } + + // Call it directly so it's not debounced and any alerts can be sent. + ProcessHealthChecks(); // Update after running health checks so new failure notifications aren't sent 2x. _hasRunHealthChecksAfterGracePeriod = true; @@ -176,11 +216,16 @@ namespace NzbDrone.Core.HealthCheck if (eventDrivenHealthCheck.ShouldExecute(message, previouslyFailed)) { filteredChecks.Add(eventDrivenHealthCheck.HealthCheck); + continue; } } - // TODO: Add debounce - PerformHealthCheck(filteredChecks.ToArray()); + lock (_pendingHealthChecks) + { + filteredChecks.ForEach(h => _pendingHealthChecks.Add(h)); + } + + _debounce.Execute(); } } } diff --git a/src/NzbDrone.Core/HealthCheck/ServerSideNotificationService.cs b/src/NzbDrone.Core/HealthCheck/ServerSideNotificationService.cs index dd742bdf6..51420e63b 100644 --- a/src/NzbDrone.Core/HealthCheck/ServerSideNotificationService.cs +++ b/src/NzbDrone.Core/HealthCheck/ServerSideNotificationService.cs @@ -9,50 +9,43 @@ using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Localization; namespace NzbDrone.Core.HealthCheck { - public interface IServerSideNotificationService - { - public List GetServerChecks(); - } - - public class ServerSideNotificationService : IServerSideNotificationService + public class ServerSideNotificationService : HealthCheckBase { private readonly IHttpClient _client; + private readonly IProwlarrCloudRequestBuilder _cloudRequestBuilder; private readonly IConfigFileProvider _configFileProvider; - private readonly IHttpRequestBuilderFactory _cloudRequestBuilder; private readonly Logger _logger; - private readonly ICached> _cache; + private readonly ICached _cache; - public ServerSideNotificationService(IHttpClient client, - IConfigFileProvider configFileProvider, - IProwlarrCloudRequestBuilder cloudRequestBuilder, - ICacheManager cacheManager, - Logger logger) + public ServerSideNotificationService(IHttpClient client, IProwlarrCloudRequestBuilder cloudRequestBuilder, IConfigFileProvider configFileProvider, ICacheManager cacheManager, ILocalizationService localizationService, Logger logger) + : base(localizationService) { _client = client; _configFileProvider = configFileProvider; - _cloudRequestBuilder = cloudRequestBuilder.Services; + _cloudRequestBuilder = cloudRequestBuilder; _logger = logger; - _cache = cacheManager.GetCache>(GetType()); + _cache = cacheManager.GetCache(GetType()); } - public List GetServerChecks() + public override HealthCheck Check() { return _cache.Get("ServerChecks", RetrieveServerChecks, TimeSpan.FromHours(2)); } - private List RetrieveServerChecks() + private HealthCheck RetrieveServerChecks() { if (BuildInfo.IsDebug) { - return new List(); + return new HealthCheck(GetType()); } - var request = _cloudRequestBuilder.Create() + var request = _cloudRequestBuilder.Services.Create() .Resource("/notification") .AddQueryParam("version", BuildInfo.Version) .AddQueryParam("os", OsInfo.Os.ToString().ToLowerInvariant()) @@ -63,17 +56,22 @@ namespace NzbDrone.Core.HealthCheck try { - _logger.Trace("Getting server side health notifications"); + _logger.Trace("Getting notifications"); + var response = _client.Execute(request); var result = Json.Deserialize>(response.Content); - return result.Select(x => new HealthCheck(GetType(), x.Type, x.Message, x.WikiUrl)).ToList(); + + var checks = result.Select(x => new HealthCheck(GetType(), x.Type, x.Message, x.WikiUrl)).ToList(); + + // Only one health check is supported, services returns an ordered list, so use the first one + return checks.FirstOrDefault() ?? new HealthCheck(GetType()); } catch (Exception ex) { - _logger.Error(ex, "Failed to retrieve server notifications"); - } + _logger.Error(ex, "Failed to retrieve notifications"); - return new List(); + return new HealthCheck(GetType()); + } } } diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs index 8dcc1861d..e78e5d229 100644 --- a/src/NzbDrone.Core/History/HistoryService.cs +++ b/src/NzbDrone.Core/History/HistoryService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; using NLog; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Datastore; using NzbDrone.Core.Indexers; @@ -205,6 +206,27 @@ namespace NzbDrone.Core.History history.Data.Add("GrabTitle", message.Title); history.Data.Add("Url", message.Url ?? string.Empty); + if (message.ElapsedTime > 0) + { + history.Data.Add("ElapsedTime", message.ElapsedTime.ToString()); + } + + if (message.Release.InfoUrl.IsNotNullOrWhiteSpace()) + { + history.Data.Add("InfoUrl", message.Release.InfoUrl); + } + + if (message.DownloadClient.IsNotNullOrWhiteSpace() || message.DownloadClientName.IsNotNullOrWhiteSpace()) + { + history.Data.Add("DownloadClient", message.DownloadClient ?? string.Empty); + history.Data.Add("DownloadClientName", message.DownloadClientName ?? string.Empty); + } + + if (message.Release.PublishDate != DateTime.MinValue) + { + history.Data.Add("PublishedDate", message.Release.PublishDate.ToUniversalTime().ToString("s") + "Z"); + } + _historyRepository.Insert(history); } @@ -218,7 +240,7 @@ namespace NzbDrone.Core.History Successful = message.Successful }; - history.Data.Add("ElapsedTime", message.Time.ToString()); + history.Data.Add("ElapsedTime", message.ElapsedTime.ToString()); _historyRepository.Insert(history); } diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedNotificationStatus.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedNotificationStatus.cs new file mode 100644 index 000000000..cfc3e1f63 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedNotificationStatus.cs @@ -0,0 +1,27 @@ +using Dapper; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class CleanupOrphanedNotificationStatus : IHousekeepingTask + { + private readonly IMainDatabase _database; + + public CleanupOrphanedNotificationStatus(IMainDatabase database) + { + _database = database; + } + + public void Clean() + { + using var mapper = _database.OpenConnection(); + + mapper.Execute(@"DELETE FROM ""NotificationStatus"" + WHERE ""Id"" IN ( + SELECT ""NotificationStatus"".""Id"" FROM ""NotificationStatus"" + LEFT OUTER JOIN ""Notifications"" + ON ""NotificationStatus"".""ProviderId"" = ""Notifications"".""Id"" + WHERE ""Notifications"".""Id"" IS NULL)"); + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureNotificationStatusTimes.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureNotificationStatusTimes.cs new file mode 100644 index 000000000..10af6ab42 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureNotificationStatusTimes.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.Notifications; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class FixFutureNotificationStatusTimes : FixFutureProviderStatusTimes, IHousekeepingTask + { + public FixFutureNotificationStatusTimes(INotificationStatusRepository notificationStatusRepository) + : base(notificationStatusRepository) + { + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/TrimLogDatabase.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/TrimLogDatabase.cs index a719652af..5763a563e 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/TrimLogDatabase.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/TrimLogDatabase.cs @@ -1,18 +1,26 @@ -using NzbDrone.Core.Instrumentation; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Instrumentation; namespace NzbDrone.Core.Housekeeping.Housekeepers { public class TrimLogDatabase : IHousekeepingTask { private readonly ILogRepository _logRepo; + private readonly IConfigFileProvider _configFileProvider; - public TrimLogDatabase(ILogRepository logRepo) + public TrimLogDatabase(ILogRepository logRepo, IConfigFileProvider configFileProvider) { _logRepo = logRepo; + _configFileProvider = configFileProvider; } public void Clean() { + if (!_configFileProvider.LogDbEnabled) + { + return; + } + _logRepo.Trim(); } } diff --git a/src/NzbDrone.Core/Http/HttpProxySettingsProvider.cs b/src/NzbDrone.Core/Http/HttpProxySettingsProvider.cs index a56bcf2e6..24b5aa67f 100644 --- a/src/NzbDrone.Core/Http/HttpProxySettingsProvider.cs +++ b/src/NzbDrone.Core/Http/HttpProxySettingsProvider.cs @@ -1,5 +1,7 @@ using System; +using System.Linq; using System.Net; +using NetTools; using NzbDrone.Common.Http; using NzbDrone.Common.Http.Proxy; using NzbDrone.Core.Configuration; @@ -52,7 +54,15 @@ namespace NzbDrone.Core.Http //We are utilizing the WebProxy implementation here to save us having to re-implement it. This way we use Microsofts implementation var proxy = new WebProxy(proxySettings.Host + ":" + proxySettings.Port, proxySettings.BypassLocalAddress, proxySettings.BypassListAsArray); - return proxy.IsBypassed((Uri)url); + return proxy.IsBypassed((Uri)url) || IsBypassedByIpAddressRange(proxySettings.BypassListAsArray, url.Host); + } + + private static bool IsBypassedByIpAddressRange(string[] bypassList, string host) + { + return bypassList.Any(bypass => + IPAddressRange.TryParse(bypass, out var ipAddressRange) && + IPAddress.TryParse(host, out var ipAddress) && + ipAddressRange.Contains(ipAddress)); } } } diff --git a/src/NzbDrone.Core/IndexerProxies/FlareSolverr/FlareSolverr.cs b/src/NzbDrone.Core/IndexerProxies/FlareSolverr/FlareSolverr.cs index c3edb880e..5107bf151 100644 --- a/src/NzbDrone.Core/IndexerProxies/FlareSolverr/FlareSolverr.cs +++ b/src/NzbDrone.Core/IndexerProxies/FlareSolverr/FlareSolverr.cs @@ -102,9 +102,15 @@ namespace NzbDrone.Core.IndexerProxies.FlareSolverr var url = request.Url.ToString(); var maxTimeout = Settings.RequestTimeout * 1000; - // Use Proxy if no credentials are set (creds not supported as of FS 2.2.9) var proxySettings = _proxySettingsProvider.GetProxySettings(); - var proxyUrl = proxySettings != null && proxySettings.Username.IsNullOrWhiteSpace() && proxySettings.Password.IsNullOrWhiteSpace() ? GetProxyUri(proxySettings) : null; + var proxyUrl = proxySettings != null ? GetProxyUri(proxySettings) : null; + + var requestProxy = new FlareSolverrProxy + { + Url = proxyUrl?.OriginalString, + Username = proxySettings != null && proxySettings.Username.IsNotNullOrWhiteSpace() ? proxySettings.Username : null, + Password = proxySettings != null && proxySettings.Password.IsNotNullOrWhiteSpace() ? proxySettings.Password : null + }; if (request.Method == HttpMethod.Get) { @@ -113,10 +119,7 @@ namespace NzbDrone.Core.IndexerProxies.FlareSolverr Cmd = "request.get", Url = url, MaxTimeout = maxTimeout, - Proxy = new FlareSolverrProxy - { - Url = proxyUrl?.AbsoluteUri - } + Proxy = requestProxy }; } else if (request.Method == HttpMethod.Post) @@ -139,10 +142,7 @@ namespace NzbDrone.Core.IndexerProxies.FlareSolverr ContentLength = null }, MaxTimeout = maxTimeout, - Proxy = new FlareSolverrProxy - { - Url = proxyUrl?.AbsoluteUri - } + Proxy = requestProxy }; } else if (contentTypeType.Contains("multipart/form-data") @@ -169,6 +169,7 @@ namespace NzbDrone.Core.IndexerProxies.FlareSolverr newRequest.LogResponseContent = true; newRequest.RequestTimeout = TimeSpan.FromSeconds(Settings.RequestTimeout + 5); newRequest.SetContent(req.ToJson()); + newRequest.ContentSummary = req.ToJson(Formatting.None); _logger.Debug("Cloudflare Detected, Applying FlareSolverr Proxy {0} to request {1}", Name, request.Url); @@ -189,16 +190,16 @@ namespace NzbDrone.Core.IndexerProxies.FlareSolverr if (response.StatusCode != HttpStatusCode.OK) { - _logger.Error("Proxy Health Check failed: {0}", response.StatusCode); - failures.Add(new NzbDroneValidationFailure("Host", string.Format(_localizationService.GetLocalizedString("ProxyCheckBadRequestMessage"), response.StatusCode))); + _logger.Error("Proxy validation failed: {0}", response.StatusCode); + failures.Add(new NzbDroneValidationFailure("Host", _localizationService.GetLocalizedString("ProxyValidationBadRequest", new Dictionary { { "statusCode", response.StatusCode } }))); } var result = JsonConvert.DeserializeObject(response.Content); } catch (Exception ex) { - _logger.Error(ex, "Proxy Health Check failed"); - failures.Add(new NzbDroneValidationFailure("Host", string.Format(_localizationService.GetLocalizedString("ProxyCheckFailedToTestMessage"), request.Url.Host))); + _logger.Error(ex, "Proxy validation failed"); + failures.Add(new NzbDroneValidationFailure("Host", _localizationService.GetLocalizedString("ProxyValidationUnableToConnect", new Dictionary { { "exceptionMessage", ex.Message } }))); } return new ValidationResult(failures); @@ -206,17 +207,13 @@ namespace NzbDrone.Core.IndexerProxies.FlareSolverr private Uri GetProxyUri(HttpProxySettings proxySettings) { - switch (proxySettings.Type) + return proxySettings.Type switch { - case ProxyType.Http: - return new Uri("http://" + proxySettings.Host + ":" + proxySettings.Port); - case ProxyType.Socks4: - return new Uri("socks4://" + proxySettings.Host + ":" + proxySettings.Port); - case ProxyType.Socks5: - return new Uri("socks5://" + proxySettings.Host + ":" + proxySettings.Port); - default: - return null; - } + ProxyType.Http => new Uri("http://" + proxySettings.Host + ":" + proxySettings.Port), + ProxyType.Socks4 => new Uri("socks4://" + proxySettings.Host + ":" + proxySettings.Port), + ProxyType.Socks5 => new Uri("socks5://" + proxySettings.Host + ":" + proxySettings.Port), + _ => null + }; } private class FlareSolverrRequest @@ -247,6 +244,8 @@ namespace NzbDrone.Core.IndexerProxies.FlareSolverr private class FlareSolverrProxy { public string Url { get; set; } + public string Username { get; set; } + public string Password { get; set; } } private class HeadersPost diff --git a/src/NzbDrone.Core/IndexerProxies/HttpIndexerProxyBase.cs b/src/NzbDrone.Core/IndexerProxies/HttpIndexerProxyBase.cs index b82251d6e..d7c9acc23 100644 --- a/src/NzbDrone.Core/IndexerProxies/HttpIndexerProxyBase.cs +++ b/src/NzbDrone.Core/IndexerProxies/HttpIndexerProxyBase.cs @@ -41,14 +41,14 @@ namespace NzbDrone.Core.IndexerProxies // We only care about 400 responses, other error codes can be ignored if (response.StatusCode == HttpStatusCode.BadRequest) { - _logger.Error("Proxy Health Check failed: {0}", response.StatusCode); - failures.Add(new NzbDroneValidationFailure("Host", string.Format("Failed to test proxy. StatusCode: {0}", response.StatusCode))); + _logger.Error("Proxy validation failed: {0}", response.StatusCode); + failures.Add(new NzbDroneValidationFailure("Host", _localizationService.GetLocalizedString("ProxyValidationBadRequest", new Dictionary { { "statusCode", response.StatusCode } }))); } } catch (Exception ex) { - _logger.Error(ex, "Proxy Health Check failed"); - failures.Add(new NzbDroneValidationFailure("Host", string.Format("Failed to test proxy: {0}", ex.Message))); + _logger.Error(ex, "Proxy validation failed"); + failures.Add(new NzbDroneValidationFailure("Host", _localizationService.GetLocalizedString("ProxyValidationUnableToConnect", new Dictionary { { "exceptionMessage", ex.Message } }))); } return new ValidationResult(failures); diff --git a/src/NzbDrone.Core/IndexerProxies/IndexerProxyBase.cs b/src/NzbDrone.Core/IndexerProxies/IndexerProxyBase.cs index dc3963875..1efe8437c 100644 --- a/src/NzbDrone.Core/IndexerProxies/IndexerProxyBase.cs +++ b/src/NzbDrone.Core/IndexerProxies/IndexerProxyBase.cs @@ -14,20 +14,7 @@ namespace NzbDrone.Core.IndexerProxies public Type ConfigContract => typeof(TSettings); - public IEnumerable DefaultDefinitions - { - get - { - var config = (IProviderConfig)new TSettings(); - - yield return new IndexerProxyDefinition - { - Name = GetType().Name, - Implementation = GetType().Name, - Settings = config - }; - } - } + public IEnumerable DefaultDefinitions => new List(); public ProviderDefinition Definition { get; set; } public abstract ValidationResult Test(); diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs index 339abff86..48e7586c5 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs @@ -17,6 +17,10 @@ namespace NzbDrone.Core.IndexerSearch.Definitions public string SearchType { get; set; } public int? Limit { get; set; } public int? Offset { get; set; } + public int? MinAge { get; set; } + public int? MaxAge { get; set; } + public long? MinSize { get; set; } + public long? MaxSize { get; set; } public string Source { get; set; } public string Host { get; set; } diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/TvSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/TvSearchCriteria.cs index 1e4c35e5b..d4c13c863 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/TvSearchCriteria.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/TvSearchCriteria.cs @@ -31,9 +31,7 @@ namespace NzbDrone.Core.IndexerSearch.Definitions !IsIdSearch; public override bool IsIdSearch => - Episode.IsNotNullOrWhiteSpace() || ImdbId.IsNotNullOrWhiteSpace() || - Season.HasValue || TvdbId.HasValue || RId.HasValue || TraktId.HasValue || @@ -45,14 +43,20 @@ namespace NzbDrone.Core.IndexerSearch.Definitions { get { - var searchQueryTerm = $"Term: []"; + var searchQueryTerm = "Term: []"; var searchEpisodeTerm = $" for Season / Episode:[{EpisodeSearchString}]"; if (SearchTerm.IsNotNullOrWhiteSpace()) { searchQueryTerm = $"Term: [{SearchTerm}]"; } - if (!ImdbId.IsNotNullOrWhiteSpace() && !TvdbId.HasValue && !RId.HasValue && !TraktId.HasValue) + if (!ImdbId.IsNotNullOrWhiteSpace() && + !TvdbId.HasValue && + !RId.HasValue && + !TraktId.HasValue && + !TvMazeId.HasValue && + !TmdbId.HasValue && + !DoubanId.HasValue) { return $"{searchQueryTerm}{searchEpisodeTerm}"; } @@ -80,11 +84,21 @@ namespace NzbDrone.Core.IndexerSearch.Definitions builder.Append($" TraktId:[{TraktId}]"); } + if (TvMazeId.HasValue) + { + builder.Append($" TvMazeId:[{TvMazeId}]"); + } + if (TmdbId.HasValue) { builder.Append($" TmdbId:[{TmdbId}]"); } + if (DoubanId.HasValue) + { + builder.Append($" DoubanId:[{DoubanId}]"); + } + builder = builder.Append(searchEpisodeTerm); return builder.ToString().Trim(); } @@ -92,29 +106,29 @@ namespace NzbDrone.Core.IndexerSearch.Definitions private string GetEpisodeSearchString() { - if (Season == null || Season == 0) + if (Season is null or 0) { return string.Empty; } string episodeString; - if (DateTime.TryParseExact(string.Format("{0} {1}", Season, Episode), "yyyy MM/dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var showDate)) + if (DateTime.TryParseExact($"{Season} {Episode}", "yyyy MM/dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var showDate)) { - episodeString = showDate.ToString("yyyy.MM.dd"); + episodeString = showDate.ToString("yyyy.MM.dd", CultureInfo.InvariantCulture); } else if (Episode.IsNullOrWhiteSpace()) { - episodeString = string.Format("S{0:00}", Season); + episodeString = $"S{Season:00}"; } else { try { - episodeString = string.Format("S{0:00}E{1:00}", Season, ParseUtil.CoerceInt(Episode)); + episodeString = $"S{Season:00}E{ParseUtil.CoerceInt(Episode):00}"; } catch (FormatException) { - episodeString = string.Format("S{0:00}E{1}", Season, Episode); + episodeString = $"S{Season:00}E{Episode}"; } } diff --git a/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs b/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs index 8fb138fcf..442b7ba00 100644 --- a/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs +++ b/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs @@ -5,7 +5,7 @@ namespace NzbDrone.Core.IndexerSearch { public class NewznabRequest { - private static readonly Regex TvRegex = new (@"\{((?:imdbid\:)(?[^{]+)|(?:rid\:)(?[^{]+)|(?:tvdbid\:)(?[^{]+)|(?:tmdbid\:)(?[^{]+)|(?:doubanid\:)(?[^{]+)|(?:season\:)(?[^{]+)|(?:episode\:)(?[^{]+)|(?:year\:)(?[^{]+)|(?:genre\:)(?[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex TvRegex = new (@"\{((?:imdbid\:)(?[^{]+)|(?:rid\:)(?[^{]+)|(?:tvdbid\:)(?[^{]+)|(?:tmdbid\:)(?[^{]+)|(?:tvmazeid\:)(?[^{]+)|(?:doubanid\:)(?[^{]+)|(?:season\:)(?[^{]+)|(?:episode\:)(?[^{]+)|(?:year\:)(?[^{]+)|(?:genre\:)(?[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex MovieRegex = new (@"\{((?:imdbid\:)(?[^{]+)|(?:doubanid\:)(?[^{]+)|(?:tmdbid\:)(?[^{]+)|(?:traktid\:)(?[^{]+)|(?:year\:)(?[^{]+)|(?:genre\:)(?[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex MusicRegex = new (@"\{((?:artist\:)(?[^{]+)|(?:album\:)(?[^{]+)|(?:track\:)(?[^{]+)|(?:label\:)(?