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/.editorconfig b/.editorconfig index 9e14400f9..2fc5f556b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -36,12 +36,18 @@ dotnet_naming_style.instance_field_style.capitalization = camel_case dotnet_naming_style.instance_field_style.required_prefix = _ # Prefer "var" everywhere -csharp_style_var_for_built_in_types = true:suggestion -csharp_style_var_when_type_is_apparent = true:suggestion -csharp_style_var_elsewhere = true:suggestion +csharp_style_var_for_built_in_types = true +csharp_style_var_when_type_is_apparent = true +csharp_style_var_elsewhere = true +# Prefer "out" variables to be declared inline +csharp_style_inlined_variable_declaration = true # Using directive is unnecessary. dotnet_diagnostic.IDE0005.severity = error +# Use var instead of explicit type +dotnet_diagnostic.IDE0007.severity = error +# Inline variable declaration +dotnet_diagnostic.IDE0018.severity = error # Stylecop Rules dotnet_diagnostic.SA0001.severity = none diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index ee1c1e09d..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 @@ -74,7 +74,7 @@ body: - type: checkboxes attributes: label: Trace Logs have been provided as applicable. Reports may be closed if the required logs are not provided. - description: Trace logs are generally required for all bug reports + description: Trace logs are generally required for all bug reports and contain `trace`. Info logs are invalid for bug reports and do not contain `debug` nor `trace` options: - - label: I have followed the steps in the wiki link above and provided the required trace logs that are relevant and show this issue. + - label: I have read and followed the steps in the wiki link above and provided the required trace logs - the logs contain `trace` - that are relevant and show this issue. required: true 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 4751edb37..ce6d46c73 100644 --- a/.github/label-actions.yml +++ b/.github/label-actions.yml @@ -4,9 +4,9 @@ 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' 'Type: Indexer Request': comment: > @@ -14,6 +14,7 @@ for bug reports and feature requests. However, this issue appears to be a indexer request. Please use our Indexer request [site](https://requests.prowlarr.com/) close: true + close-reason: 'not planned' 'Status: Logs Needed': comment: > 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 0bf0dc6d1..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.5.1' + 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' @@ -362,7 +367,7 @@ stages: - bash: | echo "Uploading source maps to sentry" curl -sL https://sentry.io/get-cli/ | bash - RELEASENAME="${PROWLARRVERSION}-${BUILD_SOURCEBRANCHNAME}" + RELEASENAME="Prowlarr@${PROWLARRVERSION}-${BUILD_SOURCEBRANCHNAME}" sentry-cli releases new --finalize -p prowlarr -p prowlarr-ui -p prowlarr-update "${RELEASENAME}" sentry-cli releases -p prowlarr-ui files "${RELEASENAME}" upload-sourcemaps _output/UI/ --rewrite sentry-cli releases set-commits --auto "${RELEASENAME}" @@ -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 @@ -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/SelectContext.tsx b/frontend/src/App/SelectContext.tsx index 6980129c1..66be388ce 100644 --- a/frontend/src/App/SelectContext.tsx +++ b/frontend/src/App/SelectContext.tsx @@ -1,58 +1,28 @@ import { cloneDeep } from 'lodash'; -import React, { useEffect } from 'react'; -import areAllSelected from 'Utilities/Table/areAllSelected'; -import selectAll from 'Utilities/Table/selectAll'; -import toggleSelected from 'Utilities/Table/toggleSelected'; +import React, { useCallback, useEffect } from 'react'; +import useSelectState, { SelectState } from 'Helpers/Hooks/useSelectState'; import ModelBase from './ModelBase'; -export enum SelectActionType { - Reset, - SelectAll, - UnselectAll, - ToggleSelected, - RemoveItem, - UpdateItems, -} - -type SelectedState = Record; - -interface SelectState { - selectedState: SelectedState; - lastToggled: number | null; - allSelected: boolean; - allUnselected: boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - items: any[]; -} - -type SelectAction = - | { type: SelectActionType.Reset } - | { type: SelectActionType.SelectAll } - | { type: SelectActionType.UnselectAll } +export type SelectContextAction = + | { type: 'reset' } + | { type: 'selectAll' } + | { type: 'unselectAll' } | { - type: SelectActionType.ToggleSelected; + type: 'toggleSelected'; id: number; isSelected: boolean; shiftKey: boolean; } | { - type: SelectActionType.RemoveItem; + type: 'removeItem'; id: number; } | { - type: SelectActionType.UpdateItems; + type: 'updateItems'; items: ModelBase[]; }; -type Dispatch = (action: SelectAction) => void; - -const initialState = { - selectedState: {}, - lastToggled: null, - allSelected: false, - allUnselected: true, - items: [], -}; +export type SelectDispatch = (action: SelectContextAction) => void; interface SelectProviderOptions { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -60,90 +30,40 @@ interface SelectProviderOptions { items: Array; } -function getSelectedState(items: ModelBase[], existingState: SelectedState) { - return items.reduce((acc: SelectedState, item) => { - const id = item.id; - - acc[id] = existingState[id] ?? false; - - return acc; - }, {}); -} - -// TODO: Can this be reused? - -const SelectContext = React.createContext<[SelectState, Dispatch] | undefined>( - cloneDeep(undefined) -); - -function selectReducer(state: SelectState, action: SelectAction): SelectState { - const { items, selectedState } = state; - - switch (action.type) { - case SelectActionType.Reset: { - return cloneDeep(initialState); - } - case SelectActionType.SelectAll: { - return { - items, - ...selectAll(selectedState, true), - }; - } - case SelectActionType.UnselectAll: { - return { - items, - ...selectAll(selectedState, false), - }; - } - case SelectActionType.ToggleSelected: { - const result = { - items, - ...toggleSelected( - state, - items, - action.id, - action.isSelected, - action.shiftKey - ), - }; - - return result; - } - case SelectActionType.UpdateItems: { - const nextSelectedState = getSelectedState(action.items, selectedState); - - return { - ...state, - ...areAllSelected(nextSelectedState), - selectedState: nextSelectedState, - items: action.items, - }; - } - default: { - throw new Error(`Unhandled action type: ${action.type}`); - } - } -} +const SelectContext = React.createContext< + [SelectState, SelectDispatch] | undefined +>(cloneDeep(undefined)); export function SelectProvider( props: SelectProviderOptions ) { const { items } = props; - const selectedState = getSelectedState(items, {}); + const [state, dispatch] = useSelectState(); - const [state, dispatch] = React.useReducer(selectReducer, { - selectedState, - lastToggled: null, - allSelected: false, - allUnselected: true, - items, - }); + const dispatchWrapper = useCallback( + (action: SelectContextAction) => { + switch (action.type) { + case 'reset': + case 'removeItem': + dispatch(action); + break; - const value: [SelectState, Dispatch] = [state, dispatch]; + default: + dispatch({ + ...action, + items, + }); + break; + } + }, + [items, dispatch] + ); + + const value: [SelectState, SelectDispatch] = [state, dispatchWrapper]; useEffect(() => { - dispatch({ type: SelectActionType.UpdateItems, items }); - }, [items]); + dispatch({ type: 'updateItems', items }); + }, [items, dispatch]); return ( diff --git a/frontend/src/App/State/AppSectionState.ts b/frontend/src/App/State/AppSectionState.ts new file mode 100644 index 000000000..f89eb25f7 --- /dev/null +++ b/frontend/src/App/State/AppSectionState.ts @@ -0,0 +1,63 @@ +import Column from 'Components/Table/Column'; +import SortDirection from 'Helpers/Props/SortDirection'; +import { FilterBuilderProp, PropertyFilter } from './AppState'; + +export interface Error { + responseJSON: { + message: string; + }; +} + +export interface AppSectionDeleteState { + isDeleting: boolean; + deleteError: Error; +} + +export interface AppSectionSaveState { + isSaving: boolean; + saveError: Error; +} + +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 { + isSchemaFetching: boolean; + isSchemaPopulated: boolean; + schemaError: Error; + schema: { + items: T[]; + }; +} + +export interface AppSectionItemState { + isFetching: boolean; + isPopulated: boolean; + error: Error; + pendingChanges: Partial; + item: T; +} + +interface AppSectionState { + isFetching: boolean; + isPopulated: boolean; + error: Error; + items: T[]; + sortKey: string; + sortDirection: SortDirection; +} + +export default AppSectionState; diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts new file mode 100644 index 000000000..0f0e82c0d --- /dev/null +++ b/frontend/src/App/State/AppState.ts @@ -0,0 +1,71 @@ +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 { + id: string; + name: string; +} + +export interface FilterBuilderProp { + name: string; + label: string; + type: string; + valueType?: string; + optionsSelector?: (items: T[]) => FilterBuilderPropOption[]; +} + +export interface PropertyFilter { + key: string; + value: boolean | string | number | string[] | number[]; + type: string; +} + +export interface Filter { + key: string; + label: string; + filers: PropertyFilter[]; +} + +export interface CustomFilter { + id: number; + type: string; + label: string; + 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; +} + +export default AppState; 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 new file mode 100644 index 000000000..4c0145d0d --- /dev/null +++ b/frontend/src/App/State/IndexerAppState.ts @@ -0,0 +1,42 @@ +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 { + 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 new file mode 100644 index 000000000..33c6c936d --- /dev/null +++ b/frontend/src/App/State/SettingsAppState.ts @@ -0,0 +1,57 @@ +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 General from 'typings/Settings/General'; +import UiSettings from 'typings/Settings/UiSettings'; + +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 {} + +export interface NotificationAppState + extends AppSectionState, + AppSectionDeleteState {} + +export type UiSettingsAppState = AppSectionItemState; + +interface SettingsAppState { + appProfiles: AppProfileAppState; + applications: ApplicationAppState; + downloadClients: DownloadClientAppState; + general: GeneralAppState; + indexerCategories: IndexerCategoryAppState; + notifications: NotificationAppState; + 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 new file mode 100644 index 000000000..53a0d847f --- /dev/null +++ b/frontend/src/App/State/TagsAppState.ts @@ -0,0 +1,28 @@ +import ModelBase from 'App/ModelBase'; +import AppSectionState, { + AppSectionDeleteState, + AppSectionSaveState, +} from 'App/State/AppSectionState'; + +export interface Tag extends ModelBase { + label: string; +} + +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/IndexerFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js index bb4e594cc..fc211caec 100644 --- a/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js +++ b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js @@ -9,13 +9,13 @@ import FilterBuilderRowValue from './FilterBuilderRowValue'; function createMapStateToProps() { return createSelector( (state) => state.indexers, - (qualityProfiles) => { + (indexers) => { const { isFetching, isPopulated, error, items - } = qualityProfiles; + } = indexers; const tagList = items.map((item) => { return { 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 fc40e9d3c..0ab181e2f 100644 --- a/frontend/src/Components/Form/AppProfileSelectInputConnector.js +++ b/frontend/src/Components/Form/AppProfileSelectInputConnector.js @@ -4,12 +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) => { @@ -23,16 +24,20 @@ function createMapStateToProps() { if (includeNoChange) { values.unshift({ key: 'noChange', - value: 'No Change', - 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 new file mode 100644 index 000000000..9cf7a429a --- /dev/null +++ b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDownloadClients } from 'Store/Actions/settingsActions'; +import sortByProp from 'Utilities/Array/sortByProp'; +import translate from 'Utilities/String/translate'; +import EnhancedSelectInput from './EnhancedSelectInput'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.downloadClients, + (state, { includeAny }) => includeAny, + (state, { protocol }) => protocol, + (downloadClients, includeAny, protocolFilter) => { + const { + isFetching, + isPopulated, + error, + items + } = downloadClients; + + const values = items + .filter((downloadClient) => downloadClient.protocol === protocolFilter) + .sort(sortByProp('name')) + .map((downloadClient) => ({ + key: downloadClient.id, + value: downloadClient.name, + hint: `(${downloadClient.id})` + })); + + if (includeAny) { + values.unshift({ + key: 0, + value: `(${translate('Any')})` + }); + } + + return { + isFetching, + isPopulated, + error, + values + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchDownloadClients: fetchDownloadClients +}; + +class DownloadClientSelectInputConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.isPopulated) { + this.props.dispatchFetchDownloadClients(); + } + } + + // + // Listeners + + onChange = ({ name, value }) => { + this.props.onChange({ name, value: parseInt(value) }); + }; + + // + // Render + + render() { + return ( + + ); + } +} + +DownloadClientSelectInputConnector.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + values: PropTypes.arrayOf(PropTypes.object).isRequired, + includeAny: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + dispatchFetchDownloadClients: PropTypes.func.isRequired +}; + +DownloadClientSelectInputConnector.defaultProps = { + includeAny: false, + protocol: 'torrent' +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSelectInputConnector); diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js index 4df54092c..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/PageSectionContent.js b/frontend/src/Components/Page/PageSectionContent.js index 774b88669..2cef9eef1 100644 --- a/frontend/src/Components/Page/PageSectionContent.js +++ b/frontend/src/Components/Page/PageSectionContent.js @@ -1,6 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; +import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import { kinds } from 'Helpers/Props'; function PageSectionContent(props) { const { @@ -17,7 +19,7 @@ function PageSectionContent(props) { ); } else if (!isFetching && !!error) { return ( -
{errorMessage}
+ {errorMessage} ); } else if (isPopulated && !error) { return ( 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} >
@@ -67,7 +67,7 @@ function ProgressBar(props) { { showText ?
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/Indexer/Stats/Stats.css.d.ts b/frontend/src/Components/Table/Cells/TableSelectCell.css.d.ts similarity index 74% rename from frontend/src/Indexer/Stats/Stats.css.d.ts rename to frontend/src/Components/Table/Cells/TableSelectCell.css.d.ts index ce2364202..b6aee3c85 100644 --- a/frontend/src/Indexer/Stats/Stats.css.d.ts +++ b/frontend/src/Components/Table/Cells/TableSelectCell.css.d.ts @@ -1,8 +1,8 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'fullWidthChart': string; - 'halfWidthChart': string; + 'input': string; + 'selectCell': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Components/Table/Column.ts b/frontend/src/Components/Table/Column.ts index f9ff7287c..24674c3fc 100644 --- a/frontend/src/Components/Table/Column.ts +++ b/frontend/src/Components/Table/Column.ts @@ -1,8 +1,14 @@ +import React from 'react'; + +type PropertyFunction = () => T; + +// TODO: Convert to generic so `name` can be a type interface Column { name: string; - label: string; - columnLabel: string; - isSortable: boolean; + label: string | PropertyFunction | React.ReactNode; + className?: string; + columnLabel?: string; + isSortable?: boolean; isVisible: boolean; isModifiable?: boolean; } diff --git a/frontend/src/Components/Table/Table.js b/frontend/src/Components/Table/Table.js index c41fc982a..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} ); }) @@ -121,6 +121,7 @@ function Table(props) { } Table.propTypes = { + ...TableHeaderCell.props, className: PropTypes.string, horizontalScroll: PropTypes.bool.isRequired, selectAll: PropTypes.bool.isRequired, 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 {
@@ -180,16 +187,19 @@ VirtualTable.propTypes = { className: PropTypes.string.isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired, scrollIndex: PropTypes.number, + scrollTop: PropTypes.number, scroller: PropTypes.instanceOf(Element).isRequired, focusScroller: PropTypes.bool.isRequired, header: PropTypes.node.isRequired, headerHeight: PropTypes.number.isRequired, - rowRenderer: PropTypes.func.isRequired + rowRenderer: PropTypes.func.isRequired, + rowHeight: PropTypes.number.isRequired }; VirtualTable.defaultProps = { className: styles.tableContainer, headerHeight: 38, + rowHeight: ROW_HEIGHT, focusScroller: true }; diff --git a/frontend/src/Components/Table/usePaging.ts b/frontend/src/Components/Table/usePaging.ts new file mode 100644 index 000000000..dfebb2355 --- /dev/null +++ b/frontend/src/Components/Table/usePaging.ts @@ -0,0 +1,54 @@ +import { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; + +interface PagingOptions { + page: number; + totalPages: number; + gotoPage: ({ page }: { page: number }) => 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 713f2bff4..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'); + } } }; @@ -67,8 +81,10 @@ function keyboardShortcuts(WrappedComponent) { }; unbindShortcut = (key) => { - delete this._mousetrapBindings[key]; - this._mousetrap.unbind(key); + if (this._mousetrap != null) { + delete this._mousetrapBindings[key]; + this._mousetrap.unbind(key); + } }; unbindAllShortcuts = () => { 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/Content/Images/Icons/manifest.json b/frontend/src/Content/Images/Icons/manifest.json index d14732f60..f53279dd3 100644 --- a/frontend/src/Content/Images/Icons/manifest.json +++ b/frontend/src/Content/Images/Icons/manifest.json @@ -1,18 +1,19 @@ { - "name": "", + "name": "Prowlarr", "icons": [ { - "src": "/Content/Images/Icons/android-chrome-192x192.png", + "src": "android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "/Content/Images/Icons/android-chrome-512x512.png", + "src": "android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } ], + "start_url": "../../../../", "theme_color": "#3a3f51", "background_color": "#3a3f51", "display": "standalone" -} \ No newline at end of file +} 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/Hooks/usePrevious.tsx b/frontend/src/Helpers/Hooks/usePrevious.tsx new file mode 100644 index 000000000..b594e2632 --- /dev/null +++ b/frontend/src/Helpers/Hooks/usePrevious.tsx @@ -0,0 +1,11 @@ +import { useEffect, useRef } from 'react'; + +export default function usePrevious(value: T): T | undefined { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +} diff --git a/frontend/src/Helpers/Hooks/useSelectState.tsx b/frontend/src/Helpers/Hooks/useSelectState.tsx new file mode 100644 index 000000000..8fb96e42a --- /dev/null +++ b/frontend/src/Helpers/Hooks/useSelectState.tsx @@ -0,0 +1,113 @@ +import { cloneDeep } from 'lodash'; +import { useReducer } from 'react'; +import ModelBase from 'App/ModelBase'; +import areAllSelected from 'Utilities/Table/areAllSelected'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; + +export type SelectedState = Record; + +export interface SelectState { + selectedState: SelectedState; + lastToggled: number | null; + allSelected: boolean; + allUnselected: boolean; +} + +export type SelectAction = + | { type: 'reset' } + | { type: 'selectAll'; items: ModelBase[] } + | { type: 'unselectAll'; items: ModelBase[] } + | { + type: 'toggleSelected'; + id: number; + isSelected: boolean; + shiftKey: boolean; + items: ModelBase[]; + } + | { + type: 'removeItem'; + id: number; + } + | { + type: 'updateItems'; + items: ModelBase[]; + }; + +export type Dispatch = (action: SelectAction) => void; + +const initialState = { + selectedState: {}, + lastToggled: null, + allSelected: false, + allUnselected: true, + items: [], +}; + +function getSelectedState(items: ModelBase[], existingState: SelectedState) { + return items.reduce((acc: SelectedState, item) => { + const id = item.id; + + acc[id] = existingState[id] ?? false; + + return acc; + }, {}); +} + +function selectReducer(state: SelectState, action: SelectAction): SelectState { + const { selectedState } = state; + + switch (action.type) { + case 'reset': { + return cloneDeep(initialState); + } + case 'selectAll': { + return { + ...selectAll(selectedState, true), + }; + } + case 'unselectAll': { + return { + ...selectAll(selectedState, false), + }; + } + case 'toggleSelected': { + const result = { + ...toggleSelected( + state, + action.items, + action.id, + action.isSelected, + action.shiftKey + ), + }; + + return result; + } + case 'updateItems': { + const nextSelectedState = getSelectedState(action.items, selectedState); + + return { + ...state, + ...areAllSelected(nextSelectedState), + selectedState: nextSelectedState, + }; + } + default: { + throw new Error(`Unhandled action type: ${action.type}`); + } + } +} + +export default function useSelectState(): [SelectState, Dispatch] { + const selectedState = getSelectedState([], {}); + + const [state, dispatch] = useReducer(selectReducer, { + selectedState, + lastToggled: null, + allSelected: false, + allUnselected: true, + }); + + return [state, dispatch]; +} 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/filterBuilderTypes.js b/frontend/src/Helpers/Props/filterBuilderTypes.js index 776ba2afc..c0806fabc 100644 --- a/frontend/src/Helpers/Props/filterBuilderTypes.js +++ b/frontend/src/Helpers/Props/filterBuilderTypes.js @@ -1,14 +1,18 @@ import * as filterTypes from './filterTypes'; export const ARRAY = 'array'; +export const CONTAINS = 'contains'; export const DATE = 'date'; +export const EQUAL = 'equal'; export const EXACT = 'exact'; export const NUMBER = 'number'; export const STRING = 'string'; export const all = [ ARRAY, + CONTAINS, DATE, + EQUAL, EXACT, NUMBER, STRING @@ -20,6 +24,10 @@ export const possibleFilterTypes = { { key: filterTypes.NOT_CONTAINS, value: 'does not contain' } ], + [CONTAINS]: [ + { key: filterTypes.CONTAINS, value: 'contains' } + ], + [DATE]: [ { key: filterTypes.LESS_THAN, value: 'is before' }, { key: filterTypes.GREATER_THAN, value: 'is after' }, @@ -29,6 +37,10 @@ export const possibleFilterTypes = { { key: filterTypes.NOT_IN_NEXT, value: 'not in the next' } ], + [EQUAL]: [ + { key: filterTypes.EQUAL, value: 'is' } + ], + [EXACT]: [ { key: filterTypes.EQUAL, value: 'is' }, { key: filterTypes.NOT_EQUAL, value: 'is not' } @@ -47,6 +59,10 @@ export const possibleFilterTypes = { { key: filterTypes.CONTAINS, value: 'contains' }, { key: filterTypes.NOT_CONTAINS, value: 'does not contain' }, { key: filterTypes.EQUAL, value: 'equal' }, - { key: filterTypes.NOT_EQUAL, value: 'not equal' } + { key: filterTypes.NOT_EQUAL, value: 'not equal' }, + { key: filterTypes.STARTS_WITH, value: 'starts with' }, + { key: filterTypes.NOT_STARTS_WITH, value: 'does not start with' }, + { key: filterTypes.ENDS_WITH, value: 'ends with' }, + { key: filterTypes.NOT_ENDS_WITH, value: 'does not end with' } ] }; 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/filterTypePredicates.js b/frontend/src/Helpers/Props/filterTypePredicates.js index a3ea11956..d07059c02 100644 --- a/frontend/src/Helpers/Props/filterTypePredicates.js +++ b/frontend/src/Helpers/Props/filterTypePredicates.js @@ -39,6 +39,22 @@ const filterTypePredicates = { [filterTypes.NOT_EQUAL]: function(itemValue, filterValue) { return itemValue !== filterValue; + }, + + [filterTypes.STARTS_WITH]: function(itemValue, filterValue) { + return itemValue.toLowerCase().startsWith(filterValue.toLowerCase()); + }, + + [filterTypes.NOT_STARTS_WITH]: function(itemValue, filterValue) { + return !itemValue.toLowerCase().startsWith(filterValue.toLowerCase()); + }, + + [filterTypes.ENDS_WITH]: function(itemValue, filterValue) { + return itemValue.toLowerCase().endsWith(filterValue.toLowerCase()); + }, + + [filterTypes.NOT_ENDS_WITH]: function(itemValue, filterValue) { + return !itemValue.toLowerCase().endsWith(filterValue.toLowerCase()); } }; diff --git a/frontend/src/Helpers/Props/filterTypes.js b/frontend/src/Helpers/Props/filterTypes.js index 993e8df57..239a4e7e9 100644 --- a/frontend/src/Helpers/Props/filterTypes.js +++ b/frontend/src/Helpers/Props/filterTypes.js @@ -10,6 +10,10 @@ export const LESS_THAN = 'lessThan'; export const LESS_THAN_OR_EQUAL = 'lessThanOrEqual'; export const NOT_CONTAINS = 'notContains'; export const NOT_EQUAL = 'notEqual'; +export const STARTS_WITH = 'startsWith'; +export const NOT_STARTS_WITH = 'notStartsWith'; +export const ENDS_WITH = 'endsWith'; +export const NOT_ENDS_WITH = 'notEndsWith'; export const all = [ CONTAINS, @@ -23,5 +27,9 @@ export const all = [ IN_LAST, NOT_IN_LAST, IN_NEXT, - NOT_IN_NEXT + NOT_IN_NEXT, + STARTS_WITH, + NOT_STARTS_WITH, + ENDS_WITH, + NOT_ENDS_WITH ]; diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 00e2c1aa0..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, @@ -72,8 +73,10 @@ import { faLanguage as fasLanguage, faLaptop as fasLaptop, faLevelUpAlt as fasLevelUpAlt, + faListCheck as fasListCheck, faLocationArrow as fasLocationArrow, faLock as fasLock, + faMagnet as fasMagnet, faMedkit as fasMedkit, faMinus as fasMinus, faMusic as fasMusic, @@ -139,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; @@ -180,6 +184,8 @@ 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; export const MONITORED = fasBookmark; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index 7a11bb0c7..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'; @@ -9,6 +8,8 @@ export const KEY_VALUE_LIST = 'keyValueList'; 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'; @@ -25,7 +26,6 @@ export const TAG_SELECT = 'tagSelect'; export const all = [ AUTO_COMPLETE, APP_PROFILE_SELECT, - AVAILABILITY_SELECT, CAPTCHA, CARDIGANNCAPTCHA, CHECK, @@ -34,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 63543f040..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') { @@ -18,8 +22,13 @@ function HistoryDetails(props) { query, queryResults, categories, + limit, + offset, source, - url + host, + url, + elapsedTime, + cached } = data; return ( @@ -31,43 +40,93 @@ function HistoryDetails(props) { /> { - !!indexer && + indexer ? + /> : + null } { - !!data && + data ? + /> : + null } { - !!data && + data ? + /> : + null } { - !!data && + limit ? + : + null + } + + { + offset ? + : + null + } + + { + data ? + /> : + null } { - !!data && + data ? + : + null + } + + { + data ? {translate('Link')} : '-'} - /> + /> : + null + } + + { + elapsedTime ? + : + null + } + + { + date ? + : + null } ); @@ -76,59 +135,156 @@ function HistoryDetails(props) { if (eventType === 'releaseGrabbed') { const { source, - title, - url + host, + grabTitle, + url, + publishedDate, + infoUrl, + downloadClient, + downloadClientName, + elapsedTime, + grabMethod } = data; + const downloadClientNameInfo = downloadClientName ?? downloadClient; + return ( { - !!indexer && + indexer ? + /> : + null } { - !!data && + data ? + /> : + null } { - !!data && + data ? + title={translate('Host')} + data={host} + /> : + null } { - !!data && + data ? + : + null + } + + { + infoUrl ? + {infoUrl}} + /> : + null + } + + { + publishedDate ? + : + null + } + + { + downloadClientNameInfo ? + : + null + } + + { + data ? {translate('Link')} : '-'} - /> + /> : + null + } + + { + elapsedTime ? + : + null + } + + { + grabMethod ? + : + null + } + + { + date ? + : + null } ); } if (eventType === 'indexerAuth') { + const { elapsedTime } = data; + return ( { - !!indexer && + indexer ? + /> : + null + } + + { + elapsedTime ? + : + null + } + + { + date ? + : + null } ); @@ -141,6 +297,15 @@ function HistoryDetails(props) { title={translate('Name')} data={data.query} /> + + { + date ? + : + null + } ); } @@ -148,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 - - } - - - - ); - } -} - -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 b83522fcf..7dabc50d9 100644 --- a/frontend/src/Indexer/Edit/EditIndexerModalContent.js +++ b/frontend/src/Indexer/Edit/EditIndexerModalContent.js @@ -26,6 +26,8 @@ function EditIndexerModalContent(props) { isTesting, saveError, item, + hasUsenetDownloadClients, + hasTorrentDownloadClients, onInputChange, onFieldChange, onModalClose, @@ -48,15 +50,18 @@ function EditIndexerModalContent(props) { appProfileId, tags, fields, - priority + priority, + protocol, + downloadClientId } = item; const indexerDisplayName = implementationName === definitionName ? implementationName : `${implementationName} (${definitionName})`; + const showDownloadClientInput = downloadClientId.value > 0 || protocol.value === 'usenet' && hasUsenetDownloadClients || protocol.value === 'torrent' && hasTorrentDownloadClients; return ( - {`${id ? translate('EditIndexer') : translate('AddIndexer')} - ${indexerDisplayName}`} + {id ? translate('EditIndexerImplementation', { implementationName: indexerDisplayName }) : translate('AddIndexerImplementation', { implementationName: indexerDisplayName })} @@ -92,7 +97,7 @@ function EditIndexerModalContent(props) { @@ -139,6 +144,7 @@ function EditIndexerModalContent(props) { }) : null } + + {showDownloadClientInput ? + + {translate('DownloadClient')} + + + : null + } + {translate('Tags')} @@ -163,6 +188,7 @@ function EditIndexerModalContent(props) { type={inputTypes.TAG} name="tags" helpText={translate('IndexerTagsHelpText')} + helpTextWarning={translate('IndexerTagsHelpTextWarning')} {...tags} onChange={onInputChange} /> @@ -222,6 +248,8 @@ EditIndexerModalContent.propTypes = { isTesting: PropTypes.bool.isRequired, saveError: PropTypes.object, item: PropTypes.object.isRequired, + hasUsenetDownloadClients: PropTypes.bool.isRequired, + hasTorrentDownloadClients: PropTypes.bool.isRequired, onInputChange: PropTypes.func.isRequired, onFieldChange: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired, diff --git a/frontend/src/Indexer/Edit/EditIndexerModalContentConnector.js b/frontend/src/Indexer/Edit/EditIndexerModalContentConnector.js index c76dd5ce4..66fdbbc15 100644 --- a/frontend/src/Indexer/Edit/EditIndexerModalContentConnector.js +++ b/frontend/src/Indexer/Edit/EditIndexerModalContentConnector.js @@ -3,17 +3,23 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { saveIndexer, setIndexerFieldValue, setIndexerValue, testIndexer } from 'Store/Actions/indexerActions'; -import { toggleAdvancedSettings } from 'Store/Actions/settingsActions'; +import { fetchDownloadClients, toggleAdvancedSettings } from 'Store/Actions/settingsActions'; import createIndexerSchemaSelector from 'Store/Selectors/createIndexerSchemaSelector'; import EditIndexerModalContent from './EditIndexerModalContent'; function createMapStateToProps() { return createSelector( (state) => state.settings.advancedSettings, + (state) => state.settings.downloadClients, createIndexerSchemaSelector(), - (advancedSettings, indexer) => { + (advancedSettings, downloadClients, indexer) => { + const usenetDownloadClients = downloadClients.items.filter((downloadClient) => downloadClient.protocol === 'usenet'); + const torrentDownloadClients = downloadClients.items.filter((downloadClient) => downloadClient.protocol === 'torrent'); + return { advancedSettings, + hasUsenetDownloadClients: usenetDownloadClients.length > 0, + hasTorrentDownloadClients: torrentDownloadClients.length > 0, ...indexer }; } @@ -25,7 +31,8 @@ const mapDispatchToProps = { setIndexerFieldValue, saveIndexer, testIndexer, - toggleAdvancedSettings + toggleAdvancedSettings, + dispatchFetchDownloadClients: fetchDownloadClients }; class EditIndexerModalContentConnector extends Component { @@ -33,6 +40,10 @@ class EditIndexerModalContentConnector extends Component { // // Lifecycle + componentDidMount() { + this.props.dispatchFetchDownloadClients(); + } + componentDidUpdate(prevProps, prevState) { if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { this.props.onModalClose(); @@ -90,7 +101,8 @@ EditIndexerModalContentConnector.propTypes = { toggleAdvancedSettings: PropTypes.func.isRequired, saveIndexer: PropTypes.func.isRequired, testIndexer: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired + onModalClose: PropTypes.func.isRequired, + dispatchFetchDownloadClients: PropTypes.func.isRequired }; export default connect(createMapStateToProps, mapDispatchToProps)(EditIndexerModalContentConnector); diff --git a/frontend/src/Indexer/Index/IndexerIndex.tsx b/frontend/src/Indexer/Index/IndexerIndex.tsx index b5b2fba6a..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,53 +141,53 @@ 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] ); const jumpBarItems = useMemo(() => { - // Reset if not sorting by sortTitle - if (sortKey !== 'sortTitle') { + // Reset if not sorting by sortName + if (sortKey !== 'sortName') { return { order: [], }; } - const characters = items.reduce((acc, item) => { - let char = item.sortTitle.charAt(0); + const characters = items.reduce((acc: Record, item) => { + let char = item.sortName.charAt(0); - if (!isNaN(char)) { + if (!isNaN(Number(char))) { char = '#'; } @@ -190,7 +219,7 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => { return ( - + { label={ isSelectMode ? translate('StopSelecting') - : translate('SelectIndexer') + : translate('SelectIndexers') } iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK} isSelectMode={isSelectMode} @@ -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 3d241428a..0793af82d 100644 --- a/frontend/src/Indexer/Index/Select/Delete/DeleteIndexerModalContent.tsx +++ b/frontend/src/Indexer/Index/Select/Delete/DeleteIndexerModalContent.tsx @@ -7,8 +7,10 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { kinds } from 'Helpers/Props'; -import { bulkDeleteIndexers } from 'Store/Actions/indexerIndexActions'; +import Indexer from 'Indexer/Indexer'; +import { bulkDeleteIndexers } from 'Store/Actions/indexerActions'; import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector'; +import translate from 'Utilities/String/translate'; import styles from './DeleteIndexerModalContent.css'; interface DeleteIndexerModalContentProps { @@ -19,21 +21,21 @@ interface DeleteIndexerModalContentProps { function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) { const { indexerIds, onModalClose } = props; - const allIndexer = useSelector(createAllIndexersSelector()); + const allIndexers: Indexer[] = useSelector(createAllIndexersSelector()); const dispatch = useDispatch(); - const indexers = useMemo(() => { - const indexers = indexerIds.map((id) => { - return allIndexer.find((s) => s.id === id); - }); + const indexers = useMemo((): Indexer[] => { + const indexerList = indexerIds.map((id) => { + return allIndexers.find((s) => s.id === id); + }) as Indexer[]; - return orderBy(indexers, ['sortTitle']); - }, [indexerIds, allIndexer]); + return orderBy(indexerList, ['sortName']); + }, [indexerIds, allIndexers]); const onDeleteIndexerConfirmed = useCallback(() => { dispatch( bulkDeleteIndexers({ - indexerIds, + ids: indexerIds, }) ); @@ -42,17 +44,19 @@ function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) { return ( - Delete Selected Indexer + {translate('DeleteSelectedIndexers')}
- {`Are you sure you want to delete ${indexers.length} selected indexers?`} + {translate('DeleteSelectedIndexersMessageText', { + count: indexers.length, + })}
    {indexers.map((s) => { return ( -
  • +
  • {s.name}
  • ); @@ -61,10 +65,10 @@ function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) { - + diff --git a/frontend/src/Indexer/Index/Select/Edit/EditIndexerModalContent.tsx b/frontend/src/Indexer/Index/Select/Edit/EditIndexerModalContent.tsx index 05ad803b0..9d42aa389 100644 --- a/frontend/src/Indexer/Index/Select/Edit/EditIndexerModalContent.tsx +++ b/frontend/src/Indexer/Index/Select/Edit/EditIndexerModalContent.tsx @@ -7,13 +7,19 @@ 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 { inputTypes } from 'Helpers/Props'; +import { inputTypes, sizes } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import styles from './EditIndexerModalContent.css'; interface SavePayload { enable?: boolean; appProfileId?: number; + priority?: number; + minimumSeeders?: number; + seedRatio?: number; + seedTime?: number; + packSeedTime?: number; + preferMagnetUrl?: boolean; } interface EditIndexerModalContentProps { @@ -25,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) { @@ -35,6 +57,18 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { const [enable, setEnable] = useState(NO_CHANGE); const [appProfileId, setAppProfileId] = useState(NO_CHANGE); + const [priority, setPriority] = useState(null); + const [minimumSeeders, setMinimumSeeders] = useState( + null + ); + const [seedRatio, setSeedRatio] = useState(null); + const [seedTime, setSeedTime] = useState(null); + const [packSeedTime, setPackSeedTime] = useState( + null + ); + const [preferMagnetUrl, setPreferMagnetUrl] = useState< + null | string | boolean + >(null); const save = useCallback(() => { let hasChanges = false; @@ -50,15 +84,56 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { payload.appProfileId = appProfileId as number; } + if (priority !== null) { + hasChanges = true; + payload.priority = priority as number; + } + + if (minimumSeeders !== null) { + hasChanges = true; + payload.minimumSeeders = minimumSeeders as number; + } + + if (seedRatio !== null) { + hasChanges = true; + payload.seedRatio = seedRatio as number; + } + + if (seedTime !== null) { + hasChanges = true; + payload.seedTime = seedTime as number; + } + + if (packSeedTime !== null) { + hasChanges = true; + payload.packSeedTime = packSeedTime as number; + } + + if (preferMagnetUrl !== null) { + hasChanges = true; + payload.preferMagnetUrl = preferMagnetUrl === 'true'; + } + if (hasChanges) { onSavePress(payload); } onModalClose(); - }, [enable, appProfileId, onSavePress, onModalClose]); + }, [ + enable, + appProfileId, + priority, + minimumSeeders, + seedRatio, + seedTime, + packSeedTime, + preferMagnetUrl, + onSavePress, + onModalClose, + ]); const onInputChange = useCallback( - ({ name, value }) => { + ({ name, value }: { name: string; value: string }) => { switch (name) { case 'enable': setEnable(value); @@ -66,8 +141,26 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { case 'appProfileId': setAppProfileId(value); break; + case 'priority': + setPriority(value); + break; + case 'minimumSeeders': + setMinimumSeeders(value); + break; + case 'seedRatio': + setSeedRatio(value); + break; + case 'seedTime': + setSeedTime(value); + break; + case 'packSeedTime': + setPackSeedTime(value); + break; + case 'preferMagnetUrl': + setPreferMagnetUrl(value); + break; default: - console.warn('EditIndexerModalContent Unknown Input'); + console.warn(`EditIndexersModalContent Unknown Input: '${name}'`); } }, [setEnable] @@ -81,10 +174,10 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { return ( - {translate('Edit Selected Indexer')} + {translate('EditSelectedIndexers')} - + {translate('Enable')} - + {translate('SyncProfile')} + + + {translate('IndexerPriority')} + + + + + + {translate('AppsMinimumSeeders')} + + + + + + {translate('SeedRatio')} + + + + + + {translate('SeedTime')} + + + + + + {translate('PackSeedTime')} + + + + + + {translate('PreferMagnetUrl')} + + +
    - {translate('{0} indexers selected', selectedCount.toString())} + {translate('CountIndexersSelected', { count: selectedCount })}
    diff --git a/frontend/src/Indexer/Index/Select/IndexerIndexSelectAllButton.tsx b/frontend/src/Indexer/Index/Select/IndexerIndexSelectAllButton.tsx index 746317dd1..d6fc776d6 100644 --- a/frontend/src/Indexer/Index/Select/IndexerIndexSelectAllButton.tsx +++ b/frontend/src/Indexer/Index/Select/IndexerIndexSelectAllButton.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { SelectActionType, useSelect } from 'App/SelectContext'; +import { useSelect } from 'App/SelectContext'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import { icons } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; @@ -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) { @@ -25,9 +25,7 @@ function IndexerIndexSelectAllButton(props: IndexerIndexSelectAllButtonProps) { const onPress = useCallback(() => { selectDispatch({ - type: allSelected - ? SelectActionType.UnselectAll - : SelectActionType.SelectAll, + type: allSelected ? 'unselectAll' : 'selectAll', }); }, [allSelected, selectDispatch]); diff --git a/frontend/src/Indexer/Index/Select/IndexerIndexSelectAllMenuItem.tsx b/frontend/src/Indexer/Index/Select/IndexerIndexSelectAllMenuItem.tsx index c5dc6981d..f9a52ed30 100644 --- a/frontend/src/Indexer/Index/Select/IndexerIndexSelectAllMenuItem.tsx +++ b/frontend/src/Indexer/Index/Select/IndexerIndexSelectAllMenuItem.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { SelectActionType, useSelect } from 'App/SelectContext'; +import { useSelect } from 'App/SelectContext'; import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem'; import { icons } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; @@ -26,9 +26,7 @@ function IndexerIndexSelectAllMenuItem( const onPressWrapper = useCallback(() => { selectDispatch({ - type: allSelected - ? SelectActionType.UnselectAll - : SelectActionType.SelectAll, + type: allSelected ? 'unselectAll' : 'selectAll', }); }, [allSelected, selectDispatch]); diff --git a/frontend/src/Indexer/Index/Select/IndexerIndexSelectFooter.tsx b/frontend/src/Indexer/Index/Select/IndexerIndexSelectFooter.tsx index 5d9317859..64fe8c1cb 100644 --- a/frontend/src/Indexer/Index/Select/IndexerIndexSelectFooter.tsx +++ b/frontend/src/Indexer/Index/Select/IndexerIndexSelectFooter.tsx @@ -1,11 +1,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { createSelector } from 'reselect'; -import { SelectActionType, useSelect } from 'App/SelectContext'; +import { useSelect } from 'App/SelectContext'; +import AppState from 'App/State/AppState'; import SpinnerButton from 'Components/Link/SpinnerButton'; import PageContentFooter from 'Components/Page/PageContentFooter'; +import usePrevious from 'Helpers/Hooks/usePrevious'; import { kinds } from 'Helpers/Props'; -import { saveIndexerEditor } from 'Store/Actions/indexerIndexActions'; +import { bulkEditIndexers } from 'Store/Actions/indexerActions'; import translate from 'Utilities/String/translate'; import getSelectedIds from 'Utilities/Table/getSelectedIds'; import DeleteIndexerModal from './Delete/DeleteIndexerModal'; @@ -13,8 +15,18 @@ import EditIndexerModal from './Edit/EditIndexerModal'; import TagsModal from './Tags/TagsModal'; import styles from './IndexerIndexSelectFooter.css'; -const seriesEditorSelector = createSelector( - (state) => state.indexers, +interface SavePayload { + enable?: boolean; + appProfileId?: number; + priority?: number; + minimumSeeders?: number; + seedRatio?: number; + seedTime?: number; + packSeedTime?: number; +} + +const indexersEditorSelector = createSelector( + (state: AppState) => state.indexers, (indexers) => { const { isSaving, isDeleting, deleteError } = indexers; @@ -27,8 +39,9 @@ const seriesEditorSelector = createSelector( ); function IndexerIndexSelectFooter() { - const { isSaving, isDeleting, deleteError } = - useSelector(seriesEditorSelector); + const { isSaving, isDeleting, deleteError } = useSelector( + indexersEditorSelector + ); const dispatch = useDispatch(); @@ -37,6 +50,7 @@ function IndexerIndexSelectFooter() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isSavingIndexer, setIsSavingIndexer] = useState(false); const [isSavingTags, setIsSavingTags] = useState(false); + const previousIsDeleting = usePrevious(isDeleting); const [selectState, selectDispatch] = useSelect(); const { selectedState } = selectState; @@ -56,14 +70,14 @@ function IndexerIndexSelectFooter() { }, [setIsEditModalOpen]); const onSavePress = useCallback( - (payload) => { + (payload: SavePayload) => { setIsSavingIndexer(true); setIsEditModalOpen(false); dispatch( - saveIndexerEditor({ + bulkEditIndexers({ ...payload, - indexerIds, + ids: indexerIds, }) ); }, @@ -79,13 +93,13 @@ function IndexerIndexSelectFooter() { }, [setIsTagsModalOpen]); const onApplyTagsPress = useCallback( - (tags, applyTags) => { + (tags: number[], applyTags: string) => { setIsSavingTags(true); setIsTagsModalOpen(false); dispatch( - saveIndexerEditor({ - indexerIds, + bulkEditIndexers({ + ids: indexerIds, tags, applyTags, }) @@ -110,10 +124,10 @@ function IndexerIndexSelectFooter() { }, [isSaving]); useEffect(() => { - if (!isDeleting && !deleteError) { - selectDispatch({ type: SelectActionType.UnselectAll }); + if (previousIsDeleting && !isDeleting && !deleteError) { + selectDispatch({ type: 'unselectAll' }); } - }, [isDeleting, deleteError, selectDispatch]); + }, [previousIsDeleting, isDeleting, deleteError, selectDispatch]); const anySelected = selectedCount > 0; @@ -134,7 +148,7 @@ function IndexerIndexSelectFooter() { isDisabled={!anySelected} onPress={onTagsPress} > - {translate('Set Tags')} + {translate('SetTags')}
@@ -151,7 +165,7 @@ function IndexerIndexSelectFooter() {
- {translate('{0} indexers selected', selectedCount.toString())} + {translate('CountIndexersSelected', { count: selectedCount })}
; onPress: () => void; } @@ -20,7 +20,7 @@ function IndexerIndexSelectModeButton( const onPressWrapper = useCallback(() => { if (isSelectMode) { selectDispatch({ - type: SelectActionType.Reset, + type: 'reset', }); } diff --git a/frontend/src/Indexer/Index/Select/IndexerIndexSelectModeMenuItem.tsx b/frontend/src/Indexer/Index/Select/IndexerIndexSelectModeMenuItem.tsx index f7d63950f..8f1c0623f 100644 --- a/frontend/src/Indexer/Index/Select/IndexerIndexSelectModeMenuItem.tsx +++ b/frontend/src/Indexer/Index/Select/IndexerIndexSelectModeMenuItem.tsx @@ -1,6 +1,6 @@ import { IconDefinition } from '@fortawesome/fontawesome-common-types'; import React, { useCallback } from 'react'; -import { SelectActionType, useSelect } from 'App/SelectContext'; +import { useSelect } from 'App/SelectContext'; import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem'; interface IndexerIndexSelectModeMenuItemProps { @@ -19,7 +19,7 @@ function IndexerIndexSelectModeMenuItem( const onPressWrapper = useCallback(() => { if (isSelectMode) { selectDispatch({ - type: SelectActionType.Reset, + type: 'reset', }); } diff --git a/frontend/src/Indexer/Index/Select/Tags/TagsModalContent.tsx b/frontend/src/Indexer/Index/Select/Tags/TagsModalContent.tsx index de8cb2c60..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] @@ -59,14 +67,14 @@ function TagsModalContent(props: TagsModalContentProps) { }, [tags, applyTags, onApplyTagsPress]); const applyTagsOptions = [ - { key: 'add', value: 'Add' }, - { key: 'remove', value: 'Remove' }, - { key: 'replace', value: 'Replace' }, + { key: 'add', value: translate('Add') }, + { key: 'remove', value: translate('Remove') }, + { key: 'replace', value: translate('Replace') }, ]; return ( - Tags + {translate('Tags')}
@@ -90,10 +98,10 @@ function TagsModalContent(props: TagsModalContentProps) { value={applyTags} values={applyTagsOptions} helpTexts={[ - translate('ApplyTagsHelpTexts1'), - translate('ApplyTagsHelpTexts2'), - translate('ApplyTagsHelpTexts3'), - translate('ApplyTagsHelpTexts4'), + translate('ApplyTagsHelpTextHowToApplyIndexers'), + translate('ApplyTagsHelpTextAdd'), + translate('ApplyTagsHelpTextRemove'), + translate('ApplyTagsHelpTextReplace'), ]} onChange={onApplyTagsChange} /> @@ -119,8 +127,8 @@ function TagsModalContent(props: TagsModalContentProps) { key={tag.id} title={ removeTag - ? translate('RemoveTagRemovingTag') - : translate('RemoveTagExistingTag') + ? translate('RemovingTag') + : translate('ExistingTag') } kind={removeTag ? kinds.INVERSE : kinds.INFO} size={sizes.LARGE} @@ -159,10 +167,10 @@ function TagsModalContent(props: TagsModalContentProps) { - + 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 2dd31f668..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; @@ -19,7 +25,12 @@ .priority, .protocol, -.privacy { +.privacy, +.minimumSeeders, +.seedRatio, +.seedTime, +.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 89c648d39..42821bd74 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexRow.css.d.ts +++ b/frontend/src/Indexer/Index/Table/IndexerIndexRow.css.d.ts @@ -8,9 +8,15 @@ interface CssExports { 'cell': string; 'checkInput': string; 'externalLink': string; + 'id': string; + 'minimumSeeders': string; + 'packSeedTime': string; + 'preferMagnetUrl': string; 'priority': string; 'privacy': string; 'protocol': string; + 'seedRatio': string; + 'seedTime': string; 'sortName': string; 'status': string; 'tags': string; diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexRow.tsx b/frontend/src/Indexer/Index/Table/IndexerIndexRow.tsx index 7bda8a287..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 { SelectActionType, useSelect } from 'App/SelectContext'; -import Label from 'Components/Label'; +import { useSelect } from 'App/SelectContext'; +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 ?? @@ -55,6 +58,27 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { const vipExpiration = fields.find((field) => field.name === 'vipExpiration')?.value ?? ''; + const minimumSeeders = + fields.find( + (field) => field.name === 'torrentBaseSettings.appMinimumSeeders' + )?.value ?? undefined; + + const seedRatio = + fields.find((field) => field.name === 'torrentBaseSettings.seedRatio') + ?.value ?? undefined; + + const seedTime = + fields.find((field) => field.name === 'torrentBaseSettings.seedTime') + ?.value ?? undefined; + + const packSeedTime = + 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( @@ -88,9 +112,9 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { }, []); const onSelectedChange = useCallback( - ({ id, value, shiftKey }) => { + ({ id, value, shiftKey }: SelectStateInputProps) => { selectDispatch({ - type: SelectActionType.ToggleSelected, + type: 'toggleSelected', id, isSelected: value, shiftKey, @@ -132,12 +156,25 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { ); } + if (name === 'id') { + return ( + + + + ); + } + if (name === 'sortName') { return ( ); @@ -146,7 +183,7 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { if (name === 'privacy') { return ( - + ); } @@ -185,7 +222,9 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { if (name === 'added') { return ( - + {minimumSeeders} + + ); + } + + if (name === 'seedRatio') { + return ( + + {seedRatio} + + ); + } + + if (name === 'seedTime') { + return ( + + {seedTime} + + ); + } + + if (name === 'packSeedTime') { + return ( + + {packSeedTime} + + ); + } + + if (name === 'preferMagnetUrl') { + 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,22 +88,20 @@ function IndexerIndexTable(props: IndexerIndexTableProps) { isSelectMode, isSmallScreen, scrollerRef, + onCloneIndexerPress, } = props; const columns = useSelector(columnsSelector); - const { showBanners } = useSelector(selectTableOptions); - const listRef: React.MutableRefObject = useRef(); + 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({ @@ -127,8 +125,8 @@ function IndexerIndexTable(props: IndexerIndexTableProps) { }, [isSmallScreen, windowWidth, windowHeight, scrollerRef, bounds]); useEffect(() => { - const currentScrollListener = isSmallScreen ? window : scrollerRef.current; - const currentScrollerRef = scrollerRef.current; + const currentScrollerRef = scrollerRef.current as HTMLElement; + const currentScrollListener = isSmallScreen ? window : currentScrollerRef; const handleScroll = throttle(() => { const { offsetTop = 0 } = currentScrollerRef; @@ -137,7 +135,7 @@ function IndexerIndexTable(props: IndexerIndexTableProps) { ? getWindowScrollTopPosition() : currentScrollerRef.scrollTop) - offsetTop; - listRef.current.scrollTo(scrollTop); + listRef.current?.scrollTo(scrollTop); }, 10); currentScrollListener.addEventListener('scroll', handleScroll); @@ -166,8 +164,8 @@ function IndexerIndexTable(props: IndexerIndexTableProps) { scrollTop += offset; } - listRef.current.scrollTo(scrollTop); - scrollerRef.current.scrollTo(0, scrollTop); + listRef.current?.scrollTo(scrollTop); + scrollerRef?.current?.scrollTo(0, scrollTop); } } }, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]); @@ -179,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 b9a3454c8..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'; @@ -12,7 +18,12 @@ .priority, .privacy, -.protocol { +.protocol, +.minimumSeeders, +.seedRatio, +.seedTime, +.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 af022da85..020d61f27 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css.d.ts +++ b/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css.d.ts @@ -5,9 +5,15 @@ interface CssExports { 'added': string; 'appProfileId': string; 'capabilities': string; + 'id': string; + 'minimumSeeders': string; + 'packSeedTime': string; + 'preferMagnetUrl': string; 'priority': string; 'privacy': string; 'protocol': string; + 'seedRatio': string; + 'seedTime': string; 'sortName': string; 'status': string; 'tags': string; diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.tsx b/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.tsx index aa231533c..908be76b5 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.tsx +++ b/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import React, { useCallback } from 'react'; import { useDispatch } from 'react-redux'; -import { SelectActionType, useSelect } from 'App/SelectContext'; +import { useSelect } from 'App/SelectContext'; import IconButton from 'Components/Link/IconButton'; import Column from 'Components/Table/Column'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; @@ -14,11 +14,11 @@ import { setIndexerSort, setIndexerTableOption, } from 'Store/Actions/indexerIndexActions'; +import { CheckInputChanged } from 'typings/inputs'; import IndexerIndexTableOptions from './IndexerIndexTableOptions'; import styles from './IndexerIndexTableHeader.css'; interface IndexerIndexTableHeaderProps { - showBanners: boolean; columns: Column[]; sortKey?: string; sortDirection?: SortDirection; @@ -31,23 +31,23 @@ 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 }) => { + ({ value }: CheckInputChanged) => { selectDispatch({ - type: value ? SelectActionType.SelectAll : SelectActionType.UnselectAll, + type: value ? 'selectAll' : 'unselectAll', }); }, [selectDispatch] @@ -92,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 5b26956e3..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 5249e980a..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'; @@ -13,35 +14,31 @@ 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 TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableRow from 'Components/Table/TableRow'; 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, @@ -53,214 +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' ? 'Newznab' : 'Torznab' - } Url`} - - {`${window.location.origin}${window.Prowlarr.urlBase}/${id}/api`} - - {tags.length > 0 ? ( - <> - - {translate('Tags')} - - - - - - ) : null} - -
-
-
-
- - - - {capabilities.searchParams[0]} - - ) - } - /> - { - return ( - - ); - }) - } - /> - { - return ( - - ); - }) - } - /> - { - return ( - - ); - }) - } - /> - { - return ( - - ); - }) - } - /> - -
-
-
- - - - + + + {translate('Details')} + + + + {translate('Categories')} + + + + {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 ( + + ); + }) + : 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 80f5fd17f..000000000 --- a/frontend/src/Indexer/Stats/Stats.js +++ /dev/null @@ -1,261 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -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, - customFilters: PropTypes.arrayOf(PropTypes.object).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/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts new file mode 100644 index 000000000..10c2d3948 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + '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 df06648a2..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: 'Basic Search' }, - { key: 'tvsearch', value: 'TV Search' }, - { key: 'movie', value: 'Movie Search' }, - { key: 'music', value: 'Audio Search' }, - { key: 'book', value: 'Book Search' } + { 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 = [ @@ -94,8 +94,8 @@ class QueryParameterModal extends Component { const newValue = `${start}${tokenValue}${end}`; onSearchInputChange({ name, value: newValue }); - this._selectionStart = newValue.length - 1; - this._selectionEnd = newValue.length - 1; + this._selectionStart = newValue.length; + this._selectionEnd = newValue.length; } }; 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 86f26fc51..872328446 100644 --- a/frontend/src/Search/SearchFooter.js +++ b/frontend/src/Search/SearchFooter.js @@ -24,23 +24,26 @@ class SearchFooter extends Component { super(props, context); const { + defaultSearchQueryParams, defaultIndexerIds, defaultCategories, defaultSearchQuery, - defaultSearchType + defaultSearchType, + defaultSearchLimit, + defaultSearchOffset } = 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: 100, - searchOffset: 0, - newSearch: true + isQueryParameterModalOpen: false, + queryModalOptions: null }; } @@ -55,7 +58,9 @@ class SearchFooter extends Component { this.onSearchPress(); } - this.props.bindShortcut('enter', this.onSearchPress, { isGlobal: true }); + setTimeout(() => { + this.props.bindShortcut('enter', this.onSearchPress, { isGlobal: true }); + }); } componentDidUpdate(prevProps) { @@ -120,7 +125,6 @@ class SearchFooter extends Component { }; onSearchPress = () => { - const { searchLimit, searchOffset, @@ -186,12 +190,13 @@ class SearchFooter extends Component { break; default: icon = icons.SEARCH; + break; } - let footerLabel = `Search ${searchIndexerIds.length === 0 ? 'all' : searchIndexerIds.length} Indexers`; + let footerLabel = searchIndexerIds.length === 0 ? translate('SearchAllIndexers') : translate('SearchCountIndexers', { count: searchIndexerIds.length }); if (isPopulated) { - footerLabel = selectedCount === 0 ? `Found ${itemCount} releases` : `Selected ${selectedCount} of ${itemCount} releases`; + footerLabel = selectedCount === 0 ? translate('FoundCountReleases', { itemCount }) : translate('SelectedCountOfCountReleases', { selectedCount, itemCount }); } return ( @@ -207,7 +212,11 @@ class SearchFooter extends Component { name="searchQuery" value={searchQuery} buttons={ - + @@ -256,11 +265,10 @@ class SearchFooter extends Component { />
- { isPopulated && state.releases, - (releases) => { + (state) => state.router.location, + (releases, location) => { const { searchQuery: defaultSearchQuery, searchIndexerIds: defaultIndexerIds, searchCategories: defaultCategories, - searchType: defaultSearchType + searchType: defaultSearchType, + searchLimit: defaultSearchLimit, + searchOffset: defaultSearchOffset } = releases.defaults; + const { params } = parseUrl(location.search); + const defaultSearchQueryParams = {}; + + if (params.query && !defaultSearchQuery) { + defaultSearchQueryParams.searchQuery = params.query; + } + + if (params.indexerIds && !defaultIndexerIds.length) { + defaultSearchQueryParams.searchIndexerIds = params.indexerIds.split(',').map((id) => Number(id)).filter(Boolean); + } + + if (params.categories && !defaultCategories.length) { + defaultSearchQueryParams.searchCategories = params.categories.split(',').map((id) => Number(id)).filter(Boolean); + } + + if (params.type && defaultSearchType === 'search') { + defaultSearchQueryParams.searchType = params.type; + } + + if (params.limit && defaultSearchLimit === 100 && !isNaN(params.limit)) { + defaultSearchQueryParams.searchLimit = Number(params.limit); + } + + if (params.offset && !defaultSearchOffset && !isNaN(params.offset)) { + defaultSearchQueryParams.searchOffset = Number(params.offset); + } + return { + defaultSearchQueryParams, defaultSearchQuery, defaultIndexerIds, defaultCategories, - defaultSearchType + defaultSearchType, + defaultSearchLimit, + defaultSearchOffset }; } ); @@ -32,6 +66,16 @@ const mapDispatchToProps = { class SearchFooterConnector extends Component { + // + // Lifecycle + + componentDidMount() { + // Set defaults from query parameters + Object.entries(this.props.defaultSearchQueryParams).forEach(([name, value]) => { + this.onInputChange({ name, value }); + }); + } + // // Listeners @@ -53,6 +97,7 @@ class SearchFooterConnector extends Component { } SearchFooterConnector.propTypes = { + defaultSearchQueryParams: PropTypes.object.isRequired, setSearchDefault: PropTypes.func.isRequired }; diff --git a/frontend/src/Search/SearchIndex.js b/frontend/src/Search/SearchIndex.js index 012dc48da..d12635070 100644 --- a/frontend/src/Search/SearchIndex.js +++ b/frontend/src/Search/SearchIndex.js @@ -1,6 +1,7 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; @@ -10,7 +11,9 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import { align, icons, sortDirections } from 'Helpers/Props'; +import { align, icons, kinds, sortDirections } from 'Helpers/Props'; +import AddIndexerModal from 'Indexer/Add/AddIndexerModal'; +import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector'; import NoIndexer from 'Indexer/NoIndexer'; import * as keyCodes from 'Utilities/Constants/keyCodes'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; @@ -27,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 { @@ -53,7 +50,9 @@ class SearchIndex extends Component { lastToggled: null, allSelected: false, allUnselected: false, - selectedState: {} + selectedState: {}, + isAddIndexerModalOpen: false, + isEditIndexerModalOpen: false }; } @@ -73,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(); @@ -95,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() { @@ -141,7 +147,7 @@ class SearchIndex extends Component { } = this.props; // Reset if not sorting by sortTitle - if (sortKey !== 'title') { + if (sortKey !== 'sortTitle') { this.setState({ jumpBarItems: { order: [] } }); return; } @@ -149,7 +155,7 @@ class SearchIndex extends Component { const characters = _.reduce(items, (acc, item) => { let char = item.sortTitle.charAt(0); - if (!isNaN(char)) { + if (!isNaN(Number(char))) { char = '#'; } @@ -180,6 +186,22 @@ class SearchIndex extends Component { // // Listeners + onAddIndexerPress = () => { + this.setState({ isAddIndexerModalOpen: true }); + }; + + onAddIndexerModalClose = () => { + this.setState({ isAddIndexerModalOpen: false }); + }; + + onAddIndexerSelectIndexer = () => { + this.setState({ isEditIndexerModalOpen: true }); + }; + + onEditIndexerModalClose = () => { + this.setState({ isEditIndexerModalOpen: false }); + }; + onJumpBarItemPress = (jumpToCharacter) => { this.setState({ jumpToCharacter }); }; @@ -251,17 +273,19 @@ class SearchIndex extends Component { jumpToCharacter, selectedState, allSelected, - allUnselected + allUnselected, + isAddIndexerModalOpen, + isEditIndexerModalOpen } = this.state; const selectedIndexerIds = this.getSelectedIds(); const ViewComponent = getViewComponent(isSmallScreen); const isLoaded = !!(!error && isPopulated && items.length && this.scrollerRef.current); - const hasNoIndexer = !totalItems; + const hasNoSearchResults = !totalItems; return ( - + @@ -290,7 +314,7 @@ class SearchIndex extends Component { selectedFilterKey={selectedFilterKey} filters={filters} customFilters={customFilters} - isDisabled={hasNoIndexer} + isDisabled={hasNoSearchResults} onFilterSelect={onFilterSelect} /> @@ -303,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 } { @@ -336,25 +362,39 @@ 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 80ca3a61d..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 { kinds, tooltipPositions } from 'Helpers/Props'; -import Tooltip from '../../Components/Tooltip/Tooltip'; - -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/AdvancedSettingsButton.js b/frontend/src/Settings/AdvancedSettingsButton.js index b441ce28a..24383bb1e 100644 --- a/frontend/src/Settings/AdvancedSettingsButton.js +++ b/frontend/src/Settings/AdvancedSettingsButton.js @@ -17,7 +17,7 @@ function AdvancedSettingsButton(props) { 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 2fdd57161..00e30cdb7 100644 --- a/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js +++ b/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js @@ -14,13 +14,29 @@ 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 './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) { @@ -38,11 +54,13 @@ function EditApplicationModalContent(props) { onSavePress, onTestPress, onDeleteApplicationPress, + onAdvancedSettingsPress, ...otherProps } = props; const { id, + implementationName, name, syncLevel, tags, @@ -53,7 +71,7 @@ function EditApplicationModalContent(props) { return ( - {`${id ? translate('Edit') : translate('Add')} ${translate('Application')}`} + {id ? translate('EditApplicationImplementation', { implementationName }) : translate('AddApplicationImplementation', { implementationName })} @@ -100,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} /> @@ -112,7 +133,8 @@ function EditApplicationModalContent(props) { @@ -149,6 +171,12 @@ function EditApplicationModalContent(props) { } + + { + this.props.toggleAdvancedSettings(); + }; + // // Render @@ -67,6 +78,7 @@ class EditApplicationModalContentConnector extends Component { onTestPress={this.onTestPress} onInputChange={this.onInputChange} onFieldChange={this.onFieldChange} + onAdvancedSettingsPress={this.onAdvancedSettingsPress} /> ); } @@ -82,7 +94,8 @@ EditApplicationModalContentConnector.propTypes = { setApplicationFieldValue: PropTypes.func, saveApplication: PropTypes.func, testApplication: PropTypes.func, - onModalClose: PropTypes.func.isRequired + onModalClose: PropTypes.func.isRequired, + toggleAdvancedSettings: PropTypes.func.isRequired }; export default connect(createMapStateToProps, mapDispatchToProps)(EditApplicationModalContentConnector); diff --git a/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModal.tsx b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModal.tsx new file mode 100644 index 000000000..1b99f543a --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModal.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageApplicationsEditModalContent from './ManageApplicationsEditModalContent'; + +interface ManageApplicationsEditModalProps { + isOpen: boolean; + applicationIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +function ManageApplicationsEditModal(props: ManageApplicationsEditModalProps) { + const { isOpen, applicationIds, onSavePress, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageApplicationsEditModal; diff --git a/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css @@ -0,0 +1,16 @@ +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + justify-content: space-between; +} + +.selected { + font-weight: bold; +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .modalFooter { + flex-direction: column; + gap: 10px; + } +} diff --git a/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css.d.ts b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css.d.ts new file mode 100644 index 000000000..cbf2d6328 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'modalFooter': string; + 'selected': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx new file mode 100644 index 000000000..57e88a4fe --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx @@ -0,0 +1,130 @@ +import React, { useCallback, useState } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +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 { inputTypes } from 'Helpers/Props'; +import { ApplicationSyncLevel } from 'typings/Application'; +import translate from 'Utilities/String/translate'; +import styles from './ManageApplicationsEditModalContent.css'; + +interface SavePayload { + syncLevel?: ApplicationSyncLevel; +} + +interface ManageApplicationsEditModalContentProps { + applicationIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +const NO_CHANGE = 'noChange'; + +const syncLevelOptions = [ + { + 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( + props: ManageApplicationsEditModalContentProps +) { + const { applicationIds, onSavePress, onModalClose } = props; + + const [syncLevel, setSyncLevel] = useState(NO_CHANGE); + + const save = useCallback(() => { + let hasChanges = false; + const payload: SavePayload = {}; + + if (syncLevel !== NO_CHANGE) { + hasChanges = true; + payload.syncLevel = syncLevel as ApplicationSyncLevel; + } + + if (hasChanges) { + onSavePress(payload); + } + + onModalClose(); + }, [syncLevel, onSavePress, onModalClose]); + + const onInputChange = useCallback( + ({ name, value }: { name: string; value: string }) => { + switch (name) { + case 'syncLevel': + setSyncLevel(value); + break; + default: + console.warn(`EditApplicationsModalContent Unknown Input: '${name}'`); + } + }, + [] + ); + + const selectedCount = applicationIds.length; + + return ( + + {translate('EditSelectedApplications')} + + + + {translate('SyncLevel')} + + + + + + +
+ {translate('CountApplicationsSelected', { count: selectedCount })} +
+ +
+ + + +
+
+
+ ); +} + +export default ManageApplicationsEditModalContent; diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModal.tsx b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModal.tsx new file mode 100644 index 000000000..e0bce2138 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageApplicationsModalContent from './ManageApplicationsModalContent'; + +interface ManageApplicationsModalProps { + isOpen: boolean; + onModalClose(): void; +} + +function ManageApplicationsModal(props: ManageApplicationsModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageApplicationsModal; diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css new file mode 100644 index 000000000..c106388ab --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css @@ -0,0 +1,16 @@ +.leftButtons, +.rightButtons { + display: flex; + flex: 1 0 50%; + flex-wrap: wrap; +} + +.rightButtons { + justify-content: flex-end; +} + +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: 10px; +} \ No newline at end of file diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css.d.ts b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css.d.ts new file mode 100644 index 000000000..7b392fff9 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'deleteButton': string; + 'leftButtons': string; + 'rightButtons': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx new file mode 100644 index 000000000..bb81729f3 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx @@ -0,0 +1,298 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { ApplicationAppState } from 'App/State/SettingsAppState'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +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 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'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import ManageApplicationsEditModal from './Edit/ManageApplicationsEditModal'; +import ManageApplicationsModalRow from './ManageApplicationsModalRow'; +import TagsModal from './Tags/TagsModal'; +import styles from './ManageApplicationsModalContent.css'; + +// TODO: This feels janky to do, but not sure of a better way currently +type OnSelectedChangeCallback = React.ComponentProps< + typeof ManageApplicationsModalRow +>['onSelectedChange']; + +const COLUMNS = [ + { + name: 'name', + label: () => translate('Name'), + isSortable: true, + isVisible: true, + }, + { + name: 'implementation', + label: () => translate('Implementation'), + isSortable: true, + isVisible: true, + }, + { + name: 'syncLevel', + label: () => translate('SyncLevel'), + isSortable: true, + isVisible: true, + }, + { + name: 'tags', + label: () => translate('Tags'), + isSortable: true, + isVisible: true, + }, +]; + +interface ManageApplicationsModalContentProps { + onModalClose(): void; + sortKey?: string; + sortDirection?: SortDirection; +} + +function ManageApplicationsModalContent( + props: ManageApplicationsModalContentProps +) { + const { onModalClose } = props; + + const { + isFetching, + isPopulated, + isDeleting, + isSaving, + error, + items, + sortKey, + sortDirection, + }: ApplicationAppState = useSelector( + createClientSideCollectionSelector('settings.applications') + ); + const dispatch = useDispatch(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isTagsModalOpen, setIsTagsModalOpen] = useState(false); + const [isSavingTags, setIsSavingTags] = useState(false); + + const [selectState, setSelectState] = useSelectState(); + + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds: number[] = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const selectedCount = selectedIds.length; + + const onSortPress = useCallback( + (value: string) => { + dispatch(setManageApplicationsSort({ sortKey: value })); + }, + [dispatch] + ); + + const onDeletePress = useCallback(() => { + setIsDeleteModalOpen(true); + }, [setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, [setIsDeleteModalOpen]); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onConfirmDelete = useCallback(() => { + dispatch(bulkDeleteApplications({ ids: selectedIds })); + setIsDeleteModalOpen(false); + }, [selectedIds, dispatch]); + + const onSavePress = useCallback( + (payload: object) => { + setIsEditModalOpen(false); + + dispatch( + bulkEditApplications({ + ids: selectedIds, + ...payload, + }) + ); + }, + [selectedIds, dispatch] + ); + + const onTagsPress = useCallback(() => { + setIsTagsModalOpen(true); + }, [setIsTagsModalOpen]); + + const onTagsModalClose = useCallback(() => { + setIsTagsModalOpen(false); + }, [setIsTagsModalOpen]); + + const onApplyTagsPress = useCallback( + (tags: number[], applyTags: string) => { + setIsSavingTags(true); + setIsTagsModalOpen(false); + + dispatch( + bulkEditApplications({ + ids: selectedIds, + tags, + applyTags, + }) + ); + }, + [selectedIds, dispatch] + ); + + const onSelectAllChange = useCallback( + ({ value }: SelectStateInputProps) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const onSelectedChange = useCallback( + ({ id, value, shiftKey = false }) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const errorMessage = getErrorMessage( + error, + 'Unable to load download clients.' + ); + const anySelected = selectedCount > 0; + + return ( + + {translate('ManageApplications')} + + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + {isPopulated && !error && !items.length && ( + {translate('NoApplicationsFound')} + )} + + {isPopulated && !!items.length && !isFetching && !isFetching ? ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ) : null} +
+ + +
+ + {translate('Delete')} + + + + {translate('Edit')} + + + + {translate('SetTags')} + +
+ + +
+ + + + + + +
+ ); +} + +export default ManageApplicationsModalContent; diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css new file mode 100644 index 000000000..8c126288c --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css @@ -0,0 +1,8 @@ +.name, +.syncLevel, +.tags, +.implementation { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css.d.ts b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css.d.ts new file mode 100644 index 000000000..cd3e47aae --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css.d.ts @@ -0,0 +1,10 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'implementation': string; + 'name': string; + 'syncLevel': string; + 'tags': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.tsx b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.tsx new file mode 100644 index 000000000..f41997f54 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.tsx @@ -0,0 +1,82 @@ +import React, { useCallback } from 'react'; +import Label from 'Components/Label'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import TagListConnector from 'Components/TagListConnector'; +import { kinds } from 'Helpers/Props'; +import { ApplicationSyncLevel } from 'typings/Application'; +import { SelectStateInputProps } from 'typings/props'; +import translate from 'Utilities/String/translate'; +import styles from './ManageApplicationsModalRow.css'; + +interface ManageApplicationsModalRowProps { + id: number; + name: string; + syncLevel: string; + implementation: string; + tags: number[]; + columns: Column[]; + isSelected?: boolean; + onSelectedChange(result: SelectStateInputProps): void; +} + +function ManageApplicationsModalRow(props: ManageApplicationsModalRowProps) { + const { + id, + isSelected, + name, + syncLevel, + implementation, + tags, + onSelectedChange, + } = props; + + const onSelectedChangeWrapper = useCallback( + (result: SelectStateInputProps) => { + onSelectedChange({ + ...result, + }); + }, + [onSelectedChange] + ); + + return ( + + + + {name} + + + {implementation} + + + + {syncLevel === ApplicationSyncLevel.AddOnly && ( + + )} + + {syncLevel === ApplicationSyncLevel.FullSync && ( + + )} + + {syncLevel === ApplicationSyncLevel.Disabled && ( + + )} + + + + + + + ); +} + +export default ManageApplicationsModalRow; diff --git a/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModal.tsx b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModal.tsx new file mode 100644 index 000000000..2e24d60e8 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModal.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import TagsModalContent from './TagsModalContent'; + +interface TagsModalProps { + isOpen: boolean; + ids: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModal(props: TagsModalProps) { + const { isOpen, onModalClose, ...otherProps } = props; + + return ( + + + + ); +} + +export default TagsModal; diff --git a/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css new file mode 100644 index 000000000..63be9aadd --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css @@ -0,0 +1,12 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.result { + padding-top: 4px; +} diff --git a/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css.d.ts b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css.d.ts new file mode 100644 index 000000000..9b4321dcc --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'message': string; + 'renameIcon': string; + 'result': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.tsx b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.tsx new file mode 100644 index 000000000..11900311e --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.tsx @@ -0,0 +1,183 @@ +import { uniq } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import { ApplicationAppState } from 'App/State/SettingsAppState'; +import { Tag } from 'App/State/TagsAppState'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Label from 'Components/Label'; +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 { inputTypes, kinds, sizes } from 'Helpers/Props'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import Application from 'typings/Application'; +import translate from 'Utilities/String/translate'; +import styles from './TagsModalContent.css'; + +interface TagsModalContentProps { + ids: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModalContent(props: TagsModalContentProps) { + const { ids, onModalClose, onApplyTagsPress } = props; + + const allApplications: ApplicationAppState = useSelector( + (state: AppState) => state.settings.applications + ); + const tagList: Tag[] = useSelector(createTagsSelector()); + + const [tags, setTags] = useState([]); + const [applyTags, setApplyTags] = useState('add'); + + const applicationsTags = useMemo(() => { + const tags = ids.reduce((acc: number[], id) => { + const s = allApplications.items.find((s: Application) => s.id === id); + + if (s) { + acc.push(...s.tags); + } + + return acc; + }, []); + + return uniq(tags); + }, [ids, allApplications]); + + const onTagsChange = useCallback( + ({ value }: { value: number[] }) => { + setTags(value); + }, + [setTags] + ); + + const onApplyTagsChange = useCallback( + ({ value }: { value: string }) => { + setApplyTags(value); + }, + [setApplyTags] + ); + + const onApplyPress = useCallback(() => { + onApplyTagsPress(tags, applyTags); + }, [tags, applyTags, onApplyTagsPress]); + + const applyTagsOptions = [ + { key: 'add', value: translate('Add') }, + { key: 'remove', value: translate('Remove') }, + { key: 'replace', value: translate('Replace') }, + ]; + + return ( + + {translate('Tags')} + + +
+ + {translate('Tags')} + + + + + + {translate('ApplyTags')} + + + + + + {translate('Result')} + +
+ {applicationsTags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + const removeTag = + (applyTags === 'remove' && tags.indexOf(id) > -1) || + (applyTags === 'replace' && tags.indexOf(id) === -1); + + return ( + + ); + })} + + {(applyTags === 'add' || applyTags === 'replace') && + tags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + if (applicationsTags.indexOf(id) > -1) { + return null; + } + + return ( + + ); + })} +
+
+
+
+ + + + + + +
+ ); +} + +export default TagsModalContent; diff --git a/frontend/src/Settings/Development/DevelopmentSettings.js b/frontend/src/Settings/Development/DevelopmentSettings.js index 7c25e2c68..128055ba8 100644 --- a/frontend/src/Settings/Development/DevelopmentSettings.js +++ b/frontend/src/Settings/Development/DevelopmentSettings.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import Alert from 'Components/Alert'; import FieldSet from 'Components/FieldSet'; import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; @@ -8,7 +9,7 @@ import FormLabel from 'Components/Form/FormLabel'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; -import { inputTypes } from 'Helpers/Props'; +import { inputTypes, kinds } from 'Helpers/Props'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import translate from 'Utilities/String/translate'; @@ -49,9 +50,9 @@ class DevelopmentSettings extends Component { { !isFetching && error && -
+ {translate('UnableToLoadDevelopmentSettings')} -
+ } { diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js index 3e060aa5d..5bd284b45 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js +++ b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js @@ -8,6 +8,7 @@ import { icons } from 'Helpers/Props'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import translate from 'Utilities/String/translate'; import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector'; +import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal'; class DownloadClientSettings extends Component { @@ -21,7 +22,8 @@ class DownloadClientSettings extends Component { this.state = { isSaving: false, - hasPendingChanges: false + hasPendingChanges: false, + isManageDownloadClientsOpen: false }; } @@ -36,6 +38,14 @@ class DownloadClientSettings extends Component { this.setState(payload); }; + onManageDownloadClientsPress = () => { + this.setState({ isManageDownloadClientsOpen: true }); + }; + + onManageDownloadClientsModalClose = () => { + this.setState({ isManageDownloadClientsOpen: false }); + }; + onSavePress = () => { if (this._saveCallback) { this._saveCallback(); @@ -53,7 +63,8 @@ class DownloadClientSettings extends Component { const { isSaving, - hasPendingChanges + hasPendingChanges, + isManageDownloadClientsOpen } = this.state; return ( @@ -71,6 +82,12 @@ class DownloadClientSettings extends Component { isSpinning={isTestingAll} onPress={dispatchTestAllDownloadClients} /> + + } onSavePress={this.onSavePress} @@ -78,6 +95,11 @@ class DownloadClientSettings extends Component { + + ); 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 8cea557a9..13b24343d 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js @@ -89,7 +89,7 @@ class DownloadClient extends Component { kind={kinds.DISABLED} outline={true} > - {translate('PrioritySettings', [priority])} + {translate('Priority')}: {priority} }
@@ -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 28554a31c..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 })} @@ -159,7 +161,7 @@ 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/ManageDownloadClientsEditModal.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModal.tsx new file mode 100644 index 000000000..549a091ff --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModal.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageDownloadClientsEditModalContent from './ManageDownloadClientsEditModalContent'; + +interface ManageDownloadClientsEditModalProps { + isOpen: boolean; + downloadClientIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +function ManageDownloadClientsEditModal( + props: ManageDownloadClientsEditModalProps +) { + const { isOpen, downloadClientIds, onSavePress, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageDownloadClientsEditModal; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css @@ -0,0 +1,16 @@ +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + justify-content: space-between; +} + +.selected { + font-weight: bold; +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .modalFooter { + flex-direction: column; + gap: 10px; + } +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css.d.ts b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css.d.ts new file mode 100644 index 000000000..cbf2d6328 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'modalFooter': string; + 'selected': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx new file mode 100644 index 000000000..d18e694c9 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx @@ -0,0 +1,145 @@ +import React, { useCallback, useState } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +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 { inputTypes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './ManageDownloadClientsEditModalContent.css'; + +interface SavePayload { + enable?: boolean; + priority?: number; +} + +interface ManageDownloadClientsEditModalContentProps { + downloadClientIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +const NO_CHANGE = 'noChange'; + +const enableOptions = [ + { + 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( + props: ManageDownloadClientsEditModalContentProps +) { + const { downloadClientIds, onSavePress, onModalClose } = props; + + const [enable, setEnable] = useState(NO_CHANGE); + const [priority, setPriority] = useState(null); + + const save = useCallback(() => { + let hasChanges = false; + const payload: SavePayload = {}; + + if (enable !== NO_CHANGE) { + hasChanges = true; + payload.enable = enable === 'enabled'; + } + + if (priority !== null) { + hasChanges = true; + payload.priority = priority as number; + } + + if (hasChanges) { + onSavePress(payload); + } + + onModalClose(); + }, [enable, priority, onSavePress, onModalClose]); + + const onInputChange = useCallback( + ({ name, value }: { name: string; value: string }) => { + switch (name) { + case 'enable': + setEnable(value); + break; + case 'priority': + setPriority(value); + break; + default: + console.warn( + `EditDownloadClientsModalContent Unknown Input: '${name}'` + ); + } + }, + [] + ); + + const selectedCount = downloadClientIds.length; + + return ( + + {translate('EditSelectedDownloadClients')} + + + + {translate('Enabled')} + + + + + + {translate('ClientPriority')} + + + + + + +
+ {translate('CountDownloadClientsSelected', { count: selectedCount })} +
+ +
+ + + +
+
+
+ ); +} + +export default ManageDownloadClientsEditModalContent; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModal.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModal.tsx new file mode 100644 index 000000000..0302f3544 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageDownloadClientsModalContent from './ManageDownloadClientsModalContent'; + +interface ManageDownloadClientsModalProps { + isOpen: boolean; + onModalClose(): void; +} + +function ManageDownloadClientsModal(props: ManageDownloadClientsModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageDownloadClientsModal; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css new file mode 100644 index 000000000..c106388ab --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css @@ -0,0 +1,16 @@ +.leftButtons, +.rightButtons { + display: flex; + flex: 1 0 50%; + flex-wrap: wrap; +} + +.rightButtons { + justify-content: flex-end; +} + +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: 10px; +} \ No newline at end of file diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css.d.ts b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css.d.ts new file mode 100644 index 000000000..7b392fff9 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'deleteButton': string; + 'leftButtons': string; + 'rightButtons': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx new file mode 100644 index 000000000..fa82d61b9 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx @@ -0,0 +1,256 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { DownloadClientAppState } from 'App/State/SettingsAppState'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +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 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'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import ManageDownloadClientsEditModal from './Edit/ManageDownloadClientsEditModal'; +import ManageDownloadClientsModalRow from './ManageDownloadClientsModalRow'; +import styles from './ManageDownloadClientsModalContent.css'; + +// TODO: This feels janky to do, but not sure of a better way currently +type OnSelectedChangeCallback = React.ComponentProps< + typeof ManageDownloadClientsModalRow +>['onSelectedChange']; + +const COLUMNS = [ + { + name: 'name', + label: () => translate('Name'), + isSortable: true, + isVisible: true, + }, + { + name: 'implementation', + label: () => translate('Implementation'), + isSortable: true, + isVisible: true, + }, + { + name: 'enable', + label: () => translate('Enabled'), + isSortable: true, + isVisible: true, + }, + { + name: 'priority', + label: () => translate('ClientPriority'), + isSortable: true, + isVisible: true, + }, +]; + +interface ManageDownloadClientsModalContentProps { + onModalClose(): void; + sortKey?: string; + sortDirection?: SortDirection; +} + +function ManageDownloadClientsModalContent( + props: ManageDownloadClientsModalContentProps +) { + const { onModalClose } = props; + + const { + isFetching, + isPopulated, + isDeleting, + isSaving, + error, + items, + sortKey, + sortDirection, + }: DownloadClientAppState = useSelector( + createClientSideCollectionSelector('settings.downloadClients') + ); + const dispatch = useDispatch(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + + const [selectState, setSelectState] = useSelectState(); + + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds: number[] = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const selectedCount = selectedIds.length; + + const onSortPress = useCallback( + (value: string) => { + dispatch(setManageDownloadClientsSort({ sortKey: value })); + }, + [dispatch] + ); + + const onDeletePress = useCallback(() => { + setIsDeleteModalOpen(true); + }, [setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, [setIsDeleteModalOpen]); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onConfirmDelete = useCallback(() => { + dispatch(bulkDeleteDownloadClients({ ids: selectedIds })); + setIsDeleteModalOpen(false); + }, [selectedIds, dispatch]); + + const onSavePress = useCallback( + (payload: object) => { + setIsEditModalOpen(false); + + dispatch( + bulkEditDownloadClients({ + ids: selectedIds, + ...payload, + }) + ); + }, + [selectedIds, dispatch] + ); + + const onSelectAllChange = useCallback( + ({ value }: SelectStateInputProps) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const onSelectedChange = useCallback( + ({ id, value, shiftKey = false }) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const errorMessage = getErrorMessage( + error, + 'Unable to load download clients.' + ); + const anySelected = selectedCount > 0; + + return ( + + {translate('ManageDownloadClients')} + + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + {isPopulated && !error && !items.length && ( + {translate('NoDownloadClientsFound')} + )} + + {isPopulated && !!items.length && !isFetching && !isFetching ? ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ) : null} +
+ + +
+ + {translate('Delete')} + + + + {translate('Edit')} + +
+ + +
+ + + + +
+ ); +} + +export default ManageDownloadClientsModalContent; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css new file mode 100644 index 000000000..444f376cc --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css @@ -0,0 +1,8 @@ +.name, +.enable, +.priority, +.implementation { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css.d.ts b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css.d.ts new file mode 100644 index 000000000..6c8cd9c29 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css.d.ts @@ -0,0 +1,10 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'enable': string; + 'implementation': string; + 'name': string; + 'priority': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx new file mode 100644 index 000000000..001bced52 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx @@ -0,0 +1,70 @@ +import React, { useCallback } from 'react'; +import Label from 'Components/Label'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import { kinds } from 'Helpers/Props'; +import { SelectStateInputProps } from 'typings/props'; +import translate from 'Utilities/String/translate'; +import styles from './ManageDownloadClientsModalRow.css'; + +interface ManageDownloadClientsModalRowProps { + id: number; + name: string; + enable: boolean; + priority: number; + implementation: string; + columns: Column[]; + isSelected?: boolean; + onSelectedChange(result: SelectStateInputProps): void; +} + +function ManageDownloadClientsModalRow( + props: ManageDownloadClientsModalRowProps +) { + const { + id, + isSelected, + name, + enable, + priority, + implementation, + onSelectedChange, + } = props; + + const onSelectedChangeWrapper = useCallback( + (result: SelectStateInputProps) => { + onSelectedChange({ + ...result, + }); + }, + [onSelectedChange] + ); + + return ( + + + + {name} + + + {implementation} + + + + + + + {priority} + + ); +} + +export default ManageDownloadClientsModalRow; diff --git a/frontend/src/Settings/General/GeneralSettings.js b/frontend/src/Settings/General/GeneralSettings.js index 38bb08f75..7289ed1c7 100644 --- a/frontend/src/Settings/General/GeneralSettings.js +++ b/frontend/src/Settings/General/GeneralSettings.js @@ -1,6 +1,7 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import Alert from 'Components/Alert'; import Form from 'Components/Form/Form'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import ConfirmModal from 'Components/Modal/ConfirmModal'; @@ -123,9 +124,9 @@ class GeneralSettings extends Component { { !isFetching && error && -
+ {translate('UnableToLoadGeneralSettings')} -
+ } { @@ -155,6 +156,7 @@ class GeneralSettings extends Component { /> 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 83443cd72..d156f4ff3 100644 --- a/frontend/src/Settings/UI/UISettings.js +++ b/frontend/src/Settings/UI/UISettings.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import Alert from 'Components/Alert'; import FieldSet from 'Components/FieldSet'; import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; @@ -8,7 +9,7 @@ import FormLabel from 'Components/Form/FormLabel'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; -import { inputTypes } from 'Helpers/Props'; +import { inputTypes, kinds } from 'Helpers/Props'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import themes from 'Styles/Themes'; import titleCase from 'Utilities/String/titleCase'; @@ -20,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 = [ @@ -80,9 +81,9 @@ class UISettings extends Component { { !isFetching && error && -
+ {translate('UnableToLoadUISettings')} -
+ } { @@ -146,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/createBulkEditItemHandler.js b/frontend/src/Store/Actions/Creators/createBulkEditItemHandler.js new file mode 100644 index 000000000..f174dae54 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createBulkEditItemHandler.js @@ -0,0 +1,54 @@ +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set, updateItem } from '../baseActions'; + +function createBulkEditItemHandler(section, url) { + return function(getState, payload, dispatch) { + + dispatch(set({ section, isSaving: true })); + + const ajaxOptions = { + url: `${url}`, + method: 'PUT', + data: JSON.stringify(payload), + dataType: 'json' + }; + + const promise = createAjaxRequest(ajaxOptions).request; + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isSaving: false, + saveError: null + }), + + ...data.map((provider) => { + + const { + ...propsToUpdate + } = provider; + + return updateItem({ + id: provider.id, + section, + ...propsToUpdate + }); + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + + return promise; + }; +} + +export default createBulkEditItemHandler; diff --git a/frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js new file mode 100644 index 000000000..3293ff1b5 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js @@ -0,0 +1,48 @@ +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { removeItem, set } from '../baseActions'; + +function createBulkRemoveItemHandler(section, url) { + return function(getState, payload, dispatch) { + const { + ids + } = payload; + + dispatch(set({ section, isDeleting: true })); + + const ajaxOptions = { + url: `${url}`, + method: 'DELETE', + data: JSON.stringify(payload), + dataType: 'json' + }; + + const promise = createAjaxRequest(ajaxOptions).request; + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isDeleting: false, + deleteError: null + }), + + ...ids.map((id) => { + return removeItem({ section, id }); + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + + return promise; + }; +} + +export default createBulkRemoveItemHandler; 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/createSaveProviderHandler.js b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js index 5761655d2..1cccf1666 100644 --- a/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js +++ b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js @@ -32,9 +32,9 @@ function createSaveProviderHandler(section, url, options = {}) { const params = { ...queryParams }; // If the user is re-saving the same provider without changes - // force it to be saved. Only applies to editing existing providers. + // force it to be saved. - if (id && _.isEqual(saveData, lastSaveData)) { + if (_.isEqual(saveData, lastSaveData)) { params.forceSave = true; } 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 70f8a8961..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 @@ -52,14 +53,14 @@ export default { isFetching: false, isPopulated: false, error: null, - isDeleting: false, - deleteError: null, isSchemaFetching: false, isSchemaPopulated: false, schemaError: null, schema: {}, isSaving: false, saveError: null, + isDeleting: false, + deleteError: null, items: [], pendingChanges: {} }, @@ -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 a670732e0..53a008b0c 100644 --- a/frontend/src/Store/Actions/Settings/applications.js +++ b/frontend/src/Store/Actions/Settings/applications.js @@ -1,10 +1,14 @@ 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'; import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; 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'; @@ -28,7 +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 @@ -43,6 +50,9 @@ export const deleteApplication = createThunk(DELETE_APPLICATION); export const testApplication = createThunk(TEST_APPLICATION); 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 { @@ -77,10 +87,19 @@ export default { selectedSchema: {}, isSaving: false, saveError: null, + isDeleting: false, + deleteError: null, isTesting: false, isTestingAll: false, items: [], - pendingChanges: {} + pendingChanges: {}, + sortKey: 'name', + sortDirection: sortDirections.ASCENDING, + sortPredicates: { + name: function(item) { + return item.name.toLowerCase(); + } + } }, // @@ -95,7 +114,9 @@ export default { [DELETE_APPLICATION]: createRemoveItemHandler(section, '/applications'), [TEST_APPLICATION]: createTestProviderHandler(section, '/applications'), [CANCEL_TEST_APPLICATION]: createCancelTestProviderHandler(section), - [TEST_ALL_APPLICATIONS]: createTestAllProvidersHandler(section, '/applications') + [TEST_ALL_APPLICATIONS]: createTestAllProvidersHandler(section, '/applications'), + [BULK_EDIT_APPLICATIONS]: createBulkEditItemHandler(section, '/applications/bulk'), + [BULK_DELETE_APPLICATIONS]: createBulkRemoveItemHandler(section, '/applications/bulk') }, // @@ -107,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/downloadClientCategories.js b/frontend/src/Store/Actions/Settings/downloadClientCategories.js index b9fb04404..38cce33c5 100644 --- a/frontend/src/Store/Actions/Settings/downloadClientCategories.js +++ b/frontend/src/Store/Actions/Settings/downloadClientCategories.js @@ -75,6 +75,8 @@ export default { selectedSchema: {}, isSaving: false, saveError: null, + isDeleting: false, + deleteError: null, items: [], pendingChanges: {} }, diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js index 7e9292f24..56784d5d0 100644 --- a/frontend/src/Store/Actions/Settings/downloadClients.js +++ b/frontend/src/Store/Actions/Settings/downloadClients.js @@ -1,10 +1,14 @@ 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'; import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; 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,6 +34,9 @@ export const DELETE_DOWNLOAD_CLIENT = 'settings/downloadClients/deleteDownloadCl export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient'; export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient'; 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 @@ -44,6 +51,9 @@ export const deleteDownloadClient = createThunk(DELETE_DOWNLOAD_CLIENT); export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT); 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 { @@ -78,10 +88,19 @@ export default { selectedSchema: {}, isSaving: false, saveError: null, + isDeleting: false, + deleteError: null, isTesting: false, isTestingAll: false, items: [], - pendingChanges: {} + pendingChanges: {}, + sortKey: 'name', + sortDirection: sortDirections.ASCENDING, + sortPredicates: { + name: function(item) { + return item.name.toLowerCase(); + } + } }, // @@ -120,7 +139,9 @@ export default { }, [CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section), - [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient') + [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient'), + [BULK_EDIT_DOWNLOAD_CLIENTS]: createBulkEditItemHandler(section, '/downloadclient/bulk'), + [BULK_DELETE_DOWNLOAD_CLIENTS]: createBulkRemoveItemHandler(section, '/downloadclient/bulk') }, // @@ -132,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 6ba5c731b..6c07540be 100644 --- a/frontend/src/Store/Actions/Settings/indexerProxies.js +++ b/frontend/src/Store/Actions/Settings/indexerProxies.js @@ -74,6 +74,8 @@ export default { selectedSchema: {}, isSaving: false, saveError: null, + isDeleting: false, + deleteError: null, isTesting: false, items: [], pendingChanges: {} @@ -102,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 3242cef4b..28346e9a6 100644 --- a/frontend/src/Store/Actions/Settings/notifications.js +++ b/frontend/src/Store/Actions/Settings/notifications.js @@ -74,6 +74,8 @@ export default { selectedSchema: {}, isSaving: false, saveError: null, + isDeleting: false, + deleteError: null, isTesting: false, items: [], pendingChanges: {} @@ -102,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 7ad498ba0..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,61 +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'), 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 } @@ -95,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', @@ -111,7 +123,7 @@ export const defaultState = { }, { key: 'indexerRss', - label: translate('IndexerRss'), + label: () => translate('IndexerRss'), filters: [ { key: 'eventType', @@ -122,7 +134,7 @@ export const defaultState = { }, { key: 'indexerQuery', - label: translate('IndexerQuery'), + label: () => translate('IndexerQuery'), filters: [ { key: 'eventType', @@ -133,7 +145,7 @@ export const defaultState = { }, { key: 'indexerAuth', - label: translate('IndexerAuth'), + label: () => translate('IndexerAuth'), filters: [ { key: 'eventType', @@ -144,7 +156,7 @@ export const defaultState = { }, { key: 'failed', - label: translate('Failed'), + label: () => translate('Failed'), filters: [ { key: 'successful', @@ -153,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 a30d6a73a..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'; @@ -13,7 +17,10 @@ import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate'; import getSectionState from 'Utilities/State/getSectionState'; import updateSectionState from 'Utilities/State/updateSectionState'; 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'; // @@ -29,6 +36,8 @@ export const defaultState = { isFetching: false, isPopulated: false, error: null, + isDeleting: false, + deleteError: null, selectedSchema: {}, isSaving: false, saveError: null, @@ -41,7 +50,7 @@ export const defaultState = { isFetching: false, isPopulated: false, error: null, - sortKey: 'name', + sortKey: 'sortName', sortDirection: sortDirections.ASCENDING, items: [] } @@ -50,7 +59,7 @@ export const defaultState = { export const filters = [ { key: 'all', - label: translate('All'), + label: () => translate('All'), filters: [] } ]; @@ -65,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; } }; @@ -84,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'; @@ -93,6 +156,8 @@ export const DELETE_INDEXER = 'indexers/deleteIndexer'; export const TEST_INDEXER = 'indexers/testIndexer'; export const CANCEL_TEST_INDEXER = 'indexers/cancelTestIndexer'; export const TEST_ALL_INDEXERS = 'indexers/testAllIndexers'; +export const BULK_EDIT_INDEXERS = 'indexers/bulkEditIndexers'; +export const BULK_DELETE_INDEXERS = 'indexers/bulkDeleteIndexers'; // // Action Creators @@ -101,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); @@ -109,6 +175,8 @@ export const deleteIndexer = createThunk(DELETE_INDEXER); export const testIndexer = createThunk(TEST_INDEXER); export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); +export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS); +export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS); export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { return { @@ -161,7 +229,9 @@ export const actionHandlers = handleThunks({ [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'), [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'), [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section), - [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer') + [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer'), + [BULK_EDIT_INDEXERS]: createBulkEditItemHandler(section, '/indexer/bulk'), + [BULK_DELETE_INDEXERS]: createBulkRemoveItemHandler(section, '/indexer/bulk') }); // @@ -174,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); @@ -191,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 cb0d0b480..a002d9b41 100644 --- a/frontend/src/Store/Actions/indexerIndexActions.js +++ b/frontend/src/Store/Actions/indexerIndexActions.js @@ -1,10 +1,6 @@ import { createAction } from 'redux-actions'; -import { batchActions } from 'redux-batched-actions'; import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; -import { createThunk, handleThunks } from 'Store/thunks'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; import translate from 'Utilities/String/translate'; -import { removeItem, set, updateItem } from './baseActions'; import createHandleActions from './Creators/createHandleActions'; import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; @@ -36,69 +32,105 @@ export const defaultState = { columns: [ { name: 'status', - columnLabel: translate('ReleaseStatus'), + 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'), + isSortable: true, + isVisible: false + }, + { + name: 'seedRatio', + label: () => translate('SeedRatio'), + isSortable: true, + isVisible: false + }, + { + name: 'seedTime', + label: () => translate('SeedTime'), + isSortable: true, + isVisible: false + }, + { + name: '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 } @@ -116,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 } @@ -186,8 +224,6 @@ export const SET_INDEXER_SORT = 'indexerIndex/setIndexerSort'; export const SET_INDEXER_FILTER = 'indexerIndex/setIndexerFilter'; export const SET_INDEXER_VIEW = 'indexerIndex/setIndexerView'; export const SET_INDEXER_TABLE_OPTION = 'indexerIndex/setIndexerTableOption'; -export const SAVE_INDEXER_EDITOR = 'indexerIndex/saveIndexerEditor'; -export const BULK_DELETE_INDEXERS = 'indexerIndex/bulkDeleteIndexers'; // // Action Creators @@ -196,89 +232,6 @@ export const setIndexerSort = createAction(SET_INDEXER_SORT); export const setIndexerFilter = createAction(SET_INDEXER_FILTER); export const setIndexerView = createAction(SET_INDEXER_VIEW); export const setIndexerTableOption = createAction(SET_INDEXER_TABLE_OPTION); -export const saveIndexerEditor = createThunk(SAVE_INDEXER_EDITOR); -export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS); - -// -// Action Handlers - -export const actionHandlers = handleThunks({ - [SAVE_INDEXER_EDITOR]: function(getState, payload, dispatch) { - dispatch(set({ - section, - isSaving: true - })); - - const promise = createAjaxRequest({ - url: '/indexer/editor', - method: 'PUT', - data: JSON.stringify(payload), - dataType: 'json' - }).request; - - promise.done((data) => { - dispatch(batchActions([ - ...data.map((indexer) => { - return updateItem({ - id: indexer.id, - section: 'indexers', - ...indexer - }); - }), - - set({ - section, - isSaving: false, - saveError: null - }) - ])); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isSaving: false, - saveError: xhr - })); - }); - }, - - [BULK_DELETE_INDEXERS]: function(getState, payload, dispatch) { - dispatch(set({ - section, - isDeleting: true - })); - - const promise = createAjaxRequest({ - url: '/indexer/editor', - method: 'DELETE', - data: JSON.stringify(payload), - dataType: 'json' - }).request; - - promise.done(() => { - dispatch(batchActions([ - ...payload.indexerIds.map((id) => { - return removeItem({ section: 'indexers', id }); - }), - - set({ - section, - isDeleting: false, - deleteError: null - }) - ])); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isDeleting: false, - deleteError: xhr - })); - }); - } -}); // // Reducers 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 336c9add8..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'; @@ -31,16 +33,18 @@ export const defaultState = { error: null, grabError: null, items: [], - sortKey: 'title', + sortKey: 'age', sortDirection: sortDirections.ASCENDING, - secondarySortKey: 'title', + secondarySortKey: 'sortTitle', secondarySortDirection: sortDirections.ASCENDING, defaults: { searchType: 'search', searchQuery: '', searchIndexerIds: [], - searchCategories: [] + searchCategories: [], + searchLimit: 100, + searchOffset: 0 }, columns: [ @@ -54,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 } @@ -156,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 } ], @@ -348,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 @@ -379,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/createIndexerClientSideCollectionItemsSelector.js b/frontend/src/Store/Selectors/createIndexerClientSideCollectionItemsSelector.js index 931bddf23..c0edaa6dd 100644 --- a/frontend/src/Store/Selectors/createIndexerClientSideCollectionItemsSelector.js +++ b/frontend/src/Store/Selectors/createIndexerClientSideCollectionItemsSelector.js @@ -9,12 +9,12 @@ function createUnoptimizedSelector(uiSection) { const items = indexers.items.map((s) => { const { id, - name + sortName } = s; return { id, - sortTitle: name + sortName }; }); @@ -38,7 +38,7 @@ const createMovieEqualSelector = createSelectorCreator( function createIndexerClientSideCollectionItemsSelector(uiSection) { return createMovieEqualSelector( createUnoptimizedSelector(uiSection), - (movies) => movies + (indexers) => indexers ); } 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/createReleaseClientSideCollectionItemsSelector.js b/frontend/src/Store/Selectors/createReleaseClientSideCollectionItemsSelector.js index c76ba4236..4bc195aa5 100644 --- a/frontend/src/Store/Selectors/createReleaseClientSideCollectionItemsSelector.js +++ b/frontend/src/Store/Selectors/createReleaseClientSideCollectionItemsSelector.js @@ -9,13 +9,13 @@ function createUnoptimizedSelector(uiSection) { const items = releases.items.map((s) => { const { guid, - title, + sortTitle, indexerId } = s; return { guid, - sortTitle: title, + sortTitle, indexerId }; }); @@ -40,7 +40,7 @@ const createMovieEqualSelector = createSelectorCreator( function createReleaseClientSideCollectionItemsSelector(uiSection) { return createMovieEqualSelector( createUnoptimizedSelector(uiSection), - (movies) => movies + (releases) => releases ); } 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 fa93d7c68..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', @@ -162,7 +162,7 @@ module.exports = { inputHoverBackgroundColor: 'rgba(255, 255, 255, 0.20)', inputSelectedBackgroundColor: 'rgba(255, 255, 255, 0.05)', advancedFormLabelColor: '#ff902b', - disabledCheckInputColor: '#ddd', + disabledCheckInputColor: '#999', disabledInputColor: '#808080', // @@ -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 7a5e399d0..ede2f97f6 100644 --- a/frontend/src/System/Backup/Backups.js +++ b/frontend/src/System/Backup/Backups.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; @@ -8,7 +9,7 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; -import { icons } from 'Helpers/Props'; +import { icons, kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import BackupRow from './BackupRow'; import RestoreBackupModalConnector from './RestoreBackupModalConnector'; @@ -20,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 }, { @@ -107,16 +108,16 @@ class Backups extends Component { { !isFetching && !!error && -
- {translate('UnableToLoadBackups')} -
+ + {translate('BackupsLoadError')} + } { noBackups && -
+ {translate('NoBackupsAreAvailable')} -
+ } { 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 c17ec1e6c..000000000 --- a/frontend/src/System/Updates/Updates.js +++ /dev/null @@ -1,251 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component, Fragment } from 'react'; -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/getIndexOfFirstCharacter.js b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js index 5cbb30085..a0dbc4d0d 100644 --- a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js +++ b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js @@ -1,9 +1,9 @@ export default function getIndexOfFirstCharacter(items, character) { return items.findIndex((item) => { - const firstCharacter = item.sortTitle.charAt(0); + const firstCharacter = 'sortName' in item ? item.sortName.charAt(0) : item.sortTitle.charAt(0); if (character === '#') { - return !isNaN(firstCharacter); + return !isNaN(Number(firstCharacter)); } return firstCharacter === character; 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 2858014d0..000000000 --- a/frontend/src/Utilities/String/translate.js +++ /dev/null @@ -1,30 +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 = '') { - if (args) { - const translatedKey = translate(key); - return translatedKey.replace(/\{(\d+)\}/g, (match, index) => { - return args[index]; - }); - } - - return translations[key] || key; -} 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/Application.ts b/frontend/src/typings/Application.ts new file mode 100644 index 000000000..650429475 --- /dev/null +++ b/frontend/src/typings/Application.ts @@ -0,0 +1,19 @@ +import ModelBase from 'App/ModelBase'; + +export enum ApplicationSyncLevel { + Disabled = 'disabled', + AddOnly = 'addOnly', + FullSync = 'fullSync', +} + +interface Application extends ModelBase { + name: string; + syncLevel: ApplicationSyncLevel; + implementationName: string; + implementation: string; + configContract: string; + infoLink: string; + tags: number[]; +} + +export default Application; diff --git a/frontend/src/typings/DownloadClient.ts b/frontend/src/typings/DownloadClient.ts new file mode 100644 index 000000000..45af9eb32 --- /dev/null +++ b/frontend/src/typings/DownloadClient.ts @@ -0,0 +1,26 @@ +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 DownloadClient extends ModelBase { + enable: boolean; + protocol: string; + priority: number; + name: string; + fields: Field[]; + implementationName: string; + implementation: string; + configContract: string; + infoLink: string; + tags: number[]; +} + +export default DownloadClient; 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/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 new file mode 100644 index 000000000..63ea906c4 --- /dev/null +++ b/frontend/src/typings/Notification.ts @@ -0,0 +1,33 @@ +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 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; + configContract: string; + infoLink: string; + tags: number[]; +} + +export default Notification; 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/Settings/UiSettings.ts b/frontend/src/typings/Settings/UiSettings.ts new file mode 100644 index 000000000..656c4518b --- /dev/null +++ b/frontend/src/typings/Settings/UiSettings.ts @@ -0,0 +1,7 @@ +export default interface UiSettings { + theme: 'auto' | 'dark' | 'light'; + showRelativeDates: boolean; + shortDateFormat: string; + longDateFormat: string; + timeFormat: 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/src/typings/props.ts b/frontend/src/typings/props.ts new file mode 100644 index 000000000..5b87e36b3 --- /dev/null +++ b/frontend/src/typings/props.ts @@ -0,0 +1,5 @@ +export interface SelectStateInputProps { + id: number; + value: boolean; + shiftKey: 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 9d8d54aff..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,84 +64,82 @@ "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.22.3", - "react-window": "1.8.8", + "react-virtualized": "9.21.1", + "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.21.8", - "@babel/eslint-parser": "7.21.8", - "@babel/plugin-proposal-class-properties": "7.18.6", - "@babel/plugin-proposal-export-default-from": "7.18.10", - "@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.21.5", - "@babel/preset-react": "7.18.6", - "@babel/preset-typescript": "7.21.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.2", + "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.30.2", + "core-js": "3.39.0", "css-loader": "6.7.3", "css-modules-typescript-loader": "4.0.1", - "eslint": "8.40.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.8", - "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.82.1", - "webpack-cli": "5.1.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 cbabf416f..ce3672c38 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -30,6 +30,13 @@ false true + + + true + + $(NoWarn);CS1591 @@ -92,20 +99,52 @@ $(MSBuildProjectName.Replace('Prowlarr','NzbDrone')) - - - false + + + + + + + + + + + + + + + + + + true + + + + true + + true - - + + - + + + + + + + false + + @@ -137,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/AutomationTest.cs b/src/NzbDrone.Automation.Test/AutomationTest.cs index 4253095b9..ae1bcc5e1 100644 --- a/src/NzbDrone.Automation.Test/AutomationTest.cs +++ b/src/NzbDrone.Automation.Test/AutomationTest.cs @@ -67,7 +67,7 @@ namespace NzbDrone.Automation.Test { try { - Screenshot image = ((ITakesScreenshot)driver).GetScreenshot(); + var image = ((ITakesScreenshot)driver).GetScreenshot(); image.SaveAsFile($"./{name}_test_screenshot.png", ScreenshotImageFormat.Png); } catch (Exception ex) diff --git a/src/NzbDrone.Automation.Test/PageModel/PageBase.cs b/src/NzbDrone.Automation.Test/PageModel/PageBase.cs index 4c9a17581..02078c47e 100644 --- a/src/NzbDrone.Automation.Test/PageModel/PageBase.cs +++ b/src/NzbDrone.Automation.Test/PageModel/PageBase.cs @@ -36,9 +36,13 @@ namespace NzbDrone.Automation.Test.PageModel { try { - IWebElement element = d.FindElement(By.ClassName("followingBalls")); + 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/CacheTests/CachedFixture.cs b/src/NzbDrone.Common.Test/CacheTests/CachedFixture.cs index 7c892047d..bdbd45aca 100644 --- a/src/NzbDrone.Common.Test/CacheTests/CachedFixture.cs +++ b/src/NzbDrone.Common.Test/CacheTests/CachedFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using FluentAssertions; using NUnit.Framework; @@ -65,9 +65,9 @@ namespace NzbDrone.Common.Test.CacheTests [Test] public void should_store_null() { - int hitCount = 0; + var hitCount = 0; - for (int i = 0; i < 10; i++) + for (var i = 0; i < 10; i++) { _cachedString.Get("key", () => { @@ -83,10 +83,10 @@ namespace NzbDrone.Common.Test.CacheTests [Platform(Exclude = "MacOsX")] public void should_honor_ttl() { - int hitCount = 0; + var hitCount = 0; _cachedString = new Cached(); - for (int i = 0; i < 10; i++) + for (var i = 0; i < 10; i++) { _cachedString.Get("key", () => diff --git a/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs b/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs index 91564beb3..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 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] @@ -142,7 +164,7 @@ namespace NzbDrone.Common.Test [Test] public void SaveDictionary_should_save_proper_value() { - int port = 20555; + var port = 20555; var dic = Subject.GetConfigDictionary(); dic["Port"] = 20555; @@ -155,9 +177,9 @@ namespace NzbDrone.Common.Test [Test] public void SaveDictionary_should_only_save_specified_values() { - int port = 20555; - int origSslPort = 20551; - int sslPort = 20552; + var port = 20555; + var origSslPort = 20551; + var sslPort = 20552; var dic = Subject.GetConfigDictionary(); dic["Port"] = port; diff --git a/src/NzbDrone.Common.Test/DiskTests/DirectoryLookupServiceFixture.cs b/src/NzbDrone.Common.Test/DiskTests/DirectoryLookupServiceFixture.cs index 78aa99f7d..dd27f6f1b 100644 --- a/src/NzbDrone.Common.Test/DiskTests/DirectoryLookupServiceFixture.cs +++ b/src/NzbDrone.Common.Test/DiskTests/DirectoryLookupServiceFixture.cs @@ -42,33 +42,33 @@ namespace NzbDrone.Common.Test.DiskTests [Test] public void should_not_contain_recycling_bin_for_root_of_drive() { - string root = @"C:\".AsOsAgnostic(); + var root = @"C:\".AsOsAgnostic(); SetupFolders(root); Mocker.GetMock() .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] public void should_not_contain_system_volume_information() { - string root = @"C:\".AsOsAgnostic(); + var root = @"C:\".AsOsAgnostic(); SetupFolders(root); Mocker.GetMock() .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] public void should_not_contain_recycling_bin_or_system_volume_information_for_root_of_drive() { - string root = @"C:\".AsOsAgnostic(); + var root = @"C:\".AsOsAgnostic(); SetupFolders(root); Mocker.GetMock() 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 9b48e77ca..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() { @@ -788,7 +798,7 @@ namespace NzbDrone.Common.Test.Http try { // the date is bad in the below - should be 13-Jul-2026 - string malformedCookie = @"__cfduid=d29e686a9d65800021c66faca0a29b4261436890790; expires=Mon, 13-Jul-26 16:19:50 GMT; path=/; HttpOnly"; + var malformedCookie = @"__cfduid=d29e686a9d65800021c66faca0a29b4261436890790; expires=Mon, 13-Jul-26 16:19:50 GMT; path=/; HttpOnly"; var requestSet = new HttpRequestBuilder($"https://{_httpBinHost}/response-headers") .AddQueryParam("Set-Cookie", malformedCookie) .Build(); @@ -822,7 +832,7 @@ namespace NzbDrone.Common.Test.Http { try { - string url = $"https://{_httpBinHost}/response-headers?Set-Cookie={Uri.EscapeDataString(malformedCookie)}"; + var url = $"https://{_httpBinHost}/response-headers?Set-Cookie={Uri.EscapeDataString(malformedCookie)}"; var requestSet = new HttpRequest(url); requestSet.AllowAutoRedirect = false; diff --git a/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs b/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs index 8f2b4a4eb..9e2b31d87 100644 --- a/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs +++ b/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs @@ -10,7 +10,9 @@ namespace NzbDrone.Common.Test.InstrumentationTests // Indexer Urls [TestCase(@"https://iptorrents.com/torrents/rss?u=mySecret;tp=mySecret;l5;download")] [TestCase(@"http://rss.torrentleech.org/mySecret")] - [TestCase(@"http://rss.torrentleech.org/rss/download/12345/01233210/filename.torrent")] + [TestCase(@"https://rss24h.torrentleech.org/mySecret")] + [TestCase(@"http://rss.torrentleech.org/rss/download/12345/01233210/file.name-RLSGRP.torrent")] + [TestCase(@"https://www.torrentleech.org/rss/download/12345/01233210/file.name-RLSGRP.torrent")] [TestCase(@"http://www.bitmetv.org/rss.php?uid=mySecret&passkey=mySecret")] [TestCase(@"https://rss.omgwtfnzbs.org/rss-search.php?catid=19,20&user=sonarr&api=mySecret&eng=1")] [TestCase(@"https://dognzb.cr/fetch/2b51db35e1912ffc138825a12b9933d2/2b51db35e1910123321025a12b9933d2")] @@ -27,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 @@ -41,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")] @@ -79,13 +85,22 @@ namespace NzbDrone.Common.Test.InstrumentationTests // Deluge [TestCase(@",{""download_location"": ""C:\Users\\mySecret mySecret\\Downloads""}")] [TestCase(@",{""download_location"": ""/home/mySecret/Downloads""}")] + [TestCase(@",{""download_location"": ""/Users/mySecret/Downloads""}")] [TestCase(@"auth.login(""mySecret"")")] // 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"",")] @@ -99,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 2dee4d822..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) @@ -74,17 +76,17 @@ namespace NzbDrone.Common continue; // Ignore directories } - string entryFileName = zipEntry.Name; + var entryFileName = zipEntry.Name; // to remove the folder from the entry:- entryFileName = Path.GetFileName(entryFileName); // Optionally match entrynames against a selection list here to skip as desired. // The unpacked length is available in the zipEntry.Size property. - byte[] buffer = new byte[4096]; // 4K is optimum - Stream zipStream = zipFile.GetInputStream(zipEntry); + var buffer = new byte[4096]; // 4K is optimum + var zipStream = zipFile.GetInputStream(zipEntry); // Manipulate the output filename here as desired. - string fullZipToPath = Path.Combine(destination, entryFileName); - string directoryName = Path.GetDirectoryName(fullZipToPath); + var fullZipToPath = Path.Combine(destination, entryFileName); + var directoryName = Path.GetDirectoryName(fullZipToPath); if (directoryName.Length > 0) { Directory.CreateDirectory(directoryName); @@ -93,7 +95,7 @@ namespace NzbDrone.Common // Unzip file in buffered chunks. This is just as fast as unpacking to a buffer the full size // of the file, but does not waste memory. // The "using" will close the stream even if an exception occurs. - using (FileStream streamWriter = File.Create(fullZipToPath)) + using (var streamWriter = File.Create(fullZipToPath)) { StreamUtils.Copy(zipStream, streamWriter, buffer); } @@ -106,7 +108,7 @@ namespace NzbDrone.Common Stream inStream = File.OpenRead(compressedFile); Stream gzipStream = new GZipInputStream(inStream); - TarArchive tarArchive = TarArchive.CreateInputTarArchive(gzipStream, null); + var tarArchive = TarArchive.CreateInputTarArchive(gzipStream, null); tarArchive.ExtractContents(destination); tarArchive.Close(); diff --git a/src/NzbDrone.Common/Cache/Cached.cs b/src/NzbDrone.Common/Cache/Cached.cs index a530baa55..42463f682 100644 --- a/src/NzbDrone.Common/Cache/Cached.cs +++ b/src/NzbDrone.Common/Cache/Cached.cs @@ -47,8 +47,7 @@ namespace NzbDrone.Common.Cache public T Find(string key) { - CacheItem cacheItem; - if (!_store.TryGetValue(key, out cacheItem)) + if (!_store.TryGetValue(key, out var cacheItem)) { return default(T); } @@ -76,8 +75,7 @@ namespace NzbDrone.Common.Cache public void Remove(string key) { - CacheItem value; - _store.TryRemove(key, out value); + _store.TryRemove(key, out _); } public int Count => _store.Count; @@ -88,9 +86,7 @@ namespace NzbDrone.Common.Cache lifeTime = lifeTime ?? _defaultLifeTime; - CacheItem cacheItem; - - if (_store.TryGetValue(key, out cacheItem) && !cacheItem.IsExpired()) + if (_store.TryGetValue(key, out var cacheItem) && !cacheItem.IsExpired()) { if (_rollingExpiry && lifeTime.HasValue) { diff --git a/src/NzbDrone.Common/Cache/CachedDictionary.cs b/src/NzbDrone.Common/Cache/CachedDictionary.cs index 6332f5054..922b45835 100644 --- a/src/NzbDrone.Common/Cache/CachedDictionary.cs +++ b/src/NzbDrone.Common/Cache/CachedDictionary.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; @@ -86,9 +86,7 @@ namespace NzbDrone.Common.Cache { RefreshIfExpired(); - TValue result; - - if (!_items.TryGetValue(key, out result)) + if (!_items.TryGetValue(key, out var result)) { throw new KeyNotFoundException(string.Format("Item {0} not found in cache.", key)); } @@ -100,9 +98,7 @@ namespace NzbDrone.Common.Cache { RefreshIfExpired(); - TValue result; - - _items.TryGetValue(key, out result); + _items.TryGetValue(key, out var result); return result; } @@ -128,8 +124,7 @@ namespace NzbDrone.Common.Cache public void Remove(string key) { - TValue item; - _items.TryRemove(key, out item); + _items.TryRemove(key, out _); } } } 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 8d8dcf2f9..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; @@ -255,7 +357,7 @@ namespace NzbDrone.Common.Disk var stringComparison = (Kind == OsPathKind.Windows || other.Kind == OsPathKind.Windows) ? StringComparison.InvariantCultureIgnoreCase : StringComparison.InvariantCulture; - for (int i = 0; i < leftFragments.Length; i++) + for (var i = 0; i < leftFragments.Length; i++) { if (!string.Equals(leftFragments[i], rightFragments[i], stringComparison)) { @@ -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) { @@ -372,12 +479,12 @@ namespace NzbDrone.Common.Disk var newFragments = new List(); - for (int j = i; j < rightFragments.Length; j++) + for (var j = i; j < rightFragments.Length; j++) { newFragments.Add(".."); } - for (int j = i; j < leftFragments.Length; j++) + for (var j = i; j < leftFragments.Length; j++) { newFragments.Add(leftFragments[j]); } 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/DateTimeExtensions.cs b/src/NzbDrone.Common/Extensions/DateTimeExtensions.cs index 57f8aac71..69e2e5e17 100644 --- a/src/NzbDrone.Common/Extensions/DateTimeExtensions.cs +++ b/src/NzbDrone.Common/Extensions/DateTimeExtensions.cs @@ -36,14 +36,14 @@ namespace NzbDrone.Common.Extensions public static bool IsValidDate(this string dateTime) { - DateTime.TryParse(dateTime, out DateTime result); + DateTime.TryParse(dateTime, out var result); return !result.Equals(default(DateTime)); } public static bool IsFutureDate(this string dateTime) { - DateTime.TryParse(dateTime, out DateTime result); + DateTime.TryParse(dateTime, out var result); return !result.Equals(default(DateTime)) && result.After(DateTime.Now); } 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/IEnumerableExtensions.cs b/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs index d18e58e51..9e5385593 100644 --- a/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs +++ b/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs @@ -126,9 +126,9 @@ namespace NzbDrone.Common.Extensions private static IEnumerable InternalDropLast(IEnumerable source, int n) { - Queue buffer = new Queue(n + 1); + var buffer = new Queue(n + 1); - foreach (T x in source) + foreach (var x in source) { buffer.Enqueue(x); 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/LevenstheinExtensions.cs b/src/NzbDrone.Common/Extensions/LevenstheinExtensions.cs index 825525457..eb20ce7b0 100644 --- a/src/NzbDrone.Common/Extensions/LevenstheinExtensions.cs +++ b/src/NzbDrone.Common/Extensions/LevenstheinExtensions.cs @@ -21,7 +21,7 @@ namespace NzbDrone.Common.Extensions return text.Length * costDelete; } - int[] matrix = new int[other.Length + 1]; + var matrix = new int[other.Length + 1]; for (var i = 1; i < matrix.Length; i++) { @@ -30,13 +30,13 @@ namespace NzbDrone.Common.Extensions for (var i = 0; i < text.Length; i++) { - int topLeft = matrix[0]; + var topLeft = matrix[0]; matrix[0] = matrix[0] + costDelete; for (var j = 0; j < other.Length; j++) { - int top = matrix[j]; - int left = matrix[j + 1]; + var top = matrix[j]; + var left = matrix[j + 1]; var sumIns = top + costInsert; var sumDel = left + costDelete; 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/StringExtensions.cs b/src/NzbDrone.Common/Extensions/StringExtensions.cs index 495bfc6ac..b36f81c40 100644 --- a/src/NzbDrone.Common/Extensions/StringExtensions.cs +++ b/src/NzbDrone.Common/Extensions/StringExtensions.cs @@ -198,13 +198,13 @@ namespace NzbDrone.Common.Extensions public static string CleanFileName(this string name) { - string result = name; + var result = name; string[] badCharacters = { "\\", "/", "<", ">", "?", "*", ":", "|", "\"" }; string[] goodCharacters = { "+", "+", "", "", "!", "-", "-", "", "" }; result = result.Replace(": ", " - "); - for (int i = 0; i < badCharacters.Length; i++) + for (var i = 0; i < badCharacters.Length; i++) { result = result.Replace(badCharacters[i], goodCharacters[i]); } diff --git a/src/NzbDrone.Common/Extensions/TryParseExtensions.cs b/src/NzbDrone.Common/Extensions/TryParseExtensions.cs index 1ed79c319..3b9f3db49 100644 --- a/src/NzbDrone.Common/Extensions/TryParseExtensions.cs +++ b/src/NzbDrone.Common/Extensions/TryParseExtensions.cs @@ -6,9 +6,7 @@ namespace NzbDrone.Common.Extensions { public static int? ParseInt32(this string source) { - int result; - - if (int.TryParse(source, out result)) + if (int.TryParse(source, out var result)) { return result; } @@ -18,9 +16,7 @@ namespace NzbDrone.Common.Extensions public static long? ParseInt64(this string source) { - long result; - - if (long.TryParse(source, out result)) + if (long.TryParse(source, out var result)) { return result; } @@ -30,9 +26,7 @@ namespace NzbDrone.Common.Extensions public static double? ParseDouble(this string source) { - double result; - - if (double.TryParse(source.Replace(',', '.'), NumberStyles.Number, CultureInfo.InvariantCulture, out result)) + if (double.TryParse(source.Replace(',', '.'), NumberStyles.Number, CultureInfo.InvariantCulture, out var result)) { return result; } 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/HashUtil.cs b/src/NzbDrone.Common/HashUtil.cs index 3c9144023..372b875a0 100644 --- a/src/NzbDrone.Common/HashUtil.cs +++ b/src/NzbDrone.Common/HashUtil.cs @@ -8,9 +8,9 @@ namespace NzbDrone.Common { public static string CalculateCrc(string input) { - uint mCrc = 0xffffffff; - byte[] bytes = Encoding.UTF8.GetBytes(input); - foreach (byte myByte in bytes) + var mCrc = 0xffffffff; + var bytes = Encoding.UTF8.GetBytes(input); + foreach (var myByte in bytes) { mCrc ^= (uint)myByte << 24; for (var i = 0; i < 8; i++) diff --git a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs index 098e47288..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,11 +48,17 @@ 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) { - var requestMessage = new HttpRequestMessage(request.Method, (Uri)request.Url); + var requestMessage = new HttpRequestMessage(request.Method, (Uri)request.Url) + { + Version = HttpVersion.Version20, + VersionPolicy = HttpVersionPolicy.RequestVersionOrLower + }; requestMessage.Headers.UserAgent.ParseAdd(_userAgentBuilder.GetUserAgent(request.UseSimplifiedUserAgent)); requestMessage.Headers.ConnectionClose = !request.ConnectionKeepAlive; @@ -103,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()); - - CookieContainer 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); + } + catch (OperationCanceledException ex) when (cts.IsCancellationRequested) + { + throw new WebException("Http request timed out", ex.InnerException, WebExceptionStatus.Timeout, null); } } @@ -186,6 +205,8 @@ namespace NzbDrone.Common.Http.Dispatchers var client = new System.Net.Http.HttpClient(handler) { + DefaultRequestVersion = HttpVersion.Version20, + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower, Timeout = Timeout.InfiniteTimeSpan }; @@ -262,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. @@ -285,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 ffbe8f4d1..326c30b04 100644 --- a/src/NzbDrone.Common/Http/HttpClient.cs +++ b/src/NzbDrone.Common/Http/HttpClient.cs @@ -91,6 +91,7 @@ namespace NzbDrone.Common.Http { request.Method = HttpMethod.Get; request.ContentData = null; + request.ContentSummary = null; } // Save to add to final response @@ -108,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))) @@ -220,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); } } } @@ -259,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/HttpResponse.cs b/src/NzbDrone.Common/Http/HttpResponse.cs index a6c9f7f86..310955224 100644 --- a/src/NzbDrone.Common/Http/HttpResponse.cs +++ b/src/NzbDrone.Common/Http/HttpResponse.cs @@ -9,9 +9,9 @@ namespace NzbDrone.Common.Http { public class HttpResponse { - private static readonly Regex RegexRefresh = new Regex("^(.*?url)=(.*?)(?:;|$)", RegexOptions.Compiled); + private static readonly Regex RegexRefresh = new ("^(.*?url)=(.*?)(?:;|$)", RegexOptions.Compiled); - public HttpResponse(HttpRequest request, HttpHeader headers, CookieCollection cookies, byte[] binaryData, long elapsedTime = 0, HttpStatusCode statusCode = HttpStatusCode.OK) + public HttpResponse(HttpRequest request, HttpHeader headers, CookieCollection cookies, byte[] binaryData, long elapsedTime = 0, HttpStatusCode statusCode = HttpStatusCode.OK, Version version = null) { Request = request; Headers = headers; @@ -19,9 +19,10 @@ namespace NzbDrone.Common.Http ResponseData = binaryData; StatusCode = statusCode; ElapsedTime = elapsedTime; + Version = version; } - public HttpResponse(HttpRequest request, HttpHeader headers, CookieCollection cookies, string content, long elapsedTime = 0, HttpStatusCode statusCode = HttpStatusCode.OK) + public HttpResponse(HttpRequest request, HttpHeader headers, CookieCollection cookies, string content, long elapsedTime = 0, HttpStatusCode statusCode = HttpStatusCode.OK, Version version = null) { Request = request; Headers = headers; @@ -30,6 +31,7 @@ namespace NzbDrone.Common.Http _content = content; StatusCode = statusCode; ElapsedTime = elapsedTime; + Version = version; } public HttpRequest Request { get; private set; } @@ -37,6 +39,7 @@ namespace NzbDrone.Common.Http public CookieCollection Cookies { get; private set; } public HttpStatusCode StatusCode { get; private set; } public long ElapsedTime { get; private set; } + public Version Version { get; private set; } public byte[] ResponseData { get; private set; } private string _content; @@ -63,6 +66,8 @@ namespace NzbDrone.Common.Http public bool HasHttpError => (int)StatusCode >= 400; + public bool HasHttpServerError => (int)StatusCode >= 500; + public bool HasHttpRedirect => StatusCode == HttpStatusCode.Moved || StatusCode == HttpStatusCode.Found || StatusCode == HttpStatusCode.SeeOther || @@ -119,7 +124,7 @@ namespace NzbDrone.Common.Http public override string ToString() { - var result = string.Format("Res: [{0}] {1}: {2}.{3} ({4} bytes)", Request.Method, Request.Url, (int)StatusCode, StatusCode, ResponseData?.Length ?? 0); + var result = $"Res: HTTP/{Version} [{Request.Method}] {Request.Url}: {(int)StatusCode}.{StatusCode} ({ResponseData?.Length ?? 0} bytes)"; if (HasHttpError && Headers.ContentType.IsNotNullOrWhiteSpace() && !Headers.ContentType.Equals("text/html", StringComparison.InvariantCultureIgnoreCase)) { @@ -134,7 +139,7 @@ namespace NzbDrone.Common.Http where T : new() { public HttpResponse(HttpResponse response) - : base(response.Request, response.Headers, response.Cookies, response.ResponseData, response.ElapsedTime, response.StatusCode) + : base(response.Request, response.Headers, response.Cookies, response.ResponseData, response.ElapsedTime, response.StatusCode, response.Version) { Resource = Json.Deserialize(response.Content); } diff --git a/src/NzbDrone.Common/Http/HttpUri.cs b/src/NzbDrone.Common/Http/HttpUri.cs index 45d2b98b5..2277ed60d 100644 --- a/src/NzbDrone.Common/Http/HttpUri.cs +++ b/src/NzbDrone.Common/Http/HttpUri.cs @@ -22,7 +22,7 @@ namespace NzbDrone.Common.Http public HttpUri(string scheme, string host, int? port, string path, string query, string fragment) { - StringBuilder builder = new StringBuilder(); + var builder = new StringBuilder(); if (scheme.IsNotNullOrWhiteSpace()) { diff --git a/src/NzbDrone.Common/Http/Proxy/HttpProxySettings.cs b/src/NzbDrone.Common/Http/Proxy/HttpProxySettings.cs index ef59c9c0f..c80044d29 100644 --- a/src/NzbDrone.Common/Http/Proxy/HttpProxySettings.cs +++ b/src/NzbDrone.Common/Http/Proxy/HttpProxySettings.cs @@ -30,8 +30,9 @@ namespace NzbDrone.Common.Http.Proxy { if (!string.IsNullOrWhiteSpace(BypassFilter)) { - var hostlist = BypassFilter.Split(','); - for (int i = 0; i < hostlist.Length; i++) + 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 fc74a68e0..393d6613a 100644 --- a/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs +++ b/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs @@ -9,61 +9,65 @@ namespace NzbDrone.Common.Instrumentation { private static readonly Regex[] CleansingRules = { - // Url - new Regex(@"(?<=[?&: ;])(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 Regex(@"(?<=[?& ;])[^=]*?(_?(?[^&=]+?)(?= |&|$|;)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"rss\.torrentleech\.org/(?!rss)(?[0-9a-z]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"rss\.torrentleech\.org/rss/download/[0-9]+/(?[0-9a-z]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"iptorrents\.com/[/a-z0-9?&;]*?(?:[?&;](u|tp)=(?[^&=;]+?))+(?= |;|&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"/fetch/[a-z0-9]{32}/(?[a-z0-9]{32})", RegexOptions.Compiled), - new Regex(@"getnzb.*?(?<=\?|&)(r)=(?[^&=]+?)(?= |&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"\b(\w*)?(_?(?[^&=]+?)(?= |&|$|;)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"(?<=authkey = "")(?[^&=]+?)(?="")", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"(?<=beyond-hd\.[a-z]+/api/torrents/)(?[^&=][a-z0-9]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"(?<=beyond-hd\.[a-z]+/torrent/download/[\w\d-]+[.]\d+[.])(?[a-z0-9]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + // Url + 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), + new (@"iptorrents\.com/[/a-z0-9?&;]*?(?:[?&;](u|tp)=(?[^&=;]+?))+(?= |;|&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new (@"/fetch/[a-z0-9]{32}/(?[a-z0-9]{32})", RegexOptions.Compiled), + new (@"getnzb.*?(?<=\?|&)(r)=(?[^&=]+?)(?= |&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new (@"\b(\w*)?(_?(?[^&=]+?)(?= |&|$|;)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + 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 Regex(@"(?<=[a-z0-9-]+\.[a-z]+/torrent/download/\d+\.)(?[^&=][a-z0-9]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + // UNIT3D + new (@"(?<=[a-z0-9-]+\.[a-z]+/torrent/download/\d+\.)(?[^&=][a-z0-9]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - // Path - new Regex(@"""C:\\Users\\(?[^\""]+?)(\\|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"""/home/(?[^/""]+?)(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + // Path + new (@"""C:\\Users\\(?[^\""]+?)(\\|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new (@"""/(home|Users)/(?[^/""]+?)(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - // Trackers Announce Keys; Designed for Qbit Json; should work for all in theory - new Regex(@"announce(\.php)?(/|%2f|%3fpasskey%3d)(?[a-z0-9]{16,})|(?[a-z0-9]{16,})(/|%2f)announce"), + // 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", RegexOptions.Compiled | RegexOptions.IgnoreCase), - // NzbGet - new Regex(@"""Name""\s*:\s*""[^""]*(username|password)""\s*,\s*""Value""\s*:\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), + // NzbGet + new (@"""Name""\s*:\s*""[^""]*(username|password)""\s*,\s*""Value""\s*:\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), - // Sabnzbd - new Regex(@"""[^""]*(username|password|api_?key|nzb_key)""\s*:\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"""email_(account|to|from|pwd)""\s*:\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), + // Sabnzbd + new (@"""[^""]*(username|password|api_?key|nzb_key)""\s*:\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new (@"""email_(account|to|from|pwd)""\s*:\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), - // uTorrent - new Regex(@"\[""[a-z._]*(username|password)"",\d,""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"\[""(boss_key|boss_key_salt|proxy\.proxy)"",\d,""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), + // uTorrent + new (@"\[""[a-z._]*(username|password)"",\d,""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new (@"\[""(boss_key|boss_key_salt|proxy\.proxy)"",\d,""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), - // Deluge - new Regex(@"auth.login\(""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), + // Deluge + new (@"auth.login\(""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), - // BroadcastheNet (;torrent_pass|torrents_notify_ is for MTV) - new Regex(@"""?method""?\s*:\s*""(getTorrents)"",\s*""?params""?\s*:\s*\[\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"getTorrents\(""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"(?<=\?|&|;|=)(authkey|torrent_pass|torrents_notify)[_=](?[^&=]+?)(?=""|&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + // BroadcastheNet (;torrent_pass|torrents_notify_ is for MTV) + new (@"""?method""?\s*:\s*""(getTorrents)"",\s*""?params""?\s*:\s*\[\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new (@"getTorrents\(""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new (@"(?<=\?|&|;|=)(authkey|torrent_pass|torrents_notify)[_=](?[^&=]+?)(?=""|&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - // Plex - new Regex(@"(?<=\?|&)(X-Plex-Client-Identifier|X-Plex-Token)=(?[^&=]+?)(?= |&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + // Plex + new (@"(?<=\?|&)(X-Plex-Client-Identifier|X-Plex-Token)=(?[^&=]+?)(?= |&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - // Indexer Responses - new Regex(@"(?:avistaz|exoticaz|cinemaz|privatehd)\.[a-z]{2,3}/rss/download/(?[^&=]+?)/(?[^&=]+?)\.torrent", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"(?:animebytes)\.[a-z]{2,3}/torrent/[0-9]+/download/(?[^&=]+?)[""]", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@",""info_hash"":""(?[^&=]+?)"",", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"""token"":""(?[^&=]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@",""pass[- _]?key"":""(?[^&=]+?)"",", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@",""rss[- _]?key"":""(?[^&=]+?)"",", RegexOptions.Compiled | RegexOptions.IgnoreCase), + // 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|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 Regex(@"(?:Auth-\w+(? + { + var value = m.Value; + foreach (var capture in m.Groups["secret"].Captures.OfType().Reverse()) { - var value = m.Value; - foreach (var capture in m.Groups["secret"].Captures.OfType().Reverse()) - { - value = value.Replace(capture.Index - m.Index, capture.Length, "(removed)"); - } + value = value.Replace(capture.Index - m.Index, capture.Length, "(removed)"); + } - return value; - }); + return value; + }); } message = CleanseRemoteIPRegex.Replace(message, CleanseRemoteIP); diff --git a/src/NzbDrone.Common/Instrumentation/CleansingClefLogLayout.cs b/src/NzbDrone.Common/Instrumentation/CleansingClefLogLayout.cs new file mode 100644 index 000000000..f110b96ac --- /dev/null +++ b/src/NzbDrone.Common/Instrumentation/CleansingClefLogLayout.cs @@ -0,0 +1,21 @@ +using System.Text; +using NLog; +using NLog.Layouts.ClefJsonLayout; +using NzbDrone.Common.EnvironmentInfo; + +namespace NzbDrone.Common.Instrumentation; + +public class CleansingClefLogLayout : CompactJsonLayout +{ + protected override void RenderFormattedMessage(LogEventInfo logEvent, StringBuilder target) + { + base.RenderFormattedMessage(logEvent, target); + + if (RuntimeInfo.IsProduction) + { + var result = CleanseLogMessage.Cleanse(target.ToString()); + target.Clear(); + target.Append(result); + } + } +} diff --git a/src/NzbDrone.Common/Instrumentation/CleansingConsoleLogLayout.cs b/src/NzbDrone.Common/Instrumentation/CleansingConsoleLogLayout.cs new file mode 100644 index 000000000..f894a4df5 --- /dev/null +++ b/src/NzbDrone.Common/Instrumentation/CleansingConsoleLogLayout.cs @@ -0,0 +1,26 @@ +using System.Text; +using NLog; +using NLog.Layouts; +using NzbDrone.Common.EnvironmentInfo; + +namespace NzbDrone.Common.Instrumentation; + +public class CleansingConsoleLogLayout : SimpleLayout +{ + public CleansingConsoleLogLayout(string format) + : base(format) + { + } + + protected override void RenderFormattedMessage(LogEventInfo logEvent, StringBuilder target) + { + base.RenderFormattedMessage(logEvent, target); + + if (RuntimeInfo.IsProduction) + { + var result = CleanseLogMessage.Cleanse(target.ToString()); + target.Clear(); + target.Append(result); + } + } +} diff --git a/src/NzbDrone.Common/Instrumentation/NzbDroneFileTarget.cs b/src/NzbDrone.Common/Instrumentation/CleansingFileTarget.cs similarity index 87% rename from src/NzbDrone.Common/Instrumentation/NzbDroneFileTarget.cs rename to src/NzbDrone.Common/Instrumentation/CleansingFileTarget.cs index 84658cf74..f74d1fca4 100644 --- a/src/NzbDrone.Common/Instrumentation/NzbDroneFileTarget.cs +++ b/src/NzbDrone.Common/Instrumentation/CleansingFileTarget.cs @@ -4,7 +4,7 @@ using NLog.Targets; namespace NzbDrone.Common.Instrumentation { - public class NzbDroneFileTarget : FileTarget + public class CleansingFileTarget : FileTarget { protected override void RenderFormattedMessage(LogEventInfo logEvent, StringBuilder target) { diff --git a/src/NzbDrone.Common/Instrumentation/CleansingJsonVisitor.cs b/src/NzbDrone.Common/Instrumentation/CleansingJsonVisitor.cs index 1e32d399f..34df2dff3 100644 --- a/src/NzbDrone.Common/Instrumentation/CleansingJsonVisitor.cs +++ b/src/NzbDrone.Common/Instrumentation/CleansingJsonVisitor.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Linq; using NzbDrone.Common.Serializer; namespace NzbDrone.Common.Instrumentation @@ -16,7 +16,7 @@ namespace NzbDrone.Common.Instrumentation } } - foreach (JToken token in json) + foreach (var token in json) { Visit(token); } diff --git a/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs b/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs index 7d8cfaf83..d9fdd5b25 100644 --- a/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs +++ b/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs @@ -12,7 +12,11 @@ namespace NzbDrone.Common.Instrumentation { public static class NzbDroneLogger { - private const string FILE_LOG_LAYOUT = @"${date:format=yyyy-MM-dd HH\:mm\:ss.f}|${level}|${logger}|${message}${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}${exception:format=Data}${newline}}"; + private const string FileLogLayout = @"${date:format=yyyy-MM-dd HH\:mm\:ss.f}|${level}|${logger}|${message}${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}${exception:format=Data}${newline}}"; + private const string ConsoleFormat = "[${level}] ${logger}: ${message} ${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}${exception:format=Data}${newline}}"; + + private static readonly CleansingConsoleLogLayout CleansingConsoleLayout = new (ConsoleFormat); + private static readonly CleansingClefLogLayout ClefLogLayout = new (); private static bool _isConfigured; @@ -41,7 +45,7 @@ namespace NzbDrone.Common.Instrumentation RegisterDebugger(); } - RegisterSentry(updateApp); + RegisterSentry(updateApp, appFolderInfo); if (updateApp) { @@ -62,7 +66,7 @@ namespace NzbDrone.Common.Instrumentation LogManager.ReconfigExistingLoggers(); } - private static void RegisterSentry(bool updateClient) + private static void RegisterSentry(bool updateClient, IAppFolderInfo appFolderInfo) { string dsn; @@ -73,11 +77,11 @@ namespace NzbDrone.Common.Instrumentation else { dsn = RuntimeInfo.IsProduction - ? "https://d62a0313c35f4afc932b4a20e1072793@sentry.servarr.com/27" + ? "https://a1fa00bd1d60465ebd9aca58c5a22d00@sentry.servarr.com/27" : "https://e38306161ff945999adf774a16e933c3@sentry.servarr.com/30"; } - var target = new SentryTarget(dsn) + var target = new SentryTarget(dsn, appFolderInfo) { Name = "sentryTarget", Layout = "${message}" @@ -94,7 +98,7 @@ namespace NzbDrone.Common.Instrumentation private static void RegisterDebugger() { - DebuggerTarget target = new DebuggerTarget(); + var target = new DebuggerTarget(); target.Name = "debuggerLogger"; target.Layout = "[${level}] [${threadid}] ${logger}: ${message} ${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}${exception:format=Data}${newline}}"; @@ -104,16 +108,6 @@ namespace NzbDrone.Common.Instrumentation LogManager.Configuration.LoggingRules.Add(loggingRule); } - private static void RegisterGlobalFilters() - { - LogManager.Setup().LoadConfiguration(c => - { - c.ForLogger("Microsoft.Hosting.Lifetime*").WriteToNil(LogLevel.Info); - c.ForLogger("System*").WriteToNil(LogLevel.Warn); - c.ForLogger("Microsoft*").WriteToNil(LogLevel.Warn); - }); - } - private static void RegisterConsole() { var level = LogLevel.Trace; @@ -121,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); @@ -138,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); @@ -147,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); @@ -170,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); @@ -195,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.", "")); @@ -204,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 7b20142e5..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,17 +106,41 @@ 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 => { o.Dsn = dsn; o.AttachStacktrace = true; o.MaxBreadcrumbs = 200; - o.Release = BuildInfo.Release; - o.BeforeSend = x => SentryCleanser.CleanseEvent(x); - o.BeforeBreadcrumb = x => SentryCleanser.CleanseBreadcrumb(x); + o.Release = $"{BuildInfo.AppName}@{BuildInfo.Release}"; + 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/OAuth/OAuthRequest.cs b/src/NzbDrone.Common/OAuth/OAuthRequest.cs index cdf7d9e1f..ceb9b9117 100644 --- a/src/NzbDrone.Common/OAuth/OAuthRequest.cs +++ b/src/NzbDrone.Common/OAuth/OAuthRequest.cs @@ -29,7 +29,7 @@ namespace NzbDrone.Common.OAuth public virtual string Version { get; set; } public virtual string SessionHandle { get; set; } - /// + /// public virtual string RequestUrl { get; set; } #if !WINRT 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 ab7d7abb4..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) { @@ -330,16 +333,7 @@ namespace NzbDrone.Common.Processes private List GetProcessesByName(string name) { - //TODO: move this to an OS specific class - var monoProcesses = Process.GetProcessesByName("mono") - .Union(Process.GetProcessesByName("mono-sgen")) - .Where(process => - process.Modules.Cast() - .Any(module => - module.ModuleName.ToLower() == name.ToLower() + ".exe")); - - var processes = Process.GetProcessesByName(name) - .Union(monoProcesses).ToList(); + var processes = Process.GetProcessesByName(name).ToList(); _logger.Debug("Found {0} processes with the name: {1}", processes.Count, name); diff --git a/src/NzbDrone.Common/Prowlarr.Common.csproj b/src/NzbDrone.Common/Prowlarr.Common.csproj index e5475de4e..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/Newtonsoft.Json/Json.cs b/src/NzbDrone.Common/Serializer/Newtonsoft.Json/Json.cs index fce2f295f..fef5b3c94 100644 --- a/src/NzbDrone.Common/Serializer/Newtonsoft.Json/Json.cs +++ b/src/NzbDrone.Common/Serializer/Newtonsoft.Json/Json.cs @@ -121,6 +121,11 @@ namespace NzbDrone.Common.Serializer return JsonConvert.SerializeObject(obj, SerializerSettings); } + public static string ToJson(this object obj, Formatting formatting) + { + return JsonConvert.SerializeObject(obj, formatting, SerializerSettings); + } + public static void Serialize(TModel model, TextWriter outputStream) { var jsonTextWriter = new JsonTextWriter(outputStream); diff --git a/src/NzbDrone.Common/Serializer/Newtonsoft.Json/JsonVisitor.cs b/src/NzbDrone.Common/Serializer/Newtonsoft.Json/JsonVisitor.cs index 0e69a0ae0..093de5b99 100644 --- a/src/NzbDrone.Common/Serializer/Newtonsoft.Json/JsonVisitor.cs +++ b/src/NzbDrone.Common/Serializer/Newtonsoft.Json/JsonVisitor.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Linq; namespace NzbDrone.Common.Serializer { @@ -60,7 +60,7 @@ namespace NzbDrone.Common.Serializer public virtual void Visit(JArray json) { - foreach (JToken token in json) + foreach (var token in json) { Visit(token); } @@ -72,7 +72,7 @@ namespace NzbDrone.Common.Serializer public virtual void Visit(JObject json) { - foreach (JProperty property in json.Properties()) + foreach (var property in json.Properties()) { Visit(property); } diff --git a/src/NzbDrone.Common/Serializer/Newtonsoft.Json/UnderscoreStringEnumConverter.cs b/src/NzbDrone.Common/Serializer/Newtonsoft.Json/UnderscoreStringEnumConverter.cs index 772f5640c..b202253b6 100644 --- a/src/NzbDrone.Common/Serializer/Newtonsoft.Json/UnderscoreStringEnumConverter.cs +++ b/src/NzbDrone.Common/Serializer/Newtonsoft.Json/UnderscoreStringEnumConverter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Text; using Newtonsoft.Json; @@ -42,7 +42,7 @@ namespace NzbDrone.Common.Serializer var enumText = value.ToString(); var builder = new StringBuilder(enumText.Length + 4); builder.Append(char.ToLower(enumText[0])); - for (int i = 1; i < enumText.Length; i++) + for (var i = 1; i < enumText.Length; i++) { if (char.IsUpper(enumText[i])) { 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/STJVersionConverter.cs b/src/NzbDrone.Common/Serializer/System.Text.Json/STJVersionConverter.cs index 70ad492c3..6fd024f7d 100644 --- a/src/NzbDrone.Common/Serializer/System.Text.Json/STJVersionConverter.cs +++ b/src/NzbDrone.Common/Serializer/System.Text.Json/STJVersionConverter.cs @@ -18,7 +18,7 @@ namespace NzbDrone.Common.Serializer { try { - Version v = new Version(reader.GetString()); + var v = new Version(reader.GetString()); return v; } catch (Exception) 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.Common/TPL/LimitedConcurrencyLevelTaskScheduler.cs b/src/NzbDrone.Common/TPL/LimitedConcurrencyLevelTaskScheduler.cs index a6137486b..ba9d804d3 100644 --- a/src/NzbDrone.Common/TPL/LimitedConcurrencyLevelTaskScheduler.cs +++ b/src/NzbDrone.Common/TPL/LimitedConcurrencyLevelTaskScheduler.cs @@ -137,7 +137,7 @@ namespace NzbDrone.Common.TPL /// An enumerable of the tasks currently scheduled. protected sealed override IEnumerable GetScheduledTasks() { - bool lockTaken = false; + var lockTaken = false; try { Monitor.TryEnter(_tasks, ref lockTaken); diff --git a/src/NzbDrone.Console/ConsoleApp.cs b/src/NzbDrone.Console/ConsoleApp.cs index cff2fd254..8f284d003 100644 --- a/src/NzbDrone.Console/ConsoleApp.cs +++ b/src/NzbDrone.Console/ConsoleApp.cs @@ -110,7 +110,7 @@ namespace NzbDrone.Console } System.Console.WriteLine("Non-recoverable failure, waiting for user intervention..."); - for (int i = 0; i < 3600; i++) + for (var i = 0; i < 3600; i++) { System.Threading.Thread.Sleep(1000); if (!System.Console.IsInputRedirected && System.Console.KeyAvailable) diff --git a/src/NzbDrone.Core.Test/Configuration/ConfigServiceFixture.cs b/src/NzbDrone.Core.Test/Configuration/ConfigServiceFixture.cs index 906f4d611..2369c9ddf 100644 --- a/src/NzbDrone.Core.Test/Configuration/ConfigServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Configuration/ConfigServiceFixture.cs @@ -31,7 +31,7 @@ namespace NzbDrone.Core.Test.Configuration [Test] public void Get_value_should_return_default_when_no_value() { - Subject.HistoryCleanupDays.Should().Be(365); + Subject.HistoryCleanupDays.Should().Be(30); } [Test] @@ -46,7 +46,7 @@ namespace NzbDrone.Core.Test.Configuration public void get_value_with_out_persist_should_not_store_default_value() { var interval = Subject.HistoryCleanupDays; - interval.Should().Be(365); + interval.Should().Be(30); Mocker.GetMock().Verify(c => c.Insert(It.IsAny()), Times.Never()); } diff --git a/src/NzbDrone.Core.Test/Datastore/BasicRepositoryFixture.cs b/src/NzbDrone.Core.Test/Datastore/BasicRepositoryFixture.cs index 9f0cf4b92..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; }); @@ -197,7 +198,7 @@ namespace NzbDrone.Core.Test.Datastore Subject.SetFields(_basicList, x => x.Interval); - for (int i = 0; i < _basicList.Count; i++) + for (var i = 0; i < _basicList.Count; i++) { _basicList[i].LastExecution = executionBackup[i]; } 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/031_apprise_server_urlFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/031_apprise_server_urlFixture.cs new file mode 100644 index 000000000..907bd7e47 --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/031_apprise_server_urlFixture.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +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 apprise_server_urlFixture : MigrationTest + { + [Test] + public void should_rename_server_url_setting_for_apprise() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Notifications").Row(new + { + Name = "Apprise", + Implementation = "Apprise", + Settings = new + { + BaseUrl = "http://localhost:8000", + NotificationType = 0 + }.ToJson(), + ConfigContract = "AppriseSettings", + OnHealthIssue = true, + IncludeHealthWarnings = true, + OnApplicationUpdate = true, + OnGrab = true, + IncludeManualGrabs = true + }); + }); + + var items = db.Query("SELECT * FROM \"Notifications\""); + + items.Should().HaveCount(1); + + items.First().Settings.Should().NotContainKey("baseUrl"); + items.First().Settings.Should().ContainKey("serverUrl"); + items.First().Settings.GetValueOrDefault("serverUrl").Should().Be("http://localhost:8000"); + } + } + + public class NotificationDefinition31 + { + public int Id { get; set; } + public int Priority { get; set; } + public string Name { get; set; } + public string Implementation { get; set; } + public Dictionary Settings { get; set; } + public string ConfigContract { get; set; } + public bool OnHealthIssue { get; set; } + public bool IncludeHealthWarnings { get; set; } + public bool OnApplicationUpdate { get; set; } + public bool OnGrab { get; set; } + public bool IncludeManualGrabs { get; set; } + public List Tags { get; set; } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/034_history_fix_data_titlesFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/034_history_fix_data_titlesFixture.cs new file mode 100644 index 000000000..4c0a3393e --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/034_history_fix_data_titlesFixture.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +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 history_fix_data_titlesFixture : MigrationTest + { + [Test] + public void should_update_data_for_book_search() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("History").Row(new + { + IndexerId = 1, + Date = DateTime.UtcNow, + Data = new + { + Author = "Fake Author", + BookTitle = "Fake Book Title", + Publisher = "", + Year = "", + Genre = "", + Query = "", + QueryType = "book", + Source = "Prowlarr", + Host = "localhost" + }.ToJson(), + EventType = 2, + Successful = true + }); + }); + + var items = db.Query("SELECT * FROM \"History\""); + + items.Should().HaveCount(1); + + items.First().Data.Should().NotContainKey("bookTitle"); + items.First().Data.Should().ContainKey("title"); + items.First().Data.GetValueOrDefault("title").Should().Be("Fake Book Title"); + } + + [Test] + public void should_update_data_for_release_grabbed() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("History").Row(new + { + IndexerId = 1, + Date = DateTime.UtcNow, + Data = new + { + GrabMethod = "Proxy", + Title = "Fake Release Title", + Source = "Prowlarr", + Host = "localhost" + }.ToJson(), + EventType = 1, + Successful = true + }); + }); + + var items = db.Query("SELECT * FROM \"History\""); + + items.Should().HaveCount(1); + + items.First().Data.Should().NotContainKey("title"); + items.First().Data.Should().ContainKey("grabTitle"); + items.First().Data.GetValueOrDefault("grabTitle").Should().Be("Fake Release Title"); + } + } + + public class HistoryDefinition34 + { + public int Id { get; set; } + public int IndexerId { get; set; } + public DateTime Date { get; set; } + public Dictionary Data { get; set; } + public int EventType { get; set; } + public string DownloadId { get; set; } + public bool Successful { get; set; } + } +} 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/FluentTest.cs b/src/NzbDrone.Core.Test/FluentTest.cs index a17f142e3..1747c44f2 100644 --- a/src/NzbDrone.Core.Test/FluentTest.cs +++ b/src/NzbDrone.Core.Test/FluentTest.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; using FluentAssertions; @@ -57,7 +57,7 @@ namespace NzbDrone.Core.Test [Test] public void ToBestDateTime_DayOfWeek() { - for (int i = 2; i < 7; i++) + for (var i = 2; i < 7; i++) { var dateTime = DateTime.Today.AddDays(i); 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 c27972e0f..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" }; - - System.IO.StringWriter 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/RarbgTests/RarbgFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/RarbgTests/RarbgFixture.cs index f077357a2..02a205b27 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/RarbgTests/RarbgFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/RarbgTests/RarbgFixture.cs @@ -17,6 +17,7 @@ using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.IndexerTests.RarbgTests { + [Obsolete("Rarbg has shutdown 2023-05-31")] [TestFixture] public class RarbgFixture : CoreTest { 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/InstrumentationTests/DatabaseTargetFixture.cs b/src/NzbDrone.Core.Test/InstrumentationTests/DatabaseTargetFixture.cs index 705ebf767..738f4c2bc 100644 --- a/src/NzbDrone.Core.Test/InstrumentationTests/DatabaseTargetFixture.cs +++ b/src/NzbDrone.Core.Test/InstrumentationTests/DatabaseTargetFixture.cs @@ -47,7 +47,7 @@ namespace NzbDrone.Core.Test.InstrumentationTests public void write_long_log() { var message = string.Empty; - for (int i = 0; i < 100; i++) + for (var i = 0; i < 100; i++) { message += Guid.NewGuid(); } 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 bb56e1a02..8d31502fa 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -15,6 +15,7 @@ namespace NzbDrone.Core.Annotations public string Label { get; set; } public string Unit { get; set; } public string HelpText { get; set; } + public string HelpTextWarning { get; set; } public string HelpLink { get; set; } public FieldType Type { get; set; } public bool Advanced { get; set; } @@ -40,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; } @@ -81,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 82fb56d86..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,8 +49,7 @@ namespace NzbDrone.Core.Applications yield return new ApplicationDefinition { - Name = GetType().Name, - SyncLevel = ApplicationSyncLevel.AddOnly, + 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/ApplicationDefinition.cs b/src/NzbDrone.Core/Applications/ApplicationDefinition.cs index 3160898f1..ed01daffc 100644 --- a/src/NzbDrone.Core/Applications/ApplicationDefinition.cs +++ b/src/NzbDrone.Core/Applications/ApplicationDefinition.cs @@ -6,6 +6,6 @@ namespace NzbDrone.Core.Applications { public ApplicationSyncLevel SyncLevel { get; set; } - public override bool Enable => SyncLevel == ApplicationSyncLevel.AddOnly || SyncLevel == ApplicationSyncLevel.FullSync; + public override bool Enable => SyncLevel is ApplicationSyncLevel.AddOnly or ApplicationSyncLevel.FullSync; } } diff --git a/src/NzbDrone.Core/Applications/ApplicationFactory.cs b/src/NzbDrone.Core/Applications/ApplicationFactory.cs index 1b8dc1e2e..2ecd2e78b 100644 --- a/src/NzbDrone.Core/Applications/ApplicationFactory.cs +++ b/src/NzbDrone.Core/Applications/ApplicationFactory.cs @@ -42,14 +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) { - ApplicationStatus blockedApplicationStatus; - if (blockedApplications.TryGetValue(application.Definition.Id, out 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; @@ -63,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 e965d12b4..108972d6e 100644 --- a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarian.cs +++ b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarian.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net; using FluentValidation.Results; using NLog; using NzbDrone.Common.Extensions; @@ -17,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; @@ -32,10 +31,10 @@ namespace NzbDrone.Core.Applications.LazyLibrarian { failures.AddIfNotNull(_lazyLibrarianV1Proxy.TestConnection(Settings)); } - catch (WebException ex) + catch (Exception ex) { - _logger.Error(ex, "Unable to send test message"); - failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Unable to complete application test, cannot connect to LazyLibrarian")); + _logger.Warn(ex, "Unable to complete application test"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to LazyLibrarian. {ex.Message}")); } return new ValidationResult(failures); @@ -66,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); @@ -75,9 +76,17 @@ 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); + + if (remoteIndexer == null) + { + _logger.Debug("Failed to add {0} [{1}]", indexer.Name, indexer.Id); + + return; + } + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerName = $"{remoteIndexer.Type},{remoteIndexer.Name}" }); } @@ -96,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); @@ -124,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); @@ -137,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; @@ -147,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/LazyLibrarianSettings.cs b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianSettings.cs index ab5590c15..9b2d17221 100644 --- a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianSettings.cs +++ b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianSettings.cs @@ -44,7 +44,7 @@ namespace NzbDrone.Core.Applications.LazyLibrarian [FieldDefinition(1, Label = "LazyLibrarian Server", HelpText = "URL used to connect to LazyLibrarian server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:5299")] public string BaseUrl { get; set; } - [FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by LazyLibrarian in Settings/Web Interface")] + [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by LazyLibrarian in Settings/Web Interface")] 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")] diff --git a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianV1Proxy.cs b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianV1Proxy.cs index 43df7764f..f4c6da138 100644 --- a/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianV1Proxy.cs +++ b/src/NzbDrone.Core/Applications/LazyLibrarian/LazyLibrarianV1Proxy.cs @@ -1,11 +1,12 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Net.Http; using FluentValidation.Results; -using Newtonsoft.Json; using NLog; using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; namespace NzbDrone.Core.Applications.LazyLibrarian { @@ -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; @@ -139,11 +154,11 @@ namespace NzbDrone.Core.Applications.LazyLibrarian return new ValidationFailure("ApiKey", status.Error.Message); } - var indexers = GetIndexers(settings); + GetIndexers(settings); } catch (HttpException ex) { - _logger.Error(ex, "Unable to send test message"); + _logger.Error(ex, "Unable to complete application test"); return new ValidationFailure("BaseUrl", "Unable to complete application test"); } catch (LazyLibrarianException ex) @@ -153,8 +168,8 @@ namespace NzbDrone.Core.Applications.LazyLibrarian } catch (Exception ex) { - _logger.Error(ex, "Unable to send test message"); - return new ValidationFailure("", "Unable to send test message"); + _logger.Error(ex, "Unable to complete application test"); + return new ValidationFailure("", $"Unable to send test message. {ex.Message}"); } return null; @@ -164,7 +179,9 @@ namespace NzbDrone.Core.Applications.LazyLibrarian { var baseUrl = settings.BaseUrl.TrimEnd('/'); - var requestBuilder = new HttpRequestBuilder(baseUrl).Resource(resource) + var requestBuilder = new HttpRequestBuilder(baseUrl) + .Resource(resource) + .Accept(HttpAccept.Json) .AddQueryParam("cmd", command) .AddQueryParam("apikey", settings.ApiKey); @@ -191,9 +208,12 @@ namespace NzbDrone.Core.Applications.LazyLibrarian { var response = _httpClient.Execute(request); - var results = JsonConvert.DeserializeObject(response.Content); + if ((int)response.StatusCode >= 300) + { + throw new HttpException(response); + } - return results; + return Json.Deserialize(response.Content); } private int CalculatePriority(int indexerPriority) => ProwlarrHighestPriority - indexerPriority + 1; diff --git a/src/NzbDrone.Core/Applications/Lidarr/Lidarr.cs b/src/NzbDrone.Core/Applications/Lidarr/Lidarr.cs index 7856d0e00..19c842f5c 100644 --- a/src/NzbDrone.Core/Applications/Lidarr/Lidarr.cs +++ b/src/NzbDrone.Core/Applications/Lidarr/Lidarr.cs @@ -1,11 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using FluentValidation.Results; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; @@ -19,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; @@ -46,11 +49,39 @@ 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) + { + switch (ex.Response.StatusCode) + { + case HttpStatusCode.Unauthorized: + _logger.Warn(ex, "API Key is invalid"); + failures.AddIfNotNull(new ValidationFailure("ApiKey", "API Key is invalid")); + break; + case HttpStatusCode.BadRequest: + _logger.Warn(ex, "Prowlarr URL is invalid"); + 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.Warn(ex, "Unable to complete application test"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Lidarr. {ex.Message}")); + break; + } + } + catch (JsonReaderException ex) + { + _logger.Error(ex, "Unable to parse JSON response from application"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to parse JSON response from application. {ex.Message}")); } catch (Exception ex) { - _logger.Error(ex, "Unable to send test message"); + _logger.Warn(ex, "Unable to complete application test"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Lidarr. {ex.Message}")); } @@ -66,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 }); } } @@ -83,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); @@ -92,9 +130,17 @@ 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); + + if (remoteIndexer == null) + { + _logger.Debug("Failed to add {0} [{1}]", indexer.Name, indexer.Id); + + return; + } + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = remoteIndexer.Id }); } @@ -112,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))); @@ -155,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; @@ -169,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) { @@ -205,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) { @@ -213,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 a39cc7637..0197255a2 100644 --- a/src/NzbDrone.Core/Applications/Lidarr/LidarrSettings.cs +++ b/src/NzbDrone.Core/Applications/Lidarr/LidarrSettings.cs @@ -33,12 +33,15 @@ namespace NzbDrone.Core.Applications.Lidarr [FieldDefinition(1, Label = "Lidarr Server", HelpText = "URL used to connect to Lidarr server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:8686")] public string BaseUrl { get; set; } - [FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Lidarr in Settings/General")] + [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 228fc1384..3fcb337c0 100644 --- a/src/NzbDrone.Core/Applications/Lidarr/LidarrV1Proxy.cs +++ b/src/NzbDrone.Core/Applications/Lidarr/LidarrV1Proxy.cs @@ -84,8 +84,20 @@ namespace NzbDrone.Core.Applications.Lidarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Post); 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 add indexer forcefully"); + + request.Url = request.Url.AddQueryParam("forceSave", "true"); + + return ExecuteIndexerRequest(request); + } } public LidarrIndexer UpdateIndexer(LidarrIndexer indexer, LidarrSettings settings) @@ -93,8 +105,20 @@ namespace NzbDrone.Core.Applications.Lidarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put); 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) @@ -102,48 +126,18 @@ namespace NzbDrone.Core.Applications.Lidarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}/test", HttpMethod.Post); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); - try + var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version"); + + if (applicationVersion == null) { - var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version"); - - if (applicationVersion == null) - { - return new ValidationFailure(string.Empty, "Failed to fetch Lidarr version"); - } - - if (new Version(applicationVersion) < MinimumApplicationVersion) - { - return new ValidationFailure(string.Empty, $"Lidarr version should be at least {MinimumApplicationVersion.ToString(3)}. Version reported is {applicationVersion}", applicationVersion); - } + return new ValidationFailure(string.Empty, "Failed to fetch Lidarr version"); } - catch (HttpException ex) + + if (new Version(applicationVersion) < MinimumApplicationVersion) { - if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) - { - _logger.Error(ex, "API Key is invalid"); - return new ValidationFailure("ApiKey", "API Key is invalid"); - } - - if (ex.Response.StatusCode == HttpStatusCode.BadRequest) - { - _logger.Error(ex, "Prowlarr URL is invalid"); - return new ValidationFailure("ProwlarrUrl", "Prowlarr url is invalid, Lidarr cannot connect to Prowlarr"); - } - - if (ex.Response.StatusCode == HttpStatusCode.SeeOther) - { - _logger.Error(ex, "Lidarr returned redirect and is invalid"); - return new ValidationFailure("BaseUrl", "Lidarr url is invalid, Prowlarr cannot connect to Lidarr - are you missing a url base?"); - } - - _logger.Error(ex, "Unable to send test message"); - return new ValidationFailure("BaseUrl", "Unable to complete application test"); - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to send test message"); - return new ValidationFailure("", "Unable to send test message"); + return new ValidationFailure(string.Empty, $"Lidarr version should be at least {MinimumApplicationVersion.ToString(3)}. Version reported is {applicationVersion}", applicationVersion); } return null; @@ -160,27 +154,30 @@ namespace NzbDrone.Core.Applications.Lidarr switch (ex.Response.StatusCode) { case HttpStatusCode.Unauthorized: - _logger.Error(ex, "API Key is invalid"); + _logger.Warn(ex, "API Key is invalid"); break; case HttpStatusCode.BadRequest: if (ex.Response.Content.Contains("Query successful, but no results in the configured categories were returned from your indexer.", StringComparison.InvariantCultureIgnoreCase)) { - _logger.Error(ex, "No Results in configured categories. See FAQ Entry: Prowlarr will not sync X Indexer to App"); + _logger.Warn(ex, "No Results in configured categories. See FAQ Entry: Prowlarr will not sync X Indexer to App"); break; } _logger.Error(ex, "Invalid Request"); break; case HttpStatusCode.SeeOther: - _logger.Error(ex, "App returned redirect and is invalid. Check App URL"); + case HttpStatusCode.TemporaryRedirect: + _logger.Warn(ex, "App returned redirect and is invalid. Check App URL"); break; case HttpStatusCode.NotFound: - _logger.Error(ex, "Remote indexer not found"); + _logger.Warn(ex, "Remote indexer not found"); break; default: _logger.Error(ex, "Unexpected response status code: {0}", ex.Response.StatusCode); - throw; + break; } + + throw; } catch (JsonReaderException ex) { @@ -192,15 +189,15 @@ namespace NzbDrone.Core.Applications.Lidarr _logger.Error(ex, "Unable to add or update indexer"); throw; } - - return null; } private HttpRequest BuildRequest(LidarrSettings settings, string resource, HttpMethod method) { var baseUrl = settings.BaseUrl.TrimEnd('/'); - var request = new HttpRequestBuilder(baseUrl).Resource(resource) + var request = new HttpRequestBuilder(baseUrl) + .Resource(resource) + .Accept(HttpAccept.Json) .SetHeader("X-Api-Key", settings.ApiKey) .Build(); @@ -217,9 +214,12 @@ namespace NzbDrone.Core.Applications.Lidarr { var response = _httpClient.Execute(request); - var results = JsonConvert.DeserializeObject(response.Content); + if ((int)response.StatusCode >= 300) + { + throw new HttpException(response); + } - return results; + return Json.Deserialize(response.Content); } } } diff --git a/src/NzbDrone.Core/Applications/Mylar/Mylar.cs b/src/NzbDrone.Core/Applications/Mylar/Mylar.cs index 052a96ff1..e9fd9ffe7 100644 --- a/src/NzbDrone.Core/Applications/Mylar/Mylar.cs +++ b/src/NzbDrone.Core/Applications/Mylar/Mylar.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net; using FluentValidation.Results; using NLog; using NzbDrone.Common.Extensions; @@ -17,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; @@ -32,10 +31,10 @@ namespace NzbDrone.Core.Applications.Mylar { failures.AddIfNotNull(_mylarV3Proxy.TestConnection(Settings)); } - catch (WebException ex) + catch (Exception ex) { - _logger.Error(ex, "Unable to send test message"); - failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Unable to complete application test, cannot connect to Mylar")); + _logger.Warn(ex, "Unable to complete application test"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Mylar. {ex.Message}")); } return new ValidationResult(failures); @@ -66,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); @@ -75,9 +76,17 @@ 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); + + if (remoteIndexer == null) + { + _logger.Debug("Failed to add {0} [{1}]", indexer.Name, indexer.Id); + + return; + } + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerName = $"{remoteIndexer.Type},{remoteIndexer.Name}" }); } @@ -96,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); @@ -124,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); @@ -137,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; @@ -147,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/Mylar/MylarSettings.cs b/src/NzbDrone.Core/Applications/Mylar/MylarSettings.cs index 385be12e1..f6f58b9e5 100644 --- a/src/NzbDrone.Core/Applications/Mylar/MylarSettings.cs +++ b/src/NzbDrone.Core/Applications/Mylar/MylarSettings.cs @@ -34,7 +34,7 @@ namespace NzbDrone.Core.Applications.Mylar [FieldDefinition(1, Label = "Mylar Server", HelpText = "URL used to connect to Mylar server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:8090")] public string BaseUrl { get; set; } - [FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Mylar in Settings/Web Interface")] + [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Mylar in Settings/Web Interface")] 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")] diff --git a/src/NzbDrone.Core/Applications/Mylar/MylarV3Proxy.cs b/src/NzbDrone.Core/Applications/Mylar/MylarV3Proxy.cs index 4c72160e9..119175634 100644 --- a/src/NzbDrone.Core/Applications/Mylar/MylarV3Proxy.cs +++ b/src/NzbDrone.Core/Applications/Mylar/MylarV3Proxy.cs @@ -3,9 +3,9 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; using FluentValidation.Results; -using Newtonsoft.Json; using NLog; using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; namespace NzbDrone.Core.Applications.Mylar { @@ -135,11 +135,11 @@ namespace NzbDrone.Core.Applications.Mylar return new ValidationFailure("ApiKey", status.Error.Message); } - var indexers = GetIndexers(settings); + GetIndexers(settings); } catch (HttpException ex) { - _logger.Error(ex, "Unable to send test message"); + _logger.Error(ex, "Unable to complete application test"); return new ValidationFailure("BaseUrl", "Unable to complete application test"); } catch (MylarException ex) @@ -149,8 +149,8 @@ namespace NzbDrone.Core.Applications.Mylar } catch (Exception ex) { - _logger.Error(ex, "Unable to send test message"); - return new ValidationFailure("", "Unable to send test message"); + _logger.Error(ex, "Unable to complete application test"); + return new ValidationFailure("", $"Unable to send test message. {ex.Message}"); } return null; @@ -160,7 +160,9 @@ namespace NzbDrone.Core.Applications.Mylar { var baseUrl = settings.BaseUrl.TrimEnd('/'); - var requestBuilder = new HttpRequestBuilder(baseUrl).Resource(resource) + var requestBuilder = new HttpRequestBuilder(baseUrl) + .Resource(resource) + .Accept(HttpAccept.Json) .AddQueryParam("cmd", command) .AddQueryParam("apikey", settings.ApiKey); @@ -187,9 +189,12 @@ namespace NzbDrone.Core.Applications.Mylar { var response = _httpClient.Execute(request); - var results = JsonConvert.DeserializeObject(response.Content); + if ((int)response.StatusCode >= 300) + { + throw new HttpException(response); + } - return results; + return Json.Deserialize(response.Content); } } } diff --git a/src/NzbDrone.Core/Applications/Radarr/Radarr.cs b/src/NzbDrone.Core/Applications/Radarr/Radarr.cs index ab4b7cdce..85b9c4a3b 100644 --- a/src/NzbDrone.Core/Applications/Radarr/Radarr.cs +++ b/src/NzbDrone.Core/Applications/Radarr/Radarr.cs @@ -1,11 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using FluentValidation.Results; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; @@ -19,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; @@ -46,11 +49,39 @@ 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) + { + switch (ex.Response.StatusCode) + { + case HttpStatusCode.Unauthorized: + _logger.Warn(ex, "API Key is invalid"); + failures.AddIfNotNull(new ValidationFailure("ApiKey", "API Key is invalid")); + break; + case HttpStatusCode.BadRequest: + _logger.Warn(ex, "Prowlarr URL is invalid"); + 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.Warn(ex, "Unable to complete application test"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Radarr. {ex.Message}")); + break; + } + } + catch (JsonReaderException ex) + { + _logger.Error(ex, "Unable to parse JSON response from application"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to parse JSON response from application. {ex.Message}")); } catch (Exception ex) { - _logger.Error(ex, "Unable to send test message"); + _logger.Warn(ex, "Unable to complete application test"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Radarr. {ex.Message}")); } @@ -66,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 }); } } @@ -83,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); @@ -92,9 +130,17 @@ 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); + + if (remoteIndexer == null) + { + _logger.Debug("Failed to add {0} [{1}]", indexer.Name, indexer.Id); + + return; + } + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = remoteIndexer.Id }); } @@ -112,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))); @@ -155,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; @@ -169,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) { @@ -205,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 68a98879c..457d7d0df 100644 --- a/src/NzbDrone.Core/Applications/Radarr/RadarrSettings.cs +++ b/src/NzbDrone.Core/Applications/Radarr/RadarrSettings.cs @@ -25,7 +25,7 @@ namespace NzbDrone.Core.Applications.Radarr { ProwlarrUrl = "http://localhost:9696"; BaseUrl = "http://localhost:7878"; - SyncCategories = new[] { 2000, 2010, 2020, 2030, 2040, 2045, 2050, 2060, 2070, 2080 }; + SyncCategories = new[] { 2000, 2010, 2020, 2030, 2040, 2045, 2050, 2060, 2070, 2080, 2090 }; } [FieldDefinition(0, Label = "Prowlarr Server", HelpText = "Prowlarr server URL as Radarr sees it, including http(s)://, port, and urlbase if needed", Placeholder = "http://localhost:9696")] @@ -34,12 +34,15 @@ namespace NzbDrone.Core.Applications.Radarr [FieldDefinition(1, Label = "Radarr Server", HelpText = "URL used to connect to Radarr server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:7878")] public string BaseUrl { get; set; } - [FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Radarr in Settings/General")] + [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 19613ca81..d431856aa 100644 --- a/src/NzbDrone.Core/Applications/Radarr/RadarrV3Proxy.cs +++ b/src/NzbDrone.Core/Applications/Radarr/RadarrV3Proxy.cs @@ -85,8 +85,20 @@ namespace NzbDrone.Core.Applications.Radarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Post); 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 add indexer forcefully"); + + request.Url = request.Url.AddQueryParam("forceSave", "true"); + + return ExecuteIndexerRequest(request); + } } public RadarrIndexer UpdateIndexer(RadarrIndexer indexer, RadarrSettings settings) @@ -94,8 +106,20 @@ namespace NzbDrone.Core.Applications.Radarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put); 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) @@ -103,60 +127,30 @@ namespace NzbDrone.Core.Applications.Radarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}/test", HttpMethod.Post); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); - try + var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version"); + + if (applicationVersion == null) { - var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version"); + return new ValidationFailure(string.Empty, "Failed to fetch Radarr version"); + } - if (applicationVersion == null) - { - return new ValidationFailure(string.Empty, "Failed to fetch Radarr version"); - } + var version = new Version(applicationVersion); - var version = new Version(applicationVersion); - - if (version.Major == 3) + if (version.Major == 3) + { + if (version < MinimumApplicationV3Version) { - if (version < MinimumApplicationV3Version) - { - return new ValidationFailure(string.Empty, $"Radarr version should be at least {MinimumApplicationV3Version.ToString(3)}. Version reported is {applicationVersion}", applicationVersion); - } - } - else - { - if (version < MinimumApplicationV4Version) - { - return new ValidationFailure(string.Empty, $"Radarr version should be at least {MinimumApplicationV4Version.ToString(3)}. Version reported is {applicationVersion}", applicationVersion); - } + return new ValidationFailure(string.Empty, $"Radarr version should be at least {MinimumApplicationV3Version.ToString(3)}. Version reported is {applicationVersion}", applicationVersion); } } - catch (HttpException ex) + else { - if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) + if (version < MinimumApplicationV4Version) { - _logger.Error(ex, "API Key is invalid"); - return new ValidationFailure("ApiKey", "API Key is invalid"); + return new ValidationFailure(string.Empty, $"Radarr version should be at least {MinimumApplicationV4Version.ToString(3)}. Version reported is {applicationVersion}", applicationVersion); } - - if (ex.Response.StatusCode == HttpStatusCode.BadRequest) - { - _logger.Error(ex, "Prowlarr URL is invalid"); - return new ValidationFailure("ProwlarrUrl", "Prowlarr url is invalid, Radarr cannot connect to Prowlarr"); - } - - if (ex.Response.StatusCode == HttpStatusCode.SeeOther) - { - _logger.Error(ex, "Radarr returned redirect and is invalid"); - return new ValidationFailure("BaseUrl", "Radarr url is invalid, Prowlarr cannot connect to Radarr - are you missing a url base?"); - } - - _logger.Error(ex, "Unable to send test message"); - return new ValidationFailure("BaseUrl", "Unable to complete application test"); - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to send test message"); - return new ValidationFailure("", "Unable to send test message"); } return null; @@ -173,27 +167,30 @@ namespace NzbDrone.Core.Applications.Radarr switch (ex.Response.StatusCode) { case HttpStatusCode.Unauthorized: - _logger.Error(ex, "API Key is invalid"); + _logger.Warn(ex, "API Key is invalid"); break; case HttpStatusCode.BadRequest: if (ex.Response.Content.Contains("Query successful, but no results in the configured categories were returned from your indexer.", StringComparison.InvariantCultureIgnoreCase)) { - _logger.Error(ex, "No Results in configured categories. See FAQ Entry: Prowlarr will not sync X Indexer to App"); + _logger.Warn(ex, "No Results in configured categories. See FAQ Entry: Prowlarr will not sync X Indexer to App"); break; } _logger.Error(ex, "Invalid Request"); break; case HttpStatusCode.SeeOther: - _logger.Error(ex, "App returned redirect and is invalid. Check App URL"); + case HttpStatusCode.TemporaryRedirect: + _logger.Warn(ex, "App returned redirect and is invalid. Check App URL"); break; case HttpStatusCode.NotFound: - _logger.Error(ex, "Remote indexer not found"); + _logger.Warn(ex, "Remote indexer not found"); break; default: _logger.Error(ex, "Unexpected response status code: {0}", ex.Response.StatusCode); - throw; + break; } + + throw; } catch (JsonReaderException ex) { @@ -205,15 +202,15 @@ namespace NzbDrone.Core.Applications.Radarr _logger.Error(ex, "Unable to add or update indexer"); throw; } - - return null; } private HttpRequest BuildRequest(RadarrSettings settings, string resource, HttpMethod method) { var baseUrl = settings.BaseUrl.TrimEnd('/'); - var request = new HttpRequestBuilder(baseUrl).Resource(resource) + var request = new HttpRequestBuilder(baseUrl) + .Resource(resource) + .Accept(HttpAccept.Json) .SetHeader("X-Api-Key", settings.ApiKey) .Build(); @@ -230,9 +227,12 @@ namespace NzbDrone.Core.Applications.Radarr { var response = _httpClient.Execute(request); - var results = JsonConvert.DeserializeObject(response.Content); + if ((int)response.StatusCode >= 300) + { + throw new HttpException(response); + } - return results; + return Json.Deserialize(response.Content); } } } diff --git a/src/NzbDrone.Core/Applications/Readarr/Readarr.cs b/src/NzbDrone.Core/Applications/Readarr/Readarr.cs index 5349324f8..1fc6742ae 100644 --- a/src/NzbDrone.Core/Applications/Readarr/Readarr.cs +++ b/src/NzbDrone.Core/Applications/Readarr/Readarr.cs @@ -3,10 +3,12 @@ using System.Collections.Generic; using System.Linq; using System.Net; using FluentValidation.Results; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; @@ -20,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; @@ -47,12 +49,40 @@ 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 (WebException ex) + catch (HttpException ex) { - _logger.Error(ex, "Unable to send test message"); - failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Unable to complete application test, cannot connect to Readarr")); + switch (ex.Response.StatusCode) + { + case HttpStatusCode.Unauthorized: + _logger.Warn(ex, "API Key is invalid"); + failures.AddIfNotNull(new ValidationFailure("ApiKey", "API Key is invalid")); + break; + case HttpStatusCode.BadRequest: + _logger.Warn(ex, "Prowlarr URL is invalid"); + 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.Warn(ex, "Unable to complete application test"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Readarr. {ex.Message}")); + break; + } + } + catch (JsonReaderException ex) + { + _logger.Error(ex, "Unable to parse JSON response from application"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to parse JSON response from application. {ex.Message}")); + } + catch (Exception ex) + { + _logger.Warn(ex, "Unable to complete application test"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Readarr. {ex.Message}")); } return new ValidationResult(failures); @@ -61,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 }); } } @@ -84,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); @@ -93,9 +130,17 @@ 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); + + if (remoteIndexer == null) + { + _logger.Debug("Failed to add {0} [{1}]", indexer.Name, indexer.Id); + + return; + } + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = remoteIndexer.Id }); } @@ -113,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))); @@ -138,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); } @@ -153,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; @@ -167,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"); @@ -197,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) { @@ -205,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 a769d8406..f789586d3 100644 --- a/src/NzbDrone.Core/Applications/Readarr/ReadarrSettings.cs +++ b/src/NzbDrone.Core/Applications/Readarr/ReadarrSettings.cs @@ -34,12 +34,15 @@ namespace NzbDrone.Core.Applications.Readarr [FieldDefinition(1, Label = "Readarr Server", HelpText = "URL used to connect to Readarr server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:8787")] public string BaseUrl { get; set; } - [FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Readarr in Settings/General")] + [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 3d74a517b..899ef79b6 100644 --- a/src/NzbDrone.Core/Applications/Readarr/ReadarrV1Proxy.cs +++ b/src/NzbDrone.Core/Applications/Readarr/ReadarrV1Proxy.cs @@ -81,8 +81,20 @@ namespace NzbDrone.Core.Applications.Readarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Post); 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 add indexer forcefully"); + + request.Url = request.Url.AddQueryParam("forceSave", "true"); + + return ExecuteIndexerRequest(request); + } } public ReadarrIndexer UpdateIndexer(ReadarrIndexer indexer, ReadarrSettings settings) @@ -90,8 +102,20 @@ namespace NzbDrone.Core.Applications.Readarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put); 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) @@ -99,39 +123,9 @@ namespace NzbDrone.Core.Applications.Readarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}/test", HttpMethod.Post); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); - try - { - Execute(request); - } - catch (HttpException ex) - { - if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) - { - _logger.Error(ex, "API Key is invalid"); - return new ValidationFailure("ApiKey", "API Key is invalid"); - } - - if (ex.Response.StatusCode == HttpStatusCode.BadRequest) - { - _logger.Error(ex, "Prowlarr URL is invalid"); - return new ValidationFailure("ProwlarrUrl", "Prowlarr url is invalid, Readarr cannot connect to Prowlarr"); - } - - if (ex.Response.StatusCode == HttpStatusCode.SeeOther) - { - _logger.Error(ex, "Readarr returned redirect and is invalid"); - return new ValidationFailure("BaseUrl", "Readarr url is invalid, Prowlarr cannot connect to Readarr - are you missing a url base?"); - } - - _logger.Error(ex, "Unable to send test message"); - return new ValidationFailure("BaseUrl", "Unable to complete application test"); - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to send test message"); - return new ValidationFailure("", "Unable to send test message"); - } + _httpClient.Post(request); return null; } @@ -147,27 +141,30 @@ namespace NzbDrone.Core.Applications.Readarr switch (ex.Response.StatusCode) { case HttpStatusCode.Unauthorized: - _logger.Error(ex, "API Key is invalid"); + _logger.Warn(ex, "API Key is invalid"); break; case HttpStatusCode.BadRequest: if (ex.Response.Content.Contains("Query successful, but no results in the configured categories were returned from your indexer.", StringComparison.InvariantCultureIgnoreCase)) { - _logger.Error(ex, "No Results in configured categories. See FAQ Entry: Prowlarr will not sync X Indexer to App"); + _logger.Warn(ex, "No Results in configured categories. See FAQ Entry: Prowlarr will not sync X Indexer to App"); break; } _logger.Error(ex, "Invalid Request"); break; case HttpStatusCode.SeeOther: - _logger.Error(ex, "App returned redirect and is invalid. Check App URL"); + case HttpStatusCode.TemporaryRedirect: + _logger.Warn(ex, "App returned redirect and is invalid. Check App URL"); break; case HttpStatusCode.NotFound: - _logger.Error(ex, "Remote indexer not found"); + _logger.Warn(ex, "Remote indexer not found"); break; default: _logger.Error(ex, "Unexpected response status code: {0}", ex.Response.StatusCode); - throw; + break; } + + throw; } catch (JsonReaderException ex) { @@ -179,15 +176,15 @@ namespace NzbDrone.Core.Applications.Readarr _logger.Error(ex, "Unable to add or update indexer"); throw; } - - return null; } private HttpRequest BuildRequest(ReadarrSettings settings, string resource, HttpMethod method) { var baseUrl = settings.BaseUrl.TrimEnd('/'); - var request = new HttpRequestBuilder(baseUrl).Resource(resource) + var request = new HttpRequestBuilder(baseUrl) + .Resource(resource) + .Accept(HttpAccept.Json) .SetHeader("X-Api-Key", settings.ApiKey) .Build(); @@ -204,9 +201,12 @@ namespace NzbDrone.Core.Applications.Readarr { var response = _httpClient.Execute(request); - var results = JsonConvert.DeserializeObject(response.Content); + if ((int)response.StatusCode >= 300) + { + throw new HttpException(response); + } - return results; + return Json.Deserialize(response.Content); } } } diff --git a/src/NzbDrone.Core/Applications/Sonarr/Sonarr.cs b/src/NzbDrone.Core/Applications/Sonarr/Sonarr.cs index c372f7a53..6e5284fc7 100644 --- a/src/NzbDrone.Core/Applications/Sonarr/Sonarr.cs +++ b/src/NzbDrone.Core/Applications/Sonarr/Sonarr.cs @@ -1,11 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using FluentValidation.Results; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; @@ -19,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; @@ -46,11 +49,43 @@ 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) + { + switch (ex.Response.StatusCode) + { + case HttpStatusCode.Unauthorized: + _logger.Warn(ex, "API Key is invalid"); + failures.AddIfNotNull(new ValidationFailure("ApiKey", "API Key is invalid")); + break; + case HttpStatusCode.BadRequest: + _logger.Warn(ex, "Prowlarr URL is invalid"); + 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.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.Warn(ex, "Unable to complete application test"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Sonarr. {ex.Message}")); + break; + } + } + catch (JsonReaderException ex) + { + _logger.Error(ex, "Unable to parse JSON response from application"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to parse JSON response from application. {ex.Message}")); } catch (Exception ex) { - _logger.Error(ex, "Unable to send test message"); + _logger.Warn(ex, "Unable to complete application test"); failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Sonarr. {ex.Message}")); } @@ -66,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 }); } } @@ -83,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); @@ -93,9 +135,17 @@ 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); + + if (remoteIndexer == null) + { + _logger.Debug("Failed to add {0} [{1}]", indexer.Name, indexer.Id); + + return; + } + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = remoteIndexer.Id }); } @@ -113,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))); @@ -157,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; @@ -171,16 +224,16 @@ 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", "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) { // Ensuring backward compatibility with older versions on first sync - syncFields.AddRange(new List { "animeStandardFormatSearch", "additionalParameters" }); + syncFields.AddRange(new List { "additionalParameters" }); } var newznab = schemas.First(i => i.Implementation == "Newznab"); @@ -207,8 +260,13 @@ 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")) + { + sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "animeStandardFormatSearch").Value = Settings.SyncAnimeStandardFormatSearch; + } if (indexer.Protocol == DownloadProtocol.Torrent) { @@ -216,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 e3dffaed0..698c7ed6f 100644 --- a/src/NzbDrone.Core/Applications/Sonarr/SonarrIndexer.cs +++ b/src/NzbDrone.Core/Applications/Sonarr/SonarrIndexer.cs @@ -30,14 +30,21 @@ 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); + var animeStandardFormatSearch = Fields.FirstOrDefault(x => x.Name == "animeStandardFormatSearch")?.Value == null ? null : (bool?)Convert.ToBoolean(Fields.FirstOrDefault(x => x.Name == "animeStandardFormatSearch").Value); + var otherAnimeStandardFormatSearch = other.Fields.FirstOrDefault(x => x.Name == "animeStandardFormatSearch")?.Value == null ? null : (bool?)Convert.ToBoolean(other.Fields.FirstOrDefault(x => x.Name == "animeStandardFormatSearch").Value); + var animeStandardFormatSearchCompare = animeStandardFormatSearch == otherAnimeStandardFormatSearch; + var minimumSeeders = Fields.FirstOrDefault(x => x.Name == "minimumSeeders")?.Value == null ? null : (int?)Convert.ToInt32(Fields.FirstOrDefault(x => x.Name == "minimumSeeders").Value); var otherMinimumSeeders = other.Fields.FirstOrDefault(x => x.Name == "minimumSeeders")?.Value == null ? null : (int?)Convert.ToInt32(other.Fields.FirstOrDefault(x => x.Name == "minimumSeeders").Value); var minimumSeedersCompare = minimumSeeders == otherMinimumSeeders; @@ -54,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 && @@ -61,7 +72,7 @@ namespace NzbDrone.Core.Applications.Sonarr other.Implementation == Implementation && other.Priority == Priority && other.Id == Id && - apiKey && apiPathCompare && baseUrl && cats && animeCats && 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 84b9ff9c6..95b52bab0 100644 --- a/src/NzbDrone.Core/Applications/Sonarr/SonarrSettings.cs +++ b/src/NzbDrone.Core/Applications/Sonarr/SonarrSettings.cs @@ -24,7 +24,7 @@ namespace NzbDrone.Core.Applications.Sonarr { ProwlarrUrl = "http://localhost:9696"; BaseUrl = "http://localhost:8989"; - SyncCategories = new[] { 5000, 5010, 5020, 5030, 5040, 5045, 5050 }; + SyncCategories = new[] { 5000, 5010, 5020, 5030, 5040, 5045, 5050, 5090 }; AnimeSyncCategories = new[] { 5070 }; } @@ -34,7 +34,7 @@ namespace NzbDrone.Core.Applications.Sonarr [FieldDefinition(1, Label = "Sonarr Server", HelpText = "URL used to connect to Sonarr server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:8989")] public string BaseUrl { get; set; } - [FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Sonarr in Settings/General")] + [FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Sonarr 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")] @@ -43,6 +43,12 @@ 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, 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() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Applications/Sonarr/SonarrV3Proxy.cs b/src/NzbDrone.Core/Applications/Sonarr/SonarrV3Proxy.cs index 4b17352ad..f92043c99 100644 --- a/src/NzbDrone.Core/Applications/Sonarr/SonarrV3Proxy.cs +++ b/src/NzbDrone.Core/Applications/Sonarr/SonarrV3Proxy.cs @@ -84,8 +84,20 @@ namespace NzbDrone.Core.Applications.Sonarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Post); 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 add indexer forcefully"); + + request.Url = request.Url.AddQueryParam("forceSave", "true"); + + return ExecuteIndexerRequest(request); + } } public SonarrIndexer UpdateIndexer(SonarrIndexer indexer, SonarrSettings settings) @@ -93,8 +105,20 @@ namespace NzbDrone.Core.Applications.Sonarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put); 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) @@ -102,54 +126,18 @@ namespace NzbDrone.Core.Applications.Sonarr var request = BuildRequest(settings, $"{AppIndexerApiRoute}/test", HttpMethod.Post); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); - try + var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version"); + + if (applicationVersion == null) { - var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version"); - - if (applicationVersion == null) - { - return new ValidationFailure(string.Empty, "Failed to fetch Sonarr version"); - } - - if (new Version(applicationVersion) < MinimumApplicationVersion) - { - return new ValidationFailure(string.Empty, $"Sonarr version should be at least {MinimumApplicationVersion.ToString(3)}. Version reported is {applicationVersion}", applicationVersion); - } + return new ValidationFailure(string.Empty, "Failed to fetch Sonarr version"); } - catch (HttpException ex) + + if (new Version(applicationVersion) < MinimumApplicationVersion) { - if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) - { - _logger.Error(ex, "API Key is invalid"); - return new ValidationFailure("ApiKey", "API Key is invalid"); - } - - if (ex.Response.StatusCode == HttpStatusCode.BadRequest) - { - _logger.Error(ex, "Prowlarr URL is invalid"); - return new ValidationFailure("ProwlarrUrl", "Prowlarr url is invalid, Sonarr cannot connect to Prowlarr"); - } - - if (ex.Response.StatusCode == HttpStatusCode.SeeOther) - { - _logger.Error(ex, "Sonarr returned redirect and is invalid"); - return new ValidationFailure("BaseUrl", "Sonarr url is invalid, Prowlarr cannot connect to Sonarr - are you missing a url base?"); - } - - if (ex.Response.StatusCode == HttpStatusCode.NotFound) - { - _logger.Error(ex, "Sonarr not found"); - return new ValidationFailure("BaseUrl", "Sonarr url is invalid, Prowlarr cannot connect to Sonarr. Is Sonarr running and accessible? Sonarr v2 is not supported."); - } - - _logger.Error(ex, "Unable to send test message"); - return new ValidationFailure("BaseUrl", "Unable to complete application test"); - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to send test message"); - return new ValidationFailure("", "Unable to send test message"); + return new ValidationFailure(string.Empty, $"Sonarr version should be at least {MinimumApplicationVersion.ToString(3)}. Version reported is {applicationVersion}", applicationVersion); } return null; @@ -166,27 +154,30 @@ namespace NzbDrone.Core.Applications.Sonarr switch (ex.Response.StatusCode) { case HttpStatusCode.Unauthorized: - _logger.Error(ex, "API Key is invalid"); + _logger.Warn(ex, "API Key is invalid"); break; case HttpStatusCode.BadRequest: if (ex.Response.Content.Contains("Query successful, but no results in the configured categories were returned from your indexer.", StringComparison.InvariantCultureIgnoreCase)) { - _logger.Error(ex, "No Results in configured categories. See FAQ Entry: Prowlarr will not sync X Indexer to App"); + _logger.Warn(ex, "No Results in configured categories. See FAQ Entry: Prowlarr will not sync X Indexer to App"); break; } _logger.Error(ex, "Invalid Request"); break; case HttpStatusCode.SeeOther: - _logger.Error(ex, "App returned redirect and is invalid. Check App URL"); + case HttpStatusCode.TemporaryRedirect: + _logger.Warn(ex, "App returned redirect and is invalid. Check App URL"); break; case HttpStatusCode.NotFound: - _logger.Error(ex, "Remote indexer not found"); + _logger.Warn(ex, "Remote indexer not found"); break; default: _logger.Error(ex, "Unexpected response status code: {0}", ex.Response.StatusCode); - throw; + break; } + + throw; } catch (JsonReaderException ex) { @@ -198,15 +189,15 @@ namespace NzbDrone.Core.Applications.Sonarr _logger.Error(ex, "Unable to add or update indexer"); throw; } - - return null; } private HttpRequest BuildRequest(SonarrSettings settings, string resource, HttpMethod method) { var baseUrl = settings.BaseUrl.TrimEnd('/'); - var request = new HttpRequestBuilder(baseUrl).Resource(resource) + var request = new HttpRequestBuilder(baseUrl) + .Resource(resource) + .Accept(HttpAccept.Json) .SetHeader("X-Api-Key", settings.ApiKey) .Build(); @@ -223,9 +214,12 @@ namespace NzbDrone.Core.Applications.Sonarr { var response = _httpClient.Execute(request); - var results = JsonConvert.DeserializeObject(response.Content); + if ((int)response.StatusCode >= 300) + { + throw new HttpException(response); + } - return results; + return Json.Deserialize(response.Content); } } } diff --git a/src/NzbDrone.Core/Applications/Whisparr/Whisparr.cs b/src/NzbDrone.Core/Applications/Whisparr/Whisparr.cs index 6d16318ed..0c149fc7c 100644 --- a/src/NzbDrone.Core/Applications/Whisparr/Whisparr.cs +++ b/src/NzbDrone.Core/Applications/Whisparr/Whisparr.cs @@ -3,10 +3,12 @@ using System.Collections.Generic; using System.Linq; using System.Net; using FluentValidation.Results; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; @@ -20,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; @@ -47,12 +49,40 @@ 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 (WebException ex) + catch (HttpException ex) { - _logger.Error(ex, "Unable to send test message"); - failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Unable to complete application test, cannot connect to Whisparr")); + switch (ex.Response.StatusCode) + { + case HttpStatusCode.Unauthorized: + _logger.Warn(ex, "API Key is invalid"); + failures.AddIfNotNull(new ValidationFailure("ApiKey", "API Key is invalid")); + break; + case HttpStatusCode.BadRequest: + _logger.Warn(ex, "Prowlarr URL is invalid"); + 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.Warn(ex, "Unable to complete application test"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Whisparr. {ex.Message}")); + break; + } + } + catch (JsonReaderException ex) + { + _logger.Error(ex, "Unable to parse JSON response from application"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to parse JSON response from application. {ex.Message}")); + } + catch (Exception ex) + { + _logger.Warn(ex, "Unable to complete application test"); + failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Whisparr. {ex.Message}")); } return new ValidationResult(failures); @@ -61,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 }); } } @@ -84,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); @@ -93,9 +130,17 @@ 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); + + if (remoteIndexer == null) + { + _logger.Debug("Failed to add {0} [{1}]", indexer.Name, indexer.Id); + + return; + } + _appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerId = remoteIndexer.Id }); } @@ -113,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))); @@ -138,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); } @@ -153,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; @@ -167,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"); @@ -197,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 3f888b769..0dfafc166 100644 --- a/src/NzbDrone.Core/Applications/Whisparr/WhisparrSettings.cs +++ b/src/NzbDrone.Core/Applications/Whisparr/WhisparrSettings.cs @@ -34,12 +34,15 @@ namespace NzbDrone.Core.Applications.Whisparr [FieldDefinition(1, Label = "Whisparr Server", HelpText = "URL used to connect to Whisparr server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:6969")] public string BaseUrl { get; set; } - [FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Whisparr in Settings/General")] + [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 4a80cbed5..e2ee60524 100644 --- a/src/NzbDrone.Core/Applications/Whisparr/WhisparrV3Proxy.cs +++ b/src/NzbDrone.Core/Applications/Whisparr/WhisparrV3Proxy.cs @@ -81,8 +81,18 @@ namespace NzbDrone.Core.Applications.Whisparr var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Post); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); - return Execute(request); + try + { + return ExecuteIndexerRequest(request); + } + catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest) + { + request.Url = request.Url.AddQueryParam("forceSave", "true"); + + return ExecuteIndexerRequest(request); + } } public WhisparrIndexer UpdateIndexer(WhisparrIndexer indexer, WhisparrSettings settings) @@ -90,8 +100,20 @@ namespace NzbDrone.Core.Applications.Whisparr var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put); 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) @@ -99,39 +121,9 @@ namespace NzbDrone.Core.Applications.Whisparr var request = BuildRequest(settings, $"{AppIndexerApiRoute}/test", HttpMethod.Post); request.SetContent(indexer.ToJson()); + request.ContentSummary = indexer.ToJson(Formatting.None); - try - { - Execute(request); - } - catch (HttpException ex) - { - if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) - { - _logger.Error(ex, "API Key is invalid"); - return new ValidationFailure("ApiKey", "API Key is invalid"); - } - - if (ex.Response.StatusCode == HttpStatusCode.BadRequest) - { - _logger.Error(ex, "Prowlarr URL is invalid"); - return new ValidationFailure("ProwlarrUrl", "Prowlarr url is invalid, Whisparr cannot connect to Prowlarr"); - } - - if (ex.Response.StatusCode == HttpStatusCode.SeeOther) - { - _logger.Error(ex, "Whisparr returned redirect and is invalid"); - return new ValidationFailure("BaseUrl", "Whisparr url is invalid, Prowlarr cannot connect to Whisparr - are you missing a url base?"); - } - - _logger.Error(ex, "Unable to send test message"); - return new ValidationFailure("BaseUrl", "Unable to complete application test"); - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to send test message"); - return new ValidationFailure("", "Unable to send test message"); - } + _httpClient.Post(request); return null; } @@ -147,27 +139,30 @@ namespace NzbDrone.Core.Applications.Whisparr switch (ex.Response.StatusCode) { case HttpStatusCode.Unauthorized: - _logger.Error(ex, "API Key is invalid"); + _logger.Warn(ex, "API Key is invalid"); break; case HttpStatusCode.BadRequest: if (ex.Response.Content.Contains("Query successful, but no results in the configured categories were returned from your indexer.", StringComparison.InvariantCultureIgnoreCase)) { - _logger.Error(ex, "No Results in configured categories. See FAQ Entry: Prowlarr will not sync X Indexer to App"); + _logger.Warn(ex, "No Results in configured categories. See FAQ Entry: Prowlarr will not sync X Indexer to App"); break; } _logger.Error(ex, "Invalid Request"); break; case HttpStatusCode.SeeOther: - _logger.Error(ex, "App returned redirect and is invalid. Check App URL"); + case HttpStatusCode.TemporaryRedirect: + _logger.Warn(ex, "App returned redirect and is invalid. Check App URL"); break; case HttpStatusCode.NotFound: - _logger.Error(ex, "Remote indexer not found"); + _logger.Warn(ex, "Remote indexer not found"); break; default: _logger.Error(ex, "Unexpected response status code: {0}", ex.Response.StatusCode); - throw; + break; } + + throw; } catch (JsonReaderException ex) { @@ -179,15 +174,15 @@ namespace NzbDrone.Core.Applications.Whisparr _logger.Error(ex, "Unable to add or update indexer"); throw; } - - return null; } private HttpRequest BuildRequest(WhisparrSettings settings, string resource, HttpMethod method) { var baseUrl = settings.BaseUrl.TrimEnd('/'); - var request = new HttpRequestBuilder(baseUrl).Resource(resource) + var request = new HttpRequestBuilder(baseUrl) + .Resource(resource) + .Accept(HttpAccept.Json) .SetHeader("X-Api-Key", settings.ApiKey) .Build(); @@ -204,9 +199,12 @@ namespace NzbDrone.Core.Applications.Whisparr { var response = _httpClient.Execute(request); - var results = JsonConvert.DeserializeObject(response.Content); + if ((int)response.StatusCode >= 300) + { + throw new HttpException(response); + } - return results; + return Json.Deserialize(response.Content); } } } 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 ddd4a98bc..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() @@ -121,8 +142,7 @@ namespace NzbDrone.Core.Configuration continue; } - object currentValue; - allWithDefaults.TryGetValue(configValue.Key, out currentValue); + allWithDefaults.TryGetValue(configValue.Key, out var currentValue); if (currentValue == null) { continue; @@ -145,7 +165,7 @@ namespace NzbDrone.Core.Configuration { const string defaultValue = "*"; - string bindAddress = GetValue("BindAddress", defaultValue); + var bindAddress = _serverOptions.BindAddress ?? GetValue("BindAddress", defaultValue); if (string.IsNullOrWhiteSpace(bindAddress)) { return defaultValue; @@ -155,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()) { @@ -183,7 +203,7 @@ namespace NzbDrone.Core.Configuration { get { - var enabled = GetValueBoolean("AuthenticationEnabled", false, false); + var enabled = _authOptions.Enabled ?? GetValueBoolean("AuthenticationEnabled", false, false); if (enabled) { @@ -191,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) { @@ -278,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(); }); } @@ -326,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(); @@ -367,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) @@ -397,6 +470,7 @@ namespace NzbDrone.Core.Configuration public void HandleAsync(ApplicationStartedEvent message) { + MigrateConfigFile(); EnsureDefaultConfigFile(); DeleteOldValues(); } @@ -406,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 8fb717ce6..27a953823 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -53,8 +53,7 @@ namespace NzbDrone.Core.Configuration foreach (var configValue in configValues) { - object currentValue; - allWithDefaults.TryGetValue(configValue.Key, out currentValue); + allWithDefaults.TryGetValue(configValue.Key, out var currentValue); if (currentValue == null || configValue.Value == null) { continue; @@ -78,7 +77,7 @@ namespace NzbDrone.Core.Configuration public int HistoryCleanupDays { - get { return GetValueInt("HistoryCleanupDays", 365); } + get { return GetValueInt("HistoryCleanupDays", 30); } set { SetValue("HistoryCleanupDays", value); } } @@ -184,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); @@ -211,9 +216,7 @@ namespace NzbDrone.Core.Configuration EnsureCache(); - string dbValue; - - if (_cache.TryGetValue(key, out dbValue) && dbValue != null && !string.IsNullOrEmpty(dbValue)) + if (_cache.TryGetValue(key, out var dbValue) && dbValue != null && !string.IsNullOrEmpty(dbValue)) { return dbValue; } diff --git a/src/NzbDrone.Core/Datastore/BasicRepository.cs b/src/NzbDrone.Core/Datastore/BasicRepository.cs index 659a69e7f..796e277b7 100644 --- a/src/NzbDrone.Core/Datastore/BasicRepository.cs +++ b/src/NzbDrone.Core/Datastore/BasicRepository.cs @@ -16,6 +16,7 @@ namespace NzbDrone.Core.Datastore { IEnumerable All(); int Count(); + TModel Find(int id); TModel Get(int id); TModel Insert(TModel model); TModel Update(TModel model); @@ -87,10 +88,17 @@ namespace NzbDrone.Core.Datastore return Query(Builder()); } - public TModel Get(int id) + public TModel Find(int id) { var model = Query(x => x.Id == id).FirstOrDefault(); + return model; + } + + public TModel Get(int id) + { + var model = Find(id); + if (model == null) { throw new ModelNotFoundException(typeof(TModel), id); @@ -196,7 +204,7 @@ namespace NzbDrone.Core.Datastore using (var conn = _database.OpenConnection()) { - using (IDbTransaction tran = conn.BeginTransaction(IsolationLevel.ReadCommitted)) + using (var tran = conn.BeginTransaction(IsolationLevel.ReadCommitted)) { foreach (var model in models) { @@ -246,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/CommandConverter.cs b/src/NzbDrone.Core/Datastore/Converters/CommandConverter.cs index e2f77c6f0..790464f3f 100644 --- a/src/NzbDrone.Core/Datastore/Converters/CommandConverter.cs +++ b/src/NzbDrone.Core/Datastore/Converters/CommandConverter.cs @@ -18,7 +18,7 @@ namespace NzbDrone.Core.Datastore.Converters } string contract; - using (JsonDocument body = JsonDocument.Parse(stringValue)) + using (var body = JsonDocument.Parse(stringValue)) { contract = body.RootElement.GetProperty("name").GetString(); } 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/031_apprise_server_url.cs b/src/NzbDrone.Core/Datastore/Migration/031_apprise_server_url.cs index 409390649..f49586e7e 100644 --- a/src/NzbDrone.Core/Datastore/Migration/031_apprise_server_url.cs +++ b/src/NzbDrone.Core/Datastore/Migration/031_apprise_server_url.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Data; using Dapper; using FluentMigrator; @@ -17,33 +18,43 @@ namespace NzbDrone.Core.Datastore.Migration private void MigrateToServerUrl(IDbConnection conn, IDbTransaction tran) { - using var selectCommand = conn.CreateCommand(); - selectCommand.Transaction = tran; - selectCommand.CommandText = "SELECT \"Id\", \"Settings\" FROM \"Notifications\" WHERE \"Implementation\" = 'Apprise'"; + var updatedNotifications = new List(); - using var reader = selectCommand.ExecuteReader(); - - while (reader.Read()) + using (var selectCommand = conn.CreateCommand()) { - var id = reader.GetInt32(0); - var settings = reader.GetString(1); + selectCommand.Transaction = tran; + selectCommand.CommandText = "SELECT \"Id\", \"Settings\" FROM \"Notifications\" WHERE \"Implementation\" = 'Apprise'"; - if (!string.IsNullOrWhiteSpace(settings)) + using var reader = selectCommand.ExecuteReader(); + + while (reader.Read()) { - var jsonObject = Json.Deserialize(settings); + var id = reader.GetInt32(0); + var settings = reader.GetString(1); - if (jsonObject.ContainsKey("baseUrl")) + if (!string.IsNullOrWhiteSpace(settings)) { - jsonObject.Add("serverUrl", jsonObject.Value("baseUrl")); - jsonObject.Remove("baseUrl"); + var jsonObject = Json.Deserialize(settings); + + if (jsonObject.ContainsKey("baseUrl")) + { + jsonObject.Add("serverUrl", jsonObject.Value("baseUrl")); + jsonObject.Remove("baseUrl"); + } + + settings = jsonObject.ToJson(); } - settings = jsonObject.ToJson(); + updatedNotifications.Add(new + { + Id = id, + Settings = settings + }); } - - var parameters = new { Settings = settings, Id = id }; - conn.Execute("UPDATE Notifications SET Settings = @Settings WHERE Id = @Id", parameters, transaction: tran); } + + var updateNotificationsSql = "UPDATE \"Notifications\" SET \"Settings\" = @Settings WHERE \"Id\" = @Id"; + conn.Execute(updateNotificationsSql, updatedNotifications, transaction: tran); } } } diff --git a/src/NzbDrone.Core/Datastore/Migration/034_history_fix_data_titles.cs b/src/NzbDrone.Core/Datastore/Migration/034_history_fix_data_titles.cs new file mode 100644 index 000000000..450c33cb4 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/034_history_fix_data_titles.cs @@ -0,0 +1,72 @@ +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(034)] + public class history_fix_data_titles : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.WithConnection(MigrateHistoryDataTitle); + } + + private void MigrateHistoryDataTitle(IDbConnection conn, IDbTransaction tran) + { + var updatedHistory = new List(); + + using (var selectCommand = conn.CreateCommand()) + { + selectCommand.Transaction = tran; + selectCommand.CommandText = "SELECT \"Id\", \"Data\", \"EventType\" FROM \"History\" WHERE \"EventType\" != 3"; + + using var reader = selectCommand.ExecuteReader(); + + while (reader.Read()) + { + var id = reader.GetInt32(0); + var data = reader.GetString(1); + var eventType = reader.GetInt32(2); + + if (!string.IsNullOrWhiteSpace(data)) + { + var jsonObject = Json.Deserialize(data); + + if (eventType == 1 && jsonObject.ContainsKey("title")) + { + jsonObject.Add("grabTitle", jsonObject.Value("title")); + jsonObject.Remove("title"); + } + + if (eventType != 1 && jsonObject.ContainsKey("bookTitle")) + { + jsonObject.Add("title", jsonObject.Value("bookTitle")); + jsonObject.Remove("bookTitle"); + } + + data = jsonObject.ToJson(); + + if (!jsonObject.ContainsKey("grabTitle") && !jsonObject.ContainsKey("title")) + { + continue; + } + + updatedHistory.Add(new + { + Id = id, + Data = data + }); + } + } + } + + var updateHistorySql = "UPDATE \"History\" SET \"Data\" = @Data WHERE \"Id\" = @Id"; + conn.Execute(updateHistorySql, updatedHistory, transaction: tran); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/035_download_client_per_indexer.cs b/src/NzbDrone.Core/Datastore/Migration/035_download_client_per_indexer.cs new file mode 100644 index 000000000..658bbaaaa --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/035_download_client_per_indexer.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(035)] + public class download_client_per_indexer : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Indexers").AddColumn("DownloadClientId").AsInt32().WithDefaultValue(0); + } + } +} 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 4ae4e6e68..a56384466 100644 --- a/src/NzbDrone.Core/Datastore/Migration/Framework/SqliteSchemaDumper.cs +++ b/src/NzbDrone.Core/Datastore/Migration/Framework/SqliteSchemaDumper.cs @@ -201,7 +201,7 @@ namespace NzbDrone.Core.Datastore.Migration.Framework public virtual IList ReadDbSchema() { - IList tables = ReadTables(); + var tables = ReadTables(); foreach (var table in tables) { table.Indexes = ReadIndexes(table.SchemaName, table.Name); @@ -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(); @@ -264,7 +264,7 @@ namespace NzbDrone.Core.Datastore.Migration.Framework protected virtual IList ReadIndexes(string schemaName, string tableName) { var sqlCommand = string.Format(@"SELECT type, name, sql FROM sqlite_master WHERE tbl_name = '{0}' AND type = 'index' AND name NOT LIKE 'sqlite_auto%';", tableName); - DataTable table = Read(sqlCommand).Tables[0]; + var table = Read(sqlCommand).Tables[0]; IList indexes = 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 902985371..3085dbf63 100644 --- a/src/NzbDrone.Core/Download/Clients/Aria2/Aria2.cs +++ b/src/NzbDrone.Core/Download/Clients/Aria2/Aria2.cs @@ -5,8 +5,9 @@ using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Validation; @@ -22,16 +23,17 @@ namespace NzbDrone.Core.Download.Clients.Aria2 public Aria2(IAria2Proxy proxy, ITorrentFileInfoReader torrentFileInfoReader, - IHttpClient httpClient, + ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxy = proxy; } - protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + protected override string AddFromMagnetLink(TorrentInfo release, string hash, string magnetLink) { var gid = _proxy.AddUri(Settings, magnetLink); @@ -50,7 +52,7 @@ namespace NzbDrone.Core.Download.Clients.Aria2 return hash; } - protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(TorrentInfo release, string hash, string filename, byte[] fileContent) { var gid = _proxy.AddTorrent(Settings, fileContent); @@ -120,7 +122,7 @@ namespace NzbDrone.Core.Download.Clients.Aria2 return null; } - protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + protected override string AddFromTorrentLink(TorrentInfo release, string hash, string torrentLink) { var gid = _proxy.AddUri(Settings, torrentLink); 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 5f6dd7a9d..9e22a7460 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackhole.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackhole.cs @@ -6,8 +6,9 @@ using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.Download.Clients.Blackhole @@ -17,20 +18,21 @@ namespace NzbDrone.Core.Download.Clients.Blackhole public override bool PreferTorrentFile => true; public TorrentBlackhole(ITorrentFileInfoReader torrentFileInfoReader, - IHttpClient httpClient, + ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { } - protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + protected override string AddFromTorrentLink(TorrentInfo release, string hash, string torrentLink) { throw new NotImplementedException("Blackhole does not support redirected indexers."); } - protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + protected override string AddFromMagnetLink(TorrentInfo release, string hash, string magnetLink) { if (!Settings.SaveMagnetFiles) { @@ -54,7 +56,7 @@ namespace NzbDrone.Core.Download.Clients.Blackhole return null; } - protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(TorrentInfo release, string hash, string filename, byte[] fileContent) { var title = release.Title; 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 9ea829e6a..90bd6ba1f 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs @@ -7,8 +7,9 @@ using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Validation; @@ -20,16 +21,17 @@ namespace NzbDrone.Core.Download.Clients.Deluge public Deluge(IDelugeProxy proxy, ITorrentFileInfoReader torrentFileInfoReader, - IHttpClient httpClient, + ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxy = proxy; } - protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + protected override string AddFromMagnetLink(TorrentInfo release, string hash, string magnetLink) { var actualHash = _proxy.AddTorrentFromMagnet(magnetLink, Settings); @@ -38,8 +40,10 @@ namespace NzbDrone.Core.Download.Clients.Deluge throw new DownloadClientException("Deluge failed to add magnet " + magnetLink); } - // _proxy.SetTorrentSeedingConfiguration(actualHash, remoteMovie.SeedConfiguration, Settings); + _proxy.SetTorrentSeedingConfiguration(actualHash, release.SeedConfiguration, Settings); + var category = GetCategoryForRelease(release) ?? Settings.Category; + if (category.IsNotNullOrWhiteSpace()) { _proxy.SetTorrentLabel(actualHash, category, Settings); @@ -53,7 +57,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge return actualHash.ToUpper(); } - protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(TorrentInfo release, string hash, string filename, byte[] fileContent) { var actualHash = _proxy.AddTorrentFromFile(filename, fileContent, Settings); @@ -62,8 +66,10 @@ namespace NzbDrone.Core.Download.Clients.Deluge throw new DownloadClientException("Deluge failed to add torrent " + filename); } - // _proxy.SetTorrentSeedingConfiguration(actualHash, release.SeedConfiguration, Settings); + _proxy.SetTorrentSeedingConfiguration(actualHash, release.SeedConfiguration, Settings); + var category = GetCategoryForRelease(release) ?? Settings.Category; + if (category.IsNotNullOrWhiteSpace()) { _proxy.SetTorrentLabel(actualHash, category, Settings); @@ -117,12 +123,12 @@ namespace NzbDrone.Core.Download.Clients.Deluge case WebExceptionStatus.ConnectionClosed: return new NzbDroneValidationFailure("UseSsl", "Verify SSL settings") { - DetailedDescription = "Please verify your SSL configuration on both Deluge and NzbDrone." + DetailedDescription = "Please verify your SSL configuration on both Deluge and Prowlarr." }; case WebExceptionStatus.SecureChannelFailure: return new NzbDroneValidationFailure("UseSsl", "Unable to connect through SSL") { - DetailedDescription = "Drone is unable to connect to Deluge using SSL. This problem could be computer related. Please try to configure both drone and Deluge to not use SSL." + DetailedDescription = "Prowlarr is unable to connect to Deluge using SSL. This problem could be computer related. Please try to configure both Prowlarr and Deluge to not use SSL." }; default: return new NzbDroneValidationFailure(string.Empty, "Unknown exception: " + ex.Message); @@ -211,7 +217,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge return null; } - protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + protected override string AddFromTorrentLink(TorrentInfo release, string hash, string torrentLink) { throw new NotImplementedException(); } diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeException.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeException.cs index 45df09c8c..fb6ca2a8a 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeException.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeException.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.Download.Clients.Deluge +namespace NzbDrone.Core.Download.Clients.Deluge { public class DelugeException : DownloadClientException { diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs index 6396052c3..7639ad7df 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs @@ -82,7 +82,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge var filter = new Dictionary(); // TODO: get_torrents_status returns the files as well, which starts to cause deluge timeouts when you get enough season packs. - //var response = ProcessRequest>(settings, "core.get_torrents_status", filter, new String[0]); + // var response = ProcessRequest>(settings, "core.get_torrents_status", filter, new String[0]); var response = ProcessRequest(settings, "web.update_ui", RequiredProperties, filter); return GetTorrents(response); @@ -93,7 +93,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge var filter = new Dictionary(); filter.Add("label", label); - //var response = ProcessRequest>(settings, "core.get_torrents_status", filter, new String[0]); + // var response = ProcessRequest>(settings, "core.get_torrents_status", filter, new String[0]); var response = ProcessRequest(settings, "web.update_ui", RequiredProperties, filter); return GetTorrents(response); @@ -203,7 +203,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge private JsonRpcRequestBuilder BuildRequest(DelugeSettings settings) { - string url = HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase); + var url = HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase); var requestBuilder = new JsonRpcRequestBuilder(url); requestBuilder.LogResponseContent = true; 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/Deluge/DelugeTorrentStatus.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrentStatus.cs index e9f5a6a70..52114f241 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrentStatus.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrentStatus.cs @@ -1,6 +1,6 @@ -namespace NzbDrone.Core.Download.Clients.Deluge +namespace NzbDrone.Core.Download.Clients.Deluge { - internal class DelugeTorrentStatus + public class DelugeTorrentStatus { public const string Paused = "Paused"; public const string Queued = "Queued"; 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 42b5bb1f2..97afe9480 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs @@ -7,9 +7,10 @@ using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; 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, - IHttpClient httpClient, + ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, httpClient, 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(ReleaseInfo release, string hash, string magnetLink) + 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); @@ -72,11 +76,11 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation throw new DownloadClientException("Failed to add magnet task to Download Station"); } - protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(TorrentInfo release, string hash, 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"] == Path.GetFileNameWithoutExtension(filename)); @@ -130,9 +134,8 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation protected long GetRemainingSize(DownloadStationTask torrent) { var downloadedString = torrent.Additional.Transfer["size_downloaded"]; - long downloadedSize; - if (downloadedString.IsNullOrWhiteSpace() || !long.TryParse(downloadedString, out downloadedSize)) + if (downloadedString.IsNullOrWhiteSpace() || !long.TryParse(downloadedString, out var downloadedSize)) { _logger.Debug("Torrent {0} has invalid size_downloaded: {1}", torrent.Title, downloadedString); downloadedSize = 0; @@ -144,9 +147,8 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation protected TimeSpan? GetRemainingTime(DownloadStationTask torrent) { var speedString = torrent.Additional.Transfer["speed_download"]; - long downloadSpeed; - if (speedString.IsNullOrWhiteSpace() || !long.TryParse(speedString, out downloadSpeed)) + if (speedString.IsNullOrWhiteSpace() || !long.TryParse(speedString, out var downloadSpeed)) { _logger.Debug("Torrent {0} has invalid speed_download: {1}", torrent.Title, speedString); downloadSpeed = 0; @@ -219,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); } @@ -270,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); @@ -307,17 +310,18 @@ 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(ReleaseInfo release, string hash, string torrentLink) + protected override string AddFromTorrentLink(TorrentInfo release, string hash, string torrentLink) { throw new NotImplementedException(); } diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs index 2cf42704b..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); @@ -211,9 +216,8 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation protected long GetRemainingSize(DownloadStationTask task) { var downloadedString = task.Additional.Transfer["size_downloaded"]; - long downloadedSize; - if (downloadedString.IsNullOrWhiteSpace() || !long.TryParse(downloadedString, out downloadedSize)) + if (downloadedString.IsNullOrWhiteSpace() || !long.TryParse(downloadedString, out var downloadedSize)) { _logger.Debug("Task {0} has invalid size_downloaded: {1}", task.Title, downloadedString); downloadedSize = 0; @@ -225,9 +229,8 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation protected long GetDownloadSpeed(DownloadStationTask task) { var speedString = task.Additional.Transfer["speed_download"]; - long downloadSpeed; - if (speedString.IsNullOrWhiteSpace() || !long.TryParse(speedString, out downloadSpeed)) + if (speedString.IsNullOrWhiteSpace() || !long.TryParse(speedString, out var downloadSpeed)) { _logger.Debug("Task {0} has invalid speed_download: {1}", task.Title, speedString); downloadSpeed = 0; @@ -273,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 f292eaf53..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.Http; +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; @@ -18,11 +20,12 @@ namespace NzbDrone.Core.Download.Clients.Flood public Flood(IFloodProxy proxy, ITorrentFileInfoReader torrentFileInfoReader, - IHttpClient httpClient, + ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxy = proxy; } @@ -56,21 +59,21 @@ namespace NzbDrone.Core.Download.Clients.Flood } } - return result; + return result.Where(t => t.IsNotNullOrWhiteSpace()); } public override string Name => "Flood"; public override bool SupportsCategories => true; public override ProviderMessage Message => new ProviderMessage("Prowlarr is unable to remove torrents that have finished seeding when using Flood", ProviderMessageType.Warning); - protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(TorrentInfo release, string hash, string filename, byte[] fileContent) { _proxy.AddTorrentByFile(Convert.ToBase64String(fileContent), HandleTags(release, Settings, GetCategoryForRelease(release)), Settings); return hash; } - protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + protected override string AddFromMagnetLink(TorrentInfo release, string hash, string magnetLink) { _proxy.AddTorrentByUrl(magnetLink, HandleTags(release, Settings, GetCategoryForRelease(release)), Settings); @@ -93,7 +96,7 @@ namespace NzbDrone.Core.Download.Clients.Flood } } - protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + protected override string AddFromTorrentLink(TorrentInfo release, string hash, string torrentLink) { throw new NotImplementedException(); } 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/FreeboxDownloadEncoding.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadEncoding.cs index 0e12570aa..7c1ef310b 100644 --- a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadEncoding.cs +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadEncoding.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.Download.Clients.FreeboxDownload +namespace NzbDrone.Core.Download.Clients.FreeboxDownload { public static class EncodingForBase64 { @@ -9,7 +9,7 @@ return null; } - byte[] textAsBytes = System.Text.Encoding.UTF8.GetBytes(text); + var textAsBytes = System.Text.Encoding.UTF8.GetBytes(text); return System.Convert.ToBase64String(textAsBytes); } @@ -20,7 +20,7 @@ return null; } - byte[] textAsBytes = System.Convert.FromBase64String(encodedText); + var textAsBytes = System.Convert.FromBase64String(encodedText); return System.Text.Encoding.UTF8.GetString(textAsBytes); } } diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadProxy.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadProxy.cs index 1c40a3832..ae952e2b9 100644 --- a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadProxy.cs @@ -14,8 +14,8 @@ namespace NzbDrone.Core.Download.Clients.FreeboxDownload public interface IFreeboxDownloadProxy { void Authenticate(FreeboxDownloadSettings settings); - string AddTaskFromUrl(string url, string directory, bool addPaused, bool addFirst, FreeboxDownloadSettings settings); - string AddTaskFromFile(string fileName, byte[] fileContent, string directory, bool addPaused, bool addFirst, FreeboxDownloadSettings settings); + string AddTaskFromUrl(string url, string directory, bool addPaused, bool addFirst, double? seedRatio, FreeboxDownloadSettings settings); + string AddTaskFromFile(string fileName, byte[] fileContent, string directory, bool addPaused, bool addFirst, double? seedRatio, FreeboxDownloadSettings settings); void DeleteTask(string id, bool deleteData, FreeboxDownloadSettings settings); FreeboxDownloadConfiguration GetDownloadConfiguration(FreeboxDownloadSettings settings); List GetTasks(FreeboxDownloadSettings settings); @@ -46,7 +46,7 @@ namespace NzbDrone.Core.Download.Clients.FreeboxDownload } } - public string AddTaskFromUrl(string url, string directory, bool addPaused, bool addFirst, FreeboxDownloadSettings settings) + public string AddTaskFromUrl(string url, string directory, bool addPaused, bool addFirst, double? seedRatio, FreeboxDownloadSettings settings) { var request = BuildRequest(settings).Resource("/downloads/add").Post(); request.Headers.ContentType = "application/x-www-form-urlencoded"; @@ -60,12 +60,12 @@ namespace NzbDrone.Core.Download.Clients.FreeboxDownload var response = ProcessRequest(request.Build(), settings); - SetTorrentSettings(response.Result.Id, addPaused, addFirst, settings); + SetTorrentSettings(response.Result.Id, addPaused, addFirst, seedRatio, settings); return response.Result.Id; } - public string AddTaskFromFile(string fileName, byte[] fileContent, string directory, bool addPaused, bool addFirst, FreeboxDownloadSettings settings) + public string AddTaskFromFile(string fileName, byte[] fileContent, string directory, bool addPaused, bool addFirst, double? seedRatio, FreeboxDownloadSettings settings) { var request = BuildRequest(settings).Resource("/downloads/add").Post(); @@ -78,7 +78,7 @@ namespace NzbDrone.Core.Download.Clients.FreeboxDownload var response = ProcessRequest(request.Build(), settings); - SetTorrentSettings(response.Result.Id, addPaused, addFirst, settings); + SetTorrentSettings(response.Result.Id, addPaused, addFirst, seedRatio, settings); return response.Result.Id; } @@ -118,7 +118,7 @@ namespace NzbDrone.Core.Download.Clients.FreeboxDownload return $"{settings.Host}:{settings.AppId}:{settings.AppToken}"; } - private void SetTorrentSettings(string id, bool addPaused, bool addFirst, FreeboxDownloadSettings settings) + private void SetTorrentSettings(string id, bool addPaused, bool addFirst, double? seedRatio, FreeboxDownloadSettings settings) { var request = BuildRequest(settings).Resource("/downloads/" + id).Build(); @@ -136,6 +136,12 @@ namespace NzbDrone.Core.Download.Clients.FreeboxDownload body.Add("queue_pos", "1"); } + if (seedRatio != null) + { + // 0 means unlimited seeding + body.Add("stop_ratio", seedRatio); + } + if (body.Count == 0) { return; diff --git a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadSettings.cs b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadSettings.cs index 3810f8e7e..effdf37d9 100644 --- a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/FreeboxDownloadSettings.cs @@ -36,7 +36,7 @@ namespace NzbDrone.Core.Download.Clients.FreeboxDownload public class FreeboxDownloadSettings : IProviderConfig { - private static readonly FreeboxDownloadSettingsValidator Validator = new FreeboxDownloadSettingsValidator(); + private static readonly FreeboxDownloadSettingsValidator Validator = new (); public FreeboxDownloadSettings() { @@ -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(10, 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 a7f3ace5e..00e7e06b4 100644 --- a/src/NzbDrone.Core/Download/Clients/FreeboxDownload/TorrentFreeboxDownload.cs +++ b/src/NzbDrone.Core/Download/Clients/FreeboxDownload/TorrentFreeboxDownload.cs @@ -1,13 +1,12 @@ -using System.Collections.Generic; -using System.Linq; +using System.Collections.Generic; using System.Text.RegularExpressions; using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; -using NzbDrone.Core.Download.Clients.FreeboxDownload.Responses; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.Download.Clients.FreeboxDownload @@ -18,49 +17,47 @@ namespace NzbDrone.Core.Download.Clients.FreeboxDownload public TorrentFreeboxDownload(IFreeboxDownloadProxy proxy, ITorrentFileInfoReader torrentFileInfoReader, - IHttpClient httpClient, + ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxy = proxy; } public override string Name => "Freebox Download"; - public override bool SupportsCategories => true; - protected IEnumerable GetTorrents() - { - return _proxy.GetTasks(Settings).Where(v => v.Type.ToLower() == FreeboxDownloadTaskType.Bt.ToString().ToLower()); - } - - protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + protected override string AddFromMagnetLink(TorrentInfo release, string hash, string magnetLink) { return _proxy.AddTaskFromUrl(magnetLink, GetDownloadDirectory(release).EncodeBase64(), ToBePaused(), ToBeQueuedFirst(), + GetSeedRatio(release), Settings); } - protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(TorrentInfo release, string hash, string filename, byte[] fileContent) { return _proxy.AddTaskFromFile(filename, fileContent, GetDownloadDirectory(release).EncodeBase64(), ToBePaused(), ToBeQueuedFirst(), + GetSeedRatio(release), Settings); } - protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + protected override string AddFromTorrentLink(TorrentInfo release, string hash, string torrentLink) { return _proxy.AddTaskFromUrl(torrentLink, GetDownloadDirectory(release).EncodeBase64(), ToBePaused(), ToBeQueuedFirst(), + GetSeedRatio(release), Settings); } @@ -108,16 +105,21 @@ namespace NzbDrone.Core.Download.Clients.FreeboxDownload var destDir = _proxy.GetDownloadConfiguration(Settings).DecodedDownloadDirectory.TrimEnd('/'); - if (Settings.Category.IsNotNullOrWhiteSpace()) - { - var category = GetCategoryForRelease(release) ?? Settings.Category; + var category = GetCategoryForRelease(release) ?? Settings.Category; + if (category.IsNotNullOrWhiteSpace()) + { destDir = $"{destDir}/{category}"; } return destDir; } + private bool ToBePaused() + { + return Settings.AddPaused; + } + private bool ToBeQueuedFirst() { if (Settings.Priority == (int)FreeboxDownloadPriority.First) @@ -128,9 +130,14 @@ namespace NzbDrone.Core.Download.Clients.FreeboxDownload return false; } - private bool ToBePaused() + private double? GetSeedRatio(TorrentInfo release) { - return Settings.AddPaused; + if (release.SeedConfiguration == null || release.SeedConfiguration.Ratio == null) + { + return null; + } + + return release.SeedConfiguration.Ratio.Value * 100; } } } diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs index a5ca2e4fc..560c40eb3 100644 --- a/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs @@ -4,8 +4,9 @@ using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Validation; @@ -17,11 +18,12 @@ namespace NzbDrone.Core.Download.Clients.Hadouken public Hadouken(IHadoukenProxy proxy, ITorrentFileInfoReader torrentFileInfoReader, - IHttpClient httpClient, + ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxy = proxy; } @@ -40,14 +42,14 @@ namespace NzbDrone.Core.Download.Clients.Hadouken failures.AddIfNotNull(TestGetTorrents()); } - protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + protected override string AddFromMagnetLink(TorrentInfo release, string hash, string magnetLink) { _proxy.AddTorrentUri(Settings, magnetLink, GetCategoryForRelease(release) ?? Settings.Category); return hash.ToUpper(); } - protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(TorrentInfo release, string hash, string filename, byte[] fileContent) { return _proxy.AddTorrentFile(Settings, fileContent, GetCategoryForRelease(release) ?? Settings.Category).ToUpper(); } @@ -97,7 +99,7 @@ namespace NzbDrone.Core.Download.Clients.Hadouken return null; } - protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + protected override string AddFromTorrentLink(TorrentInfo release, string hash, string torrentLink) { throw new NotImplementedException(); } 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/JsonConverters/NzbVortexLoginResultTypeConverter.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/JsonConverters/NzbVortexLoginResultTypeConverter.cs index e74b8f973..7f0f27de7 100644 --- a/src/NzbDrone.Core/Download/Clients/NzbVortex/JsonConverters/NzbVortexLoginResultTypeConverter.cs +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/JsonConverters/NzbVortexLoginResultTypeConverter.cs @@ -15,8 +15,7 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex.JsonConverters { var result = reader.Value.ToString().Replace("_", string.Empty); - NzbVortexLoginResultType output; - Enum.TryParse(result, true, out output); + Enum.TryParse(result, true, out NzbVortexLoginResultType output); return output; } diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/JsonConverters/NzbVortexResultTypeConverter.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/JsonConverters/NzbVortexResultTypeConverter.cs index bd63788bc..aa515abdb 100644 --- a/src/NzbDrone.Core/Download/Clients/NzbVortex/JsonConverters/NzbVortexResultTypeConverter.cs +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/JsonConverters/NzbVortexResultTypeConverter.cs @@ -15,8 +15,7 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex.JsonConverters { var result = reader.Value.ToString().Replace("_", string.Empty); - NzbVortexResultType output; - Enum.TryParse(result, true, out output); + Enum.TryParse(result, true, out NzbVortexResultType output); return output; } 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 0aa160cda..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; } @@ -70,7 +70,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget protected IEnumerable GetCategories(Dictionary config) { - for (int i = 1; i < 100; i++) + for (var i = 1; i < 100; i++) { var name = config.GetValueOrDefault("Category" + i + ".Name"); @@ -169,8 +169,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget var config = _proxy.GetConfig(Settings); var keepHistory = config.GetValueOrDefault("KeepHistory", "7"); - int value; - if (!int.TryParse(keepHistory, NumberStyles.None, CultureInfo.InvariantCulture, out value) || value == 0) + if (!int.TryParse(keepHistory, NumberStyles.None, CultureInfo.InvariantCulture, out var value) || value == 0) { return new NzbDroneValidationFailure(string.Empty, "NzbGet setting KeepHistory should be greater than 0") { diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs index 76cff3220..1d0b6d644 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs @@ -177,11 +177,10 @@ namespace NzbDrone.Core.Download.Clients.Nzbget var queue = GetQueue(settings); var history = GetHistory(settings); - int nzbId; NzbgetQueueItem queueItem; NzbgetHistoryItem historyItem; - if (id.Length < 10 && int.TryParse(id, out nzbId)) + if (id.Length < 10 && int.TryParse(id, out var nzbId)) { // Download wasn't grabbed by Prowlarr, so the id is the NzbId reported by nzbget. queueItem = queue.SingleOrDefault(h => h.NzbId == nzbId); 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 95dd9e30b..3d1863784 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -6,8 +6,9 @@ using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Validation; @@ -26,12 +27,13 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent public QBittorrent(IQBittorrentProxySelector proxySelector, ITorrentFileInfoReader torrentFileInfoReader, - IHttpClient httpClient, + ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, ICacheManager cacheManager, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxySelector = proxySelector; @@ -41,33 +43,41 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent private IQBittorrentProxy Proxy => _proxySelector.GetProxy(Settings); private Version ProxyApiVersion => _proxySelector.GetApiVersion(Settings); - protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + protected override string AddFromMagnetLink(TorrentInfo release, string hash, string magnetLink) { if (!Proxy.GetConfig(Settings).DhtEnabled && !magnetLink.Contains("&tr=")) { throw new NotSupportedException("Magnet Links without trackers not supported if DHT is disabled"); } - //var setShareLimits = release.SeedConfiguration != null && (release.SeedConfiguration.Ratio.HasValue || release.SeedConfiguration.SeedTime.HasValue); - //var addHasSetShareLimits = setShareLimits && ProxyApiVersion >= new Version(2, 8, 1); - var itemToTop = Settings.Priority == (int)QBittorrentPriority.First; + var setShareLimits = release.SeedConfiguration != null && (release.SeedConfiguration.Ratio.HasValue || release.SeedConfiguration.SeedTime.HasValue); + var addHasSetShareLimits = setShareLimits && ProxyApiVersion >= new Version(2, 8, 1); + var moveToTop = Settings.Priority == (int)QBittorrentPriority.First; var forceStart = (QBittorrentState)Settings.InitialState == QBittorrentState.ForceStart; var category = GetCategoryForRelease(release) ?? Settings.Category; - Proxy.AddTorrentFromUrl(magnetLink, null, Settings, category); + Proxy.AddTorrentFromUrl(magnetLink, addHasSetShareLimits && setShareLimits ? release.SeedConfiguration : null, Settings, category); - if (itemToTop || forceStart) + if ((!addHasSetShareLimits && setShareLimits) || moveToTop || forceStart) { if (!WaitForTorrent(hash)) { return hash; } - //if (!addHasSetShareLimits && setShareLimits) - //{ - // Proxy.SetTorrentSeedingConfiguration(hash.ToLower(), release.SeedConfiguration, Settings); - //} - if (itemToTop) + if (!addHasSetShareLimits && setShareLimits) + { + try + { + Proxy.SetTorrentSeedingConfiguration(hash.ToLower(), release.SeedConfiguration, Settings); + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed to set the torrent seed criteria for {0}.", hash); + } + } + + if (moveToTop) { try { @@ -95,28 +105,36 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent return hash; } - protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(TorrentInfo release, string hash, string filename, byte[] fileContent) { - //var setShareLimits = release.SeedConfiguration != null && (release.SeedConfiguration.Ratio.HasValue || release.SeedConfiguration.SeedTime.HasValue); - //var addHasSetShareLimits = setShareLimits && ProxyApiVersion >= new Version(2, 8, 1); - var itemToTop = Settings.Priority == (int)QBittorrentPriority.First; + var setShareLimits = release.SeedConfiguration != null && (release.SeedConfiguration.Ratio.HasValue || release.SeedConfiguration.SeedTime.HasValue); + var addHasSetShareLimits = setShareLimits && ProxyApiVersion >= new Version(2, 8, 1); + var moveToTop = Settings.Priority == (int)QBittorrentPriority.First; var forceStart = (QBittorrentState)Settings.InitialState == QBittorrentState.ForceStart; var category = GetCategoryForRelease(release) ?? Settings.Category; - Proxy.AddTorrentFromFile(filename, fileContent, null, Settings, category); + Proxy.AddTorrentFromFile(filename, fileContent, addHasSetShareLimits ? release.SeedConfiguration : null, Settings, category); - if (itemToTop || forceStart) + if ((!addHasSetShareLimits && setShareLimits) || moveToTop || forceStart) { if (!WaitForTorrent(hash)) { return hash; } - //if (!addHasSetShareLimits && setShareLimits) - //{ - // Proxy.SetTorrentSeedingConfiguration(hash.ToLower(), release.SeedConfiguration, Settings); - //} - if (itemToTop) + if (!addHasSetShareLimits && setShareLimits) + { + try + { + Proxy.SetTorrentSeedingConfiguration(hash.ToLower(), release.SeedConfiguration, Settings); + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed to set the torrent seed criteria for {0}.", hash); + } + } + + if (moveToTop) { try { @@ -146,14 +164,16 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent protected bool WaitForTorrent(string hash) { - var count = 5; + var count = 10; while (count != 0) { try { - Proxy.GetTorrentProperties(hash.ToLower(), Settings); - return true; + if (Proxy.IsTorrentLoaded(hash.ToLower(), Settings)) + { + return true; + } } catch { @@ -235,9 +255,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent _logger.Error(ex, "Unable to test qBittorrent"); return new NzbDroneValidationFailure("Host", "Unable to connect to qBittorrent") - { - DetailedDescription = ex.Message - }; + { + DetailedDescription = ex.Message + }; } return null; @@ -257,7 +277,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent return null; } - Dictionary labels = Proxy.GetLabels(Settings); + var labels = Proxy.GetLabels(Settings); foreach (var category in Categories) { @@ -297,11 +317,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { var recentPriorityDefault = Settings.Priority == (int)QBittorrentPriority.Last; - if (recentPriorityDefault) - { - return null; - } - try { var config = Proxy.GetConfig(Settings); @@ -450,7 +465,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent torrent.SeedingTime = torrentProperties.SeedingTime; } - protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + protected override string AddFromTorrentLink(TorrentInfo release, string hash, string torrentLink) { throw new NotImplementedException(); } 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/QBittorrentLabel.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentLabel.cs index 224a079e9..8980502fe 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentLabel.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentLabel.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.Download.Clients.QBittorrent +namespace NzbDrone.Core.Download.Clients.QBittorrent { public class QBittorrentLabel { diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPriority.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPriority.cs index 7374fc312..5455fd8f6 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPriority.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPriority.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.Download.Clients.QBittorrent +namespace NzbDrone.Core.Download.Clients.QBittorrent { public enum QBittorrentPriority { diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs index 2460b3239..33fcfc5ca 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs @@ -1,11 +1,8 @@ using System; using System.Collections.Generic; - using NLog; using NzbDrone.Common.Cache; -using NzbDrone.Common.Http; - namespace NzbDrone.Core.Download.Clients.QBittorrent { public interface IQBittorrentProxy @@ -15,6 +12,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent string GetVersion(QBittorrentSettings settings); QBittorrentPreferences GetConfig(QBittorrentSettings settings); List GetTorrents(QBittorrentSettings settings); + bool IsTorrentLoaded(string hash, QBittorrentSettings settings); QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorrentSettings settings); List GetTorrentFiles(string hash, QBittorrentSettings settings); @@ -27,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); } @@ -40,7 +36,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent public class QBittorrentProxySelector : IQBittorrentProxySelector { - private readonly IHttpClient _httpClient; private readonly ICached> _proxyCache; private readonly Logger _logger; @@ -49,11 +44,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent public QBittorrentProxySelector(QBittorrentProxyV1 proxyV1, QBittorrentProxyV2 proxyV2, - IHttpClient httpClient, ICacheManager cacheManager, Logger logger) { - _httpClient = httpClient; _proxyCache = cacheManager.GetCache>(GetType()); _logger = logger; diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs index 8bf6c1878..7c9cc8768 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs @@ -97,6 +97,23 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent return response; } + public bool IsTorrentLoaded(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource($"/query/propertiesGeneral/{hash}"); + request.LogHttpError = false; + + try + { + ProcessRequest(request, settings); + + return true; + } + catch + { + return false; + } + } + public QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorrentSettings settings) { var request = BuildRequest(settings).Resource($"/query/propertiesGeneral/{hash}"); @@ -129,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); } @@ -159,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); } @@ -195,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() @@ -237,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; } @@ -246,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") @@ -295,15 +296,14 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent var request = requestBuilder.Build(); request.LogResponseContent = true; + request.SuppressHttpErrorStatusCodes = new[] { HttpStatusCode.Forbidden }; HttpResponse response; try { response = _httpClient.Execute(request); - } - catch (HttpException ex) - { - if (ex.Response.StatusCode == HttpStatusCode.Forbidden) + + if (response.StatusCode == HttpStatusCode.Forbidden) { _logger.Debug("Authentication required, logging in."); @@ -313,10 +313,10 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent response = _httpClient.Execute(request); } - else - { - throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); - } + } + catch (HttpException ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); } catch (WebException ex) { @@ -367,9 +367,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent throw new DownloadClientUnavailableException("Failed to connect to qBittorrent, please check your settings.", ex); } - // returns "Fails." on bad login if (response.Content != "Ok.") { + // returns "Fails." on bad login _logger.Debug("qbitTorrent authentication failed."); throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent."); } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs index 12a29c401..cfa7c9934 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs @@ -106,6 +106,24 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent return response; } + public bool IsTorrentLoaded(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/properties") + .AddQueryParam("hash", hash); + request.LogHttpError = false; + + try + { + ProcessRequest(request, settings); + + return true; + } + catch + { + return false; + } + } + public QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorrentSettings settings) { var request = BuildRequest(settings).Resource("/api/v2/torrents/properties") @@ -129,24 +147,12 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent var request = BuildRequest(settings).Resource("/api/v2/torrents/add") .Post() .AddFormParameter("urls", torrentUrl); - if (category.IsNotNullOrWhiteSpace()) - { - request.AddFormParameter("category", category); - } - // Note: ForceStart is handled by separate api call - if ((QBittorrentState)settings.InitialState == QBittorrentState.Start) - { - request.AddFormParameter("paused", false); - } - else if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) - { - request.AddFormParameter("paused", true); - } + AddTorrentDownloadFormParameters(request, settings, category); if (seedConfiguration != null) { - AddTorrentSeedingFormParameters(request, seedConfiguration, settings); + AddTorrentSeedingFormParameters(request, seedConfiguration); } var result = ProcessRequest(request, settings); @@ -164,24 +170,11 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent .Post() .AddFormUpload("torrents", fileName, fileContent); - if (category.IsNotNullOrWhiteSpace()) - { - request.AddFormParameter("category", category); - } - - // Note: ForceStart is handled by separate api call - if ((QBittorrentState)settings.InitialState == QBittorrentState.Start) - { - request.AddFormParameter("paused", false); - } - else if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) - { - request.AddFormParameter("paused", true); - } + AddTorrentDownloadFormParameters(request, settings, category); if (seedConfiguration != null) { - AddTorrentSeedingFormParameters(request, seedConfiguration, settings); + AddTorrentSeedingFormParameters(request, seedConfiguration); } var result = ProcessRequest(request, settings); @@ -230,29 +223,72 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent return Json.Deserialize>(ProcessRequest(request, settings)); } - private void AddTorrentSeedingFormParameters(HttpRequestBuilder request, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) + private void AddTorrentSeedingFormParameters(HttpRequestBuilder request, TorrentSeedConfiguration seedConfiguration, bool always = false) { var ratioLimit = seedConfiguration.Ratio.HasValue ? seedConfiguration.Ratio : -2; var seedingTimeLimit = seedConfiguration.SeedTime.HasValue ? (long)seedConfiguration.SeedTime.Value.TotalMinutes : -2; - if (ratioLimit != -2) + if (ratioLimit != -2 || always) { request.AddFormParameter("ratioLimit", ratioLimit); } - if (seedingTimeLimit != -2) + if (seedingTimeLimit != -2 || always) { request.AddFormParameter("seedingTimeLimit", seedingTimeLimit); } } + private void AddTorrentDownloadFormParameters(HttpRequestBuilder request, QBittorrentSettings settings, string category) + { + if (category.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("category", category); + } + + // Avoid extraneous API version check if initial state is ForceStart + if ((QBittorrentState)settings.InitialState is QBittorrentState.Start or QBittorrentState.Stop) + { + 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) + { + request.AddFormParameter("sequentialDownload", true); + } + + if (settings.FirstAndLast) + { + 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) { var request = BuildRequest(settings).Resource("/api/v2/torrents/setShareLimits") .Post() .AddFormParameter("hashes", hash); - AddTorrentSeedingFormParameters(request, seedConfiguration, settings); + AddTorrentSeedingFormParameters(request, seedConfiguration, true); try { @@ -261,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; } @@ -283,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; } @@ -292,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") @@ -341,15 +361,14 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent var request = requestBuilder.Build(); request.LogResponseContent = true; + request.SuppressHttpErrorStatusCodes = new[] { HttpStatusCode.Forbidden }; HttpResponse response; try { response = _httpClient.Execute(request); - } - catch (HttpException ex) - { - if (ex.Response.StatusCode == HttpStatusCode.Forbidden) + + if (response.StatusCode == HttpStatusCode.Forbidden) { _logger.Debug("Authentication required, logging in."); @@ -359,10 +378,10 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent response = _httpClient.Execute(request); } - else - { - throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); - } + } + catch (HttpException ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); } catch (WebException ex) { @@ -418,9 +437,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent throw new DownloadClientUnavailableException("Failed to connect to qBittorrent, please check your settings.", ex); } - // returns "Fails." on bad login if (response.Content != "Ok.") { + // returns "Fails." on bad login _logger.Debug("qbitTorrent authentication failed."); throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent."); } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs index d0c46c0c5..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,15 +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 = "DownloadClientQbittorrentSettingsSequentialOrder", Type = FieldType.Checkbox, HelpText = "DownloadClientQbittorrentSettingsSequentialOrderHelpText")] + public bool SequentialOrder { get; set; } + + [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/JsonConverters/SabnzbdPriorityTypeConverter.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdPriorityTypeConverter.cs index 246b5b558..f29317251 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdPriorityTypeConverter.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdPriorityTypeConverter.cs @@ -15,8 +15,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd.JsonConverters { var queuePriority = reader.Value.ToString(); - SabnzbdPriority output; - Enum.TryParse(queuePriority, out output); + Enum.TryParse(queuePriority, out SabnzbdPriority output); return output; } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdStringArrayConverter.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdStringArrayConverter.cs index bca2353a1..b5ab193ce 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdStringArrayConverter.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdStringArrayConverter.cs @@ -14,7 +14,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd.JsonConverters var stringArray = (string[])value; writer.WriteStartArray(); - for (int i = 0; i < stringArray.Length; i++) + for (var i = 0; i < stringArray.Length; i++) { writer.WriteValue(stringArray[i]); } 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/SabnzbdProxy.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs index de610ae9f..41fd428df 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs @@ -52,9 +52,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd request.AddFormUpload("name", filename, nzbData, "application/x-nzb"); - SabnzbdAddResponse response; - - if (!Json.TryDeserialize(ProcessRequest(request, settings), out response)) + if (!Json.TryDeserialize(ProcessRequest(request, settings), out var response)) { response = new SabnzbdAddResponse(); response.Status = true; @@ -71,9 +69,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd request.AddQueryParam("cat", category); request.AddQueryParam("priority", priority); - SabnzbdAddResponse response; - - if (!Json.TryDeserialize(ProcessRequest(request, settings), out response)) + if (!Json.TryDeserialize(ProcessRequest(request, settings), out var response)) { response = new SabnzbdAddResponse(); response.Status = true; @@ -96,9 +92,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd { var request = BuildRequest("version", settings); - SabnzbdVersionResponse response; - - if (!Json.TryDeserialize(ProcessRequest(request, settings), out response)) + if (!Json.TryDeserialize(ProcessRequest(request, settings), out var response)) { response = new SabnzbdVersionResponse(); } @@ -157,9 +151,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd var request = BuildRequest("retry", settings); request.AddQueryParam("value", id); - SabnzbdRetryResponse response; - - if (!Json.TryDeserialize(ProcessRequest(request, settings), out response)) + if (!Json.TryDeserialize(ProcessRequest(request, settings), out var response)) { response = new SabnzbdRetryResponse(); response.Status = true; @@ -230,9 +222,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd private void CheckForError(HttpResponse response) { - SabnzbdJsonError result; - - if (!Json.TryDeserialize(response.Content, out result)) + if (!Json.TryDeserialize(response.Content, out var result)) { //Handle plain text responses from SAB result = new SabnzbdJsonError(); 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 fac544a20..ad14de894 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs @@ -3,8 +3,9 @@ using System.Text.RegularExpressions; using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; -using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; namespace NzbDrone.Core.Download.Clients.Transmission { @@ -12,11 +13,12 @@ namespace NzbDrone.Core.Download.Clients.Transmission { public Transmission(ITransmissionProxy proxy, ITorrentFileInfoReader torrentFileInfoReader, - IHttpClient httpClient, + ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(proxy, torrentFileInfoReader, httpClient, 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 a4e4d90ae..977e3bfee 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs @@ -4,8 +4,9 @@ using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Validation; @@ -17,60 +18,24 @@ namespace NzbDrone.Core.Download.Clients.Transmission public TransmissionBase(ITransmissionProxy proxy, ITorrentFileInfoReader torrentFileInfoReader, - IHttpClient httpClient, + ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxy = proxy; } - protected bool HasReachedSeedLimit(TransmissionTorrent torrent, double? ratio, Lazy config) + protected override string AddFromMagnetLink(TorrentInfo release, string hash, string magnetLink) { - var isStopped = torrent.Status == TransmissionTorrentStatus.Stopped; - var isSeeding = torrent.Status == TransmissionTorrentStatus.Seeding; + var category = GetCategoryForRelease(release) ?? Settings.Category; + var downloadDirectory = GetDownloadDirectory(category); - if (torrent.SeedRatioMode == 1) - { - if (isStopped && ratio.HasValue && ratio >= torrent.SeedRatioLimit) - { - return true; - } - } - else if (torrent.SeedRatioMode == 0) - { - if (isStopped && config.Value.SeedRatioLimited && ratio >= config.Value.SeedRatioLimit) - { - return true; - } - } + _proxy.AddTorrentFromUrl(magnetLink, downloadDirectory, Settings); + _proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings); - // Transmission doesn't support SeedTimeLimit, use/abuse seed idle limit, but only if it was set per-torrent. - if (torrent.SeedIdleMode == 1) - { - if ((isStopped || isSeeding) && torrent.SecondsSeeding > torrent.SeedIdleLimit * 60) - { - return true; - } - } - else if (torrent.SeedIdleMode == 0) - { - // The global idle limit is a real idle limit, if it's configured then 'Stopped' is enough. - if (isStopped && config.Value.IdleSeedingLimitEnabled) - { - return true; - } - } - - return false; - } - - protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) - { - _proxy.AddTorrentFromUrl(magnetLink, GetDownloadDirectory(), Settings); - - //_proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings); if (Settings.Priority == (int)TransmissionPriority.First) { _proxy.MoveTorrentToTopInQueue(hash, Settings); @@ -79,11 +44,14 @@ namespace NzbDrone.Core.Download.Clients.Transmission return hash; } - protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + 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); - //_proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings); if (Settings.Priority == (int)TransmissionPriority.First) { _proxy.MoveTorrentToTopInQueue(hash, Settings); @@ -92,7 +60,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission return hash; } - protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + protected override string AddFromTorrentLink(TorrentInfo release, string hash, string torrentLink) { throw new NotImplementedException(); } @@ -113,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; } @@ -128,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/TransmissionConfig.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionConfig.cs index 1b96ca6d3..e987259a9 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionConfig.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionConfig.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.Transmission { diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionException.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionException.cs index 3b91b4ce3..fe1b01759 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionException.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionException.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.Download.Clients.Transmission +namespace NzbDrone.Core.Download.Clients.Transmission { public class TransmissionException : DownloadClientException { diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionPriority.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionPriority.cs index 1cf99c501..d896909b3 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionPriority.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionPriority.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.Download.Clients.Transmission +namespace NzbDrone.Core.Download.Clients.Transmission { public enum TransmissionPriority { diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs index 1172b600a..76f8684e0 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.Collections.Generic; 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; @@ -141,7 +142,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission private TransmissionResponse GetSessionVariables(TransmissionSettings settings) { - // Retrieve transmission information such as the default download directory, bandwith throttling and seed ratio. + // Retrieve transmission information such as the default download directory, bandwidth throttling and seed ratio. return ProcessRequest("session-get", null, settings); } @@ -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/TransmissionResponse.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionResponse.cs index 5d16754b7..68dc7f4d2 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionResponse.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace NzbDrone.Core.Download.Clients.Transmission { 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/Transmission/TransmissionTorrent.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs index 3abb5d4e8..70ab8a3b9 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.Download.Clients.Transmission +namespace NzbDrone.Core.Download.Clients.Transmission { public class TransmissionTorrent { @@ -9,7 +9,7 @@ public long TotalSize { get; set; } public long LeftUntilDone { get; set; } public bool IsFinished { get; set; } - public int Eta { get; set; } + public long Eta { get; set; } public TransmissionTorrentStatus Status { get; set; } public int SecondsDownloading { get; set; } public int SecondsSeeding { get; set; } diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrentStatus.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrentStatus.cs index 13e40f04e..f4683bd1b 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrentStatus.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrentStatus.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.Download.Clients.Transmission +namespace NzbDrone.Core.Download.Clients.Transmission { public enum TransmissionTorrentStatus { diff --git a/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs b/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs index bd1f4c37b..3b87962bb 100644 --- a/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs +++ b/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs @@ -1,9 +1,10 @@ using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; -using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Download.Clients.Transmission; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Localization; namespace NzbDrone.Core.Download.Clients.Vuze { @@ -13,11 +14,12 @@ namespace NzbDrone.Core.Download.Clients.Vuze public Vuze(ITransmissionProxy proxy, ITorrentFileInfoReader torrentFileInfoReader, - IHttpClient httpClient, + ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(proxy, torrentFileInfoReader, httpClient, configService, diskProvider, logger) + : base(proxy, torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { } @@ -27,7 +29,7 @@ namespace NzbDrone.Core.Download.Clients.Vuze // - A multi-file torrent is downloaded in a job folder and 'outputPath' points to that directory directly. // - A single-file torrent is downloaded in the root folder and 'outputPath' poinst to that root folder. // We have to make sure the return value points to the job folder OR file. - if (outputPath == default || outputPath.FileName == torrent.Name || torrent.FileCount > 1) + if (outputPath.FileName == torrent.Name || torrent.FileCount > 1) { _logger.Trace("Vuze output directory: {0}", outputPath); } @@ -46,8 +48,7 @@ namespace NzbDrone.Core.Download.Clients.Vuze _logger.Debug("Vuze protocol version information: {0}", versionString); - int version; - if (!int.TryParse(versionString, out version) || version < MINIMUM_SUPPORTED_PROTOCOL_VERSION) + if (!int.TryParse(versionString, out var version) || version < MINIMUM_SUPPORTED_PROTOCOL_VERSION) { { return new ValidationFailure(string.Empty, "Protocol version not supported, use Vuze 5.0.0.0 or higher with Vuze Web Remote plugin."); diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs index 9f7505cfd..628ebdf52 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs @@ -6,10 +6,11 @@ using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; 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; @@ -23,18 +24,19 @@ namespace NzbDrone.Core.Download.Clients.RTorrent public RTorrent(IRTorrentProxy proxy, ITorrentFileInfoReader torrentFileInfoReader, - IHttpClient httpClient, + ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, IRTorrentDirectoryValidator rTorrentDirectoryValidator, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxy = proxy; _rTorrentDirectoryValidator = rTorrentDirectoryValidator; } - protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + protected override string AddFromMagnetLink(TorrentInfo release, string hash, string magnetLink) { var priority = (RTorrentPriority)Settings.Priority; @@ -54,7 +56,7 @@ namespace NzbDrone.Core.Download.Clients.RTorrent return hash; } - protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(TorrentInfo release, string hash, string filename, byte[] fileContent) { var priority = (RTorrentPriority)Settings.Priority; @@ -157,7 +159,7 @@ namespace NzbDrone.Core.Download.Clients.RTorrent return false; } - protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + protected override string AddFromTorrentLink(TorrentInfo release, string hash, string torrentLink) { throw new NotImplementedException(); } 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 7d8da7b1c..98ad41eec 100644 --- a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs @@ -3,12 +3,13 @@ using System.Collections.Generic; using System.Net; using FluentValidation.Results; using NLog; -using NzbDrone.Common.Cache; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; 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 @@ -16,29 +17,27 @@ namespace NzbDrone.Core.Download.Clients.UTorrent public class UTorrent : TorrentClientBase { private readonly IUTorrentProxy _proxy; - private readonly ICached _torrentCache; public UTorrent(IUTorrentProxy proxy, - ICacheManager cacheManager, ITorrentFileInfoReader torrentFileInfoReader, - IHttpClient httpClient, + ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + : base(torrentFileInfoReader, seedConfigProvider, configService, diskProvider, localizationService, logger) { _proxy = proxy; - - _torrentCache = cacheManager.GetCache(GetType(), "differentialTorrents"); } - protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + protected override string AddFromMagnetLink(TorrentInfo release, string hash, string magnetLink) { _proxy.AddTorrentFromUrl(magnetLink, Settings); + _proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings); - //_proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings); var category = GetCategoryForRelease(release) ?? Settings.Category; - if (GetCategoryForRelease(release).IsNotNullOrWhiteSpace()) + + if (category.IsNotNullOrWhiteSpace()) { _proxy.SetTorrentLabel(hash, category, Settings); } @@ -53,12 +52,13 @@ namespace NzbDrone.Core.Download.Clients.UTorrent return hash; } - protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(TorrentInfo release, string hash, string filename, byte[] fileContent) { _proxy.AddTorrentFromFile(filename, fileContent, Settings); + _proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings); - //_proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings); var category = GetCategoryForRelease(release) ?? Settings.Category; + if (category.IsNotNullOrWhiteSpace()) { _proxy.SetTorrentLabel(hash, category, Settings); @@ -75,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) @@ -125,9 +128,9 @@ namespace NzbDrone.Core.Download.Clients.UTorrent _logger.Error(ex, "Failed to test uTorrent"); return new NzbDroneValidationFailure("Host", "Unable to connect to uTorrent") - { - DetailedDescription = ex.Message - }; + { + DetailedDescription = ex.Message + }; } return null; @@ -148,7 +151,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent return null; } - protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + protected override string AddFromTorrentLink(TorrentInfo release, string hash, string torrentLink) { throw new NotImplementedException(); } 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/Clients/uTorrent/UTorrentTorrent.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrent.cs index 027b138e0..35fec74de 100644 --- a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrent.cs @@ -39,7 +39,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent public object Unknown28 { get; set; } } - internal class UTorrentTorrentJsonConverter : JsonConverter + public class UTorrentTorrentJsonConverter : JsonConverter { public override bool CanConvert(Type objectType) { diff --git a/src/NzbDrone.Core/Download/DownloadClientBase.cs b/src/NzbDrone.Core/Download/DownloadClientBase.cs index 7659c32a6..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; } @@ -40,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 f5cd9f005..19aedf751 100644 --- a/src/NzbDrone.Core/Download/DownloadClientFactory.cs +++ b/src/NzbDrone.Core/Download/DownloadClientFactory.cs @@ -57,12 +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) { - DownloadClientStatus downloadClientStatus; - if (blockedIndexers.TryGetValue(client.Definition.Id, out 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; @@ -76,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/DownloadClientProvider.cs b/src/NzbDrone.Core/Download/DownloadClientProvider.cs index 3ad8f6615..c69f4a01d 100644 --- a/src/NzbDrone.Core/Download/DownloadClientProvider.cs +++ b/src/NzbDrone.Core/Download/DownloadClientProvider.cs @@ -2,13 +2,14 @@ using System.Linq; using NLog; using NzbDrone.Common.Cache; +using NzbDrone.Core.Download.Clients; using NzbDrone.Core.Indexers; namespace NzbDrone.Core.Download { public interface IProvideDownloadClient { - IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol); + IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol, int indexerId = 0); IEnumerable GetDownloadClients(); IDownloadClient Get(int id); } @@ -18,17 +19,23 @@ namespace NzbDrone.Core.Download private readonly Logger _logger; private readonly IDownloadClientFactory _downloadClientFactory; private readonly IDownloadClientStatusService _downloadClientStatusService; + private readonly IIndexerFactory _indexerFactory; private readonly ICached _lastUsedDownloadClient; - public DownloadClientProvider(IDownloadClientStatusService downloadClientStatusService, IDownloadClientFactory downloadClientFactory, ICacheManager cacheManager, Logger logger) + public DownloadClientProvider(IDownloadClientStatusService downloadClientStatusService, + IDownloadClientFactory downloadClientFactory, + IIndexerFactory indexerFactory, + ICacheManager cacheManager, + Logger logger) { _logger = logger; _downloadClientFactory = downloadClientFactory; _downloadClientStatusService = downloadClientStatusService; + _indexerFactory = indexerFactory; _lastUsedDownloadClient = cacheManager.GetCache(GetType(), "lastDownloadClientId"); } - public IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol) + public IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol, int indexerId = 0) { var availableProviders = _downloadClientFactory.GetAvailableProviders().Where(v => v.Protocol == downloadProtocol).ToList(); @@ -37,6 +44,23 @@ namespace NzbDrone.Core.Download return null; } + if (indexerId > 0) + { + var indexer = _indexerFactory.Find(indexerId); + + if (indexer is { DownloadClientId: > 0 }) + { + var client = availableProviders.SingleOrDefault(d => d.Definition.Id == indexer.DownloadClientId); + + if (client == null) + { + throw new DownloadClientUnavailableException("Indexer specified download client is not available"); + } + + return client; + } + } + var blockedProviders = new HashSet(_downloadClientStatusService.GetBlockedProviders().Select(v => v.ProviderId)); if (blockedProviders.Any()) @@ -54,7 +78,7 @@ namespace NzbDrone.Core.Download } // Use the first priority clients first - availableProviders = availableProviders.GroupBy(v => (v.Definition as DownloadClientDefinition).Priority) + availableProviders = availableProviders.GroupBy(v => ((DownloadClientDefinition)v.Definition).Priority) .OrderBy(v => v.Key) .First().OrderBy(v => v.Definition.Id).ToList(); diff --git a/src/NzbDrone.Core/Download/DownloadService.cs b/src/NzbDrone.Core/Download/DownloadService.cs index 49085b6e4..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; @@ -16,7 +14,7 @@ namespace NzbDrone.Core.Download { public interface IDownloadService { - Task SendReportToClient(ReleaseInfo release, string source, string host, bool redirect); + Task SendReportToClient(ReleaseInfo release, string source, string host, bool redirect, int? downloadClientId); Task DownloadReport(string link, int indexerId, string source, string host, string title); void RecordRedirect(string link, int indexerId, string source, string host, string title); } @@ -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,23 +39,28 @@ namespace NzbDrone.Core.Download _downloadClientStatusService = downloadClientStatusService; _indexerFactory = indexerFactory; _indexerStatusService = indexerStatusService; - _rateLimitService = rateLimitService; _eventAggregator = eventAggregator; _logger = logger; } - public async Task SendReportToClient(ReleaseInfo release, string source, string host, bool redirect) + public async Task SendReportToClient(ReleaseInfo release, string source, string host, bool redirect, int? downloadClientId) + { + var downloadClient = downloadClientId.HasValue + ? _downloadClientProvider.Get(downloadClientId.Value) + : _downloadClientProvider.GetDownloadClient(release.DownloadProtocol, release.IndexerId); + + await SendReportToClient(release, source, host, redirect, downloadClient); + } + + private async Task SendReportToClient(ReleaseInfo release, string source, string host, bool redirect, IDownloadClient downloadClient) { var downloadTitle = release.Title; - var downloadClient = _downloadClientProvider.GetDownloadClient(release.DownloadProtocol); if (downloadClient == null) { throw new DownloadClientUnavailableException($"{release.DownloadProtocol} Download client isn't configured yet"); } - // Get the seed configuration for this release. - // remoteMovie.SeedConfiguration = _seedConfigProvider.GetSeedConfiguration(remoteMovie); var indexer = _indexerFactory.GetInstance(_indexerFactory.Get(release.IndexerId)); var grabEvent = new IndexerDownloadEvent(release, true, source, host, release.Title, release.DownloadUrl) @@ -68,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 }; @@ -125,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 { @@ -144,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) { @@ -198,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/Extensions/XmlExtensions.cs b/src/NzbDrone.Core/Download/Extensions/XmlExtensions.cs index bce66c8bc..b4571d6e8 100644 --- a/src/NzbDrone.Core/Download/Extensions/XmlExtensions.cs +++ b/src/NzbDrone.Core/Download/Extensions/XmlExtensions.cs @@ -32,13 +32,13 @@ namespace NzbDrone.Core.Download.Extensions public static long ElementAsLong(this XElement element, XName name) { var el = element.Element(name); - return long.TryParse(el?.Value, out long value) ? value : default; + return long.TryParse(el?.Value, out var value) ? value : default; } public static int ElementAsInt(this XElement element, XName name) { var el = element.Element(name); - return int.TryParse(el?.Value, out int value) ? value : default(int); + return int.TryParse(el?.Value, out var value) ? value : default(int); } public static int GetIntResponse(this XDocument document) diff --git a/src/NzbDrone.Core/Download/TorrentClientBase.cs b/src/NzbDrone.Core/Download/TorrentClientBase.cs index 9a5787e0a..217e70a31 100644 --- a/src/NzbDrone.Core/Download/TorrentClientBase.cs +++ b/src/NzbDrone.Core/Download/TorrentClientBase.cs @@ -5,10 +5,10 @@ using MonoTorrent; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; 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; @@ -18,32 +18,39 @@ namespace NzbDrone.Core.Download public abstract class TorrentClientBase : DownloadClientBase where TSettings : IProviderConfig, new() { - protected readonly IHttpClient _httpClient; - protected readonly ITorrentFileInfoReader _torrentFileInfoReader; + private readonly ITorrentFileInfoReader _torrentFileInfoReader; + private readonly ISeedConfigProvider _seedConfigProvider; protected TorrentClientBase(ITorrentFileInfoReader torrentFileInfoReader, - IHttpClient httpClient, + ISeedConfigProvider seedConfigProvider, IConfigService configService, IDiskProvider diskProvider, + ILocalizationService localizationService, Logger logger) - : base(configService, diskProvider, logger) + : base(configService, diskProvider, localizationService, logger) { - _httpClient = httpClient; _torrentFileInfoReader = torrentFileInfoReader; + _seedConfigProvider = seedConfigProvider; } public override DownloadProtocol Protocol => DownloadProtocol.Torrent; public virtual bool PreferTorrentFile => false; - protected abstract string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink); - protected abstract string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent); - protected abstract string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink); + protected abstract string AddFromMagnetLink(TorrentInfo release, string hash, string magnetLink); + protected abstract string AddFromTorrentFile(TorrentInfo release, string hash, string filename, byte[] fileContent); + protected abstract string AddFromTorrentLink(TorrentInfo release, string hash, string torrentLink); public override async Task Download(ReleaseInfo release, bool redirect, IIndexer indexer) { var torrentInfo = release as TorrentInfo; + if (torrentInfo != null) + { + // Get the seed configuration for this release. + torrentInfo.SeedConfiguration = _seedConfigProvider.GetSeedConfiguration(release); + } + string magnetUrl = null; string torrentUrl = null; @@ -67,7 +74,7 @@ namespace NzbDrone.Core.Download { try { - return await DownloadFromWebUrl(release, indexer, torrentUrl); + return await DownloadFromWebUrl(torrentInfo, indexer, torrentUrl); } catch (Exception ex) { @@ -84,7 +91,7 @@ namespace NzbDrone.Core.Download { try { - return DownloadFromMagnetUrl(release, magnetUrl); + return DownloadFromMagnetUrl(torrentInfo, magnetUrl); } catch (NotSupportedException ex) { @@ -98,7 +105,7 @@ namespace NzbDrone.Core.Download { try { - return DownloadFromMagnetUrl(release, magnetUrl); + return DownloadFromMagnetUrl(torrentInfo, magnetUrl); } catch (NotSupportedException ex) { @@ -113,18 +120,17 @@ namespace NzbDrone.Core.Download if (torrentUrl.IsNotNullOrWhiteSpace()) { - return await DownloadFromWebUrl(release, indexer, torrentUrl); + return await DownloadFromWebUrl(torrentInfo, indexer, torrentUrl); } } return null; } - private async Task DownloadFromWebUrl(ReleaseInfo release, IIndexer indexer, string torrentUrl) + 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 @@ -155,7 +161,7 @@ namespace NzbDrone.Core.Download return actualHash; } - private string DownloadFromMagnetUrl(ReleaseInfo release, string magnetUrl) + private string DownloadFromMagnetUrl(TorrentInfo release, string magnetUrl) { string hash = null; string actualHash = null; @@ -166,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 cc507b242..483f52b6b 100644 --- a/src/NzbDrone.Core/Fluent.cs +++ b/src/NzbDrone.Core/Fluent.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -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); @@ -95,17 +75,17 @@ namespace NzbDrone.Core } var cs = s.ToCharArray(); - int length = 0; - int i = 0; + var length = 0; + var i = 0; while (i < cs.Length) { - int charSize = 1; + var charSize = 1; if (i < (cs.Length - 1) && char.IsSurrogate(cs[i])) { charSize = 2; } - int byteSize = Encoding.UTF8.GetByteCount(cs, i, charSize); + var byteSize = Encoding.UTF8.GetByteCount(cs, i, charSize); if ((byteSize + length) <= maxLength) { i = i + charSize; 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 0ea4bbd3c..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 { @@ -28,13 +30,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.Before( - 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.Before(DateTime.UtcNow.AddHours(-6))) + .ToList(); if (backOffProviders.Empty()) { 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/IndexerCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/IndexerCheck.cs index c8781a06a..0a163045e 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/IndexerCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/IndexerCheck.cs @@ -8,6 +8,7 @@ namespace NzbDrone.Core.HealthCheck.Checks [CheckOn(typeof(ProviderAddedEvent))] [CheckOn(typeof(ProviderUpdatedEvent))] [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderBulkUpdatedEvent))] [CheckOn(typeof(ProviderBulkDeletedEvent))] [CheckOn(typeof(ProviderStatusChangedEvent))] public class IndexerCheck : HealthCheckBase 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 792fe7bd9..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; @@ -9,6 +10,7 @@ namespace NzbDrone.Core.HealthCheck.Checks { [CheckOn(typeof(ProviderUpdatedEvent))] [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderBulkUpdatedEvent))] [CheckOn(typeof(ProviderBulkDeletedEvent))] [CheckOn(typeof(ProviderStatusChangedEvent))] public class IndexerLongTermStatusCheck : HealthCheckBase @@ -16,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; @@ -29,13 +29,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.Before( - 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.Before(DateTime.UtcNow.AddHours(-6))) + .ToList(); if (backOffProviders.Empty()) { @@ -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 9c60ec427..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.Any(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 50% rename from src/NzbDrone.Core/HealthCheck/Checks/IndexerProxyCheck.cs rename to src/NzbDrone.Core/HealthCheck/Checks/IndexerProxyStatusCheck.cs index a78061561..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) { @@ -22,28 +23,32 @@ namespace NzbDrone.Core.HealthCheck.Checks public override HealthCheck Check() { - var enabledProviders = _proxyFactory.GetAvailableProviders(); + var enabledProxies = _proxyFactory.GetAvailableProviders() + .Where(n => ((IndexerProxyDefinition)n.Definition).Enable) + .ToList(); - var badProxies = enabledProviders.Where(p => p.Test().IsValid == false).ToList(); + var badProxies = enabledProxies.Where(p => p.Test().IsValid == false).ToList(); - if (enabledProviders.Empty() || badProxies.Count == 0) + if (enabledProxies.Empty() || badProxies.Count == 0) { return new HealthCheck(GetType()); } - if (badProxies.Count == enabledProviders.Count) + if (badProxies.Count == enabledProxies.Count) { 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 fae67980b..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; @@ -9,6 +10,7 @@ namespace NzbDrone.Core.HealthCheck.Checks { [CheckOn(typeof(ProviderUpdatedEvent))] [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderBulkUpdatedEvent))] [CheckOn(typeof(ProviderBulkDeletedEvent))] [CheckOn(typeof(ProviderStatusChangedEvent))] public class IndexerStatusCheck : HealthCheckBase @@ -27,13 +29,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()) { @@ -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 11ae42719..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; @@ -160,8 +200,7 @@ namespace NzbDrone.Core.HealthCheck _isRunningHealthChecksAfterGracePeriod = false; } - IEventDrivenHealthCheck[] checks; - if (!_eventDrivenHealthChecks.TryGetValue(message.GetType(), out checks)) + if (!_eventDrivenHealthChecks.TryGetValue(message.GetType(), out var checks)) { return; } @@ -177,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 87481b00b..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; @@ -128,52 +129,58 @@ namespace NzbDrone.Core.History Successful = response?.StatusCode == HttpStatusCode.OK || (response is { Request: { SuppressHttpError: true, SuppressHttpErrorStatusCodes: not null } } && response.Request.SuppressHttpErrorStatusCodes.Contains(response.StatusCode)) }; - if (message.Query is MovieSearchCriteria) + if (message.Query is MovieSearchCriteria movieSearchCriteria) { - history.Data.Add("ImdbId", ((MovieSearchCriteria)message.Query).FullImdbId ?? string.Empty); - history.Data.Add("TmdbId", ((MovieSearchCriteria)message.Query).TmdbId?.ToString() ?? string.Empty); - history.Data.Add("TraktId", ((MovieSearchCriteria)message.Query).TraktId?.ToString() ?? string.Empty); - history.Data.Add("Year", ((MovieSearchCriteria)message.Query).Year?.ToString() ?? string.Empty); - history.Data.Add("Genre", ((MovieSearchCriteria)message.Query).Genre ?? string.Empty); + history.Data.Add("ImdbId", movieSearchCriteria.FullImdbId); + history.Data.Add("TmdbId", movieSearchCriteria.TmdbId?.ToString()); + history.Data.Add("TraktId", movieSearchCriteria.TraktId?.ToString()); + history.Data.Add("Year", movieSearchCriteria.Year?.ToString()); + history.Data.Add("Genre", movieSearchCriteria.Genre); } - if (message.Query is TvSearchCriteria) + if (message.Query is TvSearchCriteria tvSearchCriteria) { - history.Data.Add("ImdbId", ((TvSearchCriteria)message.Query).FullImdbId ?? string.Empty); - history.Data.Add("TvdbId", ((TvSearchCriteria)message.Query).TvdbId?.ToString() ?? string.Empty); - history.Data.Add("TmdbId", ((TvSearchCriteria)message.Query).TmdbId?.ToString() ?? string.Empty); - history.Data.Add("TraktId", ((TvSearchCriteria)message.Query).TraktId?.ToString() ?? string.Empty); - history.Data.Add("RId", ((TvSearchCriteria)message.Query).RId?.ToString() ?? string.Empty); - history.Data.Add("TvMazeId", ((TvSearchCriteria)message.Query).TvMazeId?.ToString() ?? string.Empty); - history.Data.Add("Season", ((TvSearchCriteria)message.Query).Season?.ToString() ?? string.Empty); - history.Data.Add("Episode", ((TvSearchCriteria)message.Query).Episode ?? string.Empty); - history.Data.Add("Year", ((TvSearchCriteria)message.Query).Year?.ToString() ?? string.Empty); - history.Data.Add("Genre", ((TvSearchCriteria)message.Query).Genre ?? string.Empty); + history.Data.Add("ImdbId", tvSearchCriteria.FullImdbId); + history.Data.Add("TvdbId", tvSearchCriteria.TvdbId?.ToString()); + history.Data.Add("TmdbId", tvSearchCriteria.TmdbId?.ToString()); + history.Data.Add("TraktId", tvSearchCriteria.TraktId?.ToString()); + history.Data.Add("RId", tvSearchCriteria.RId?.ToString()); + history.Data.Add("TvMazeId", tvSearchCriteria.TvMazeId?.ToString()); + history.Data.Add("Season", tvSearchCriteria.Season?.ToString()); + history.Data.Add("Episode", tvSearchCriteria.Episode); + history.Data.Add("Year", tvSearchCriteria.Year?.ToString()); + history.Data.Add("Genre", tvSearchCriteria.Genre); } - if (message.Query is MusicSearchCriteria) + if (message.Query is MusicSearchCriteria musicSearchCriteria) { - history.Data.Add("Artist", ((MusicSearchCriteria)message.Query).Artist ?? string.Empty); - history.Data.Add("Album", ((MusicSearchCriteria)message.Query).Album ?? string.Empty); - history.Data.Add("Track", ((MusicSearchCriteria)message.Query).Track ?? string.Empty); - history.Data.Add("Label", ((MusicSearchCriteria)message.Query).Label ?? string.Empty); - history.Data.Add("Year", ((MusicSearchCriteria)message.Query).Year?.ToString() ?? string.Empty); - history.Data.Add("Genre", ((MusicSearchCriteria)message.Query).Genre ?? string.Empty); + history.Data.Add("Artist", musicSearchCriteria.Artist); + history.Data.Add("Album", musicSearchCriteria.Album); + history.Data.Add("Track", musicSearchCriteria.Track); + history.Data.Add("Label", musicSearchCriteria.Label); + history.Data.Add("Year", musicSearchCriteria.Year?.ToString()); + history.Data.Add("Genre", musicSearchCriteria.Genre); } - if (message.Query is BookSearchCriteria) + if (message.Query is BookSearchCriteria bookSearchCriteria) { - history.Data.Add("Author", ((BookSearchCriteria)message.Query).Author ?? string.Empty); - history.Data.Add("BookTitle", ((BookSearchCriteria)message.Query).Title ?? string.Empty); - history.Data.Add("Publisher", ((BookSearchCriteria)message.Query).Publisher ?? string.Empty); - history.Data.Add("Year", ((BookSearchCriteria)message.Query).Year?.ToString() ?? string.Empty); - history.Data.Add("Genre", ((BookSearchCriteria)message.Query).Genre ?? string.Empty); + history.Data.Add("Author", bookSearchCriteria.Author); + history.Data.Add("Title", bookSearchCriteria.Title); + history.Data.Add("Publisher", bookSearchCriteria.Publisher); + history.Data.Add("Year", bookSearchCriteria.Year?.ToString()); + history.Data.Add("Genre", bookSearchCriteria.Genre); } + history.Data.Add("Limit", message.Query.Limit?.ToString()); + history.Data.Add("Offset", message.Query.Offset?.ToString()); + + // Clean empty data + history.Data = history.Data.Where(d => d.Value != null).ToDictionary(x => x.Key, x => x.Value); + history.Data.Add("ElapsedTime", message.QueryResult.Cached ? "0" : message.QueryResult.Response?.ElapsedTime.ToString() ?? string.Empty); history.Data.Add("Query", message.Query.SearchTerm ?? string.Empty); history.Data.Add("QueryType", message.Query.SearchType ?? string.Empty); - history.Data.Add("Categories", string.Join(",", message.Query.Categories) ?? string.Empty); + history.Data.Add("Categories", string.Join(",", message.Query.Categories ?? Array.Empty())); history.Data.Add("Source", message.Query.Source ?? string.Empty); history.Data.Add("Host", message.Query.Host ?? string.Empty); history.Data.Add("QueryResults", message.QueryResult.Releases?.Count.ToString() ?? string.Empty); @@ -196,9 +203,30 @@ namespace NzbDrone.Core.History history.Data.Add("Source", message.Source ?? string.Empty); history.Data.Add("Host", message.Host ?? string.Empty); history.Data.Add("GrabMethod", message.Redirect ? "Redirect" : "Proxy"); - history.Data.Add("Title", message.Title); + 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); } @@ -212,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/CleanupAdditionalUsers.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupAdditionalUsers.cs index 8b36317ec..0f525a278 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupAdditionalUsers.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupAdditionalUsers.cs @@ -14,13 +14,11 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers public void Clean() { - using (var mapper = _database.OpenConnection()) - { - mapper.Execute(@"DELETE FROM ""Users"" - WHERE ""Id"" NOT IN ( - SELECT ""Id"" FROM ""Users"" - LIMIT 1)"); - } + using var mapper = _database.OpenConnection(); + mapper.Execute(@"DELETE FROM ""Users"" + WHERE ""Id"" NOT IN ( + SELECT ""Id"" FROM ""Users"" + LIMIT 1)"); } } } diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedApplicationStatus.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedApplicationStatus.cs index 86e4cd2a5..3c5d91ca1 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedApplicationStatus.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedApplicationStatus.cs @@ -14,14 +14,13 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers public void Clean() { - var mapper = _database.OpenConnection(); - + using var mapper = _database.OpenConnection(); mapper.Execute(@"DELETE FROM ""ApplicationStatus"" - WHERE ""Id"" IN ( - SELECT ""ApplicationStatus"".""Id"" FROM ""ApplicationStatus"" - LEFT OUTER JOIN ""Applications"" - ON ""ApplicationStatus"".""ProviderId"" = ""Applications"".""Id"" - WHERE ""Applications"".""Id"" IS NULL)"); + WHERE ""Id"" IN ( + SELECT ""ApplicationStatus"".""Id"" FROM ""ApplicationStatus"" + LEFT OUTER JOIN ""Applications"" + ON ""ApplicationStatus"".""ProviderId"" = ""Applications"".""Id"" + WHERE ""Applications"".""Id"" IS NULL)"); } } } diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedDownloadClientStatus.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedDownloadClientStatus.cs index 5a6af9fd8..fe1adfbd6 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedDownloadClientStatus.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedDownloadClientStatus.cs @@ -14,14 +14,13 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers public void Clean() { - var mapper = _database.OpenConnection(); - + using var mapper = _database.OpenConnection(); mapper.Execute(@"DELETE FROM ""DownloadClientStatus"" - WHERE ""Id"" IN ( - SELECT ""DownloadClientStatus"".""Id"" FROM ""DownloadClientStatus"" - LEFT OUTER JOIN ""DownloadClients"" - ON ""DownloadClientStatus"".""ProviderId"" = ""DownloadClients"".""Id"" - WHERE ""DownloadClients"".""Id"" IS NULL)"); + WHERE ""Id"" IN ( + SELECT ""DownloadClientStatus"".""Id"" FROM ""DownloadClientStatus"" + LEFT OUTER JOIN ""DownloadClients"" + ON ""DownloadClientStatus"".""ProviderId"" = ""DownloadClients"".""Id"" + WHERE ""DownloadClients"".""Id"" IS NULL)"); } } } diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedHistoryItems.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedHistoryItems.cs index e6d8d789d..571304073 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedHistoryItems.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedHistoryItems.cs @@ -19,15 +19,13 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers private void CleanupOrphanedByIndexer() { - using (var mapper = _database.OpenConnection()) - { - mapper.Execute(@"DELETE FROM ""History"" - WHERE ""Id"" IN ( - SELECT ""History"".""Id"" FROM ""History"" - LEFT OUTER JOIN ""Indexers"" - ON ""History"".""IndexerId"" = ""Indexers"".""Id"" - WHERE ""Indexers"".""Id"" IS NULL)"); - } + using var mapper = _database.OpenConnection(); + mapper.Execute(@"DELETE FROM ""History"" + WHERE ""Id"" IN ( + SELECT ""History"".""Id"" FROM ""History"" + LEFT OUTER JOIN ""Indexers"" + ON ""History"".""IndexerId"" = ""Indexers"".""Id"" + WHERE ""Indexers"".""Id"" IS NULL)"); } } } diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedIndexerStatus.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedIndexerStatus.cs index 059f059e4..9486641b5 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedIndexerStatus.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedIndexerStatus.cs @@ -14,15 +14,13 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers public void Clean() { - using (var mapper = _database.OpenConnection()) - { - mapper.Execute(@"DELETE FROM ""IndexerStatus"" - WHERE ""Id"" IN ( - SELECT ""IndexerStatus"".""Id"" FROM ""IndexerStatus"" - LEFT OUTER JOIN ""Indexers"" - ON ""IndexerStatus"".""ProviderId"" = ""Indexers"".""Id"" - WHERE ""Indexers"".""Id"" IS NULL)"); - } + using var mapper = _database.OpenConnection(); + mapper.Execute(@"DELETE FROM ""IndexerStatus"" + WHERE ""Id"" IN ( + SELECT ""IndexerStatus"".""Id"" FROM ""IndexerStatus"" + LEFT OUTER JOIN ""Indexers"" + ON ""IndexerStatus"".""ProviderId"" = ""Indexers"".""Id"" + WHERE ""Indexers"".""Id"" IS NULL)"); } } } 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/CleanupUnusedTags.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs index 1df062918..1ad3f91f1 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs @@ -17,23 +17,21 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers public void Clean() { - using (var mapper = _database.OpenConnection()) + using var mapper = _database.OpenConnection(); + var usedTags = new[] { "Notifications", "IndexerProxies", "Indexers", "Applications" } + .SelectMany(v => GetUsedTags(v, mapper)) + .Distinct() + .ToArray(); + + if (usedTags.Length > 0) { - var usedTags = new[] { "Notifications", "IndexerProxies", "Indexers", "Applications" } - .SelectMany(v => GetUsedTags(v, mapper)) - .Distinct() - .ToArray(); + var usedTagsList = string.Join(",", usedTags.Select(d => d.ToString()).ToArray()); - if (usedTags.Length > 0) - { - var usedTagsList = string.Join(",", usedTags.Select(d => d.ToString()).ToArray()); - - mapper.Execute($"DELETE FROM \"Tags\" WHERE NOT \"Id\" IN ({usedTagsList})"); - } - else - { - mapper.Execute($"DELETE FROM \"Tags\""); - } + mapper.Execute($"DELETE FROM \"Tags\" WHERE NOT \"Id\" IN ({usedTagsList})"); + } + else + { + mapper.Execute("DELETE FROM \"Tags\""); } } 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/FixFutureRunScheduledTasks.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureRunScheduledTasks.cs index 6b45ed0c4..62704c023 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureRunScheduledTasks.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureRunScheduledTasks.cs @@ -24,13 +24,11 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers _logger.Debug("Not running scheduled task last execution cleanup during debug"); } - using (var mapper = _database.OpenConnection()) - { - mapper.Execute(@"UPDATE ""ScheduledTasks"" - SET ""LastExecution"" = @time - WHERE ""LastExecution"" > @time", - new { time = DateTime.UtcNow }); - } + using var mapper = _database.OpenConnection(); + mapper.Execute(@"UPDATE ""ScheduledTasks"" + SET ""LastExecution"" = @time + WHERE ""LastExecution"" > @time", + new { time = DateTime.UtcNow }); } } } 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 9974c1f7f..d7c9acc23 100644 --- a/src/NzbDrone.Core/IndexerProxies/HttpIndexerProxyBase.cs +++ b/src/NzbDrone.Core/IndexerProxies/HttpIndexerProxyBase.cs @@ -31,8 +31,8 @@ namespace NzbDrone.Core.IndexerProxies var failures = new List(); var request = PreRequest(_cloudRequestBuilder.Create() - .Resource("/ping") - .Build()); + .Resource("/ping") + .Build()); try { @@ -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/IndexerProxies/IndexerProxyFactory.cs b/src/NzbDrone.Core/IndexerProxies/IndexerProxyFactory.cs index e62c58dab..a103d676d 100644 --- a/src/NzbDrone.Core/IndexerProxies/IndexerProxyFactory.cs +++ b/src/NzbDrone.Core/IndexerProxies/IndexerProxyFactory.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using NLog; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.ThingiProvider; @@ -16,5 +17,10 @@ namespace NzbDrone.Core.IndexerProxies : base(providerRepository, providers, container, eventAggregator, logger) { } + + protected override List Active() + { + return All().Where(c => c.Enable && c.Settings.Validate().IsValid).ToList(); + } } } 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 5d5e82fb3..442b7ba00 100644 --- a/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs +++ b/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs @@ -1,13 +1,14 @@ using System.Text.RegularExpressions; +using NzbDrone.Common.Extensions; namespace NzbDrone.Core.IndexerSearch { public class NewznabRequest { - private static readonly Regex TvRegex = new Regex(@"\{((?:imdbid\:)(?[^{]+)|(?:rid\:)(?[^{]+)|(?:tvdbid\:)(?[^{]+)|(?:tmdbid\:)(?[^{]+)|(?:doubanid\:)(?[^{]+)|(?:season\:)(?[^{]+)|(?:episode\:)(?[^{]+)|(?:year\:)(?[^{]+)|(?:genre\:)(?[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex MovieRegex = new Regex(@"\{((?:imdbid\:)(?[^{]+)|(?:doubanid\:)(?[^{]+)|(?:tmdbid\:)(?[^{]+)|(?:traktid\:)(?[^{]+)|(?:year\:)(?[^{]+)|(?:genre\:)(?[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex MusicRegex = new Regex(@"\{((?:artist\:)(?[^{]+)|(?:album\:)(?[^{]+)|(?:track\:)(?[^{]+)|(?:label\:)(?