Compare commits

..

1 Commits

Author SHA1 Message Date
Robin Dadswell
0be2aee9c3 Fixed: Instance Name validation 2022-05-16 21:29:00 +01:00
464 changed files with 7663 additions and 75334 deletions

View File

@@ -5,9 +5,9 @@ body:
- type: checkboxes - type: checkboxes
attributes: attributes:
label: Is there an existing issue for this? label: Is there an existing issue for this?
description: Please search to see if an open or closed issue already exists for the bug you encountered. If a bug exists and is closed note that it may only be fixed in an unstable branch. description: Please search to see if an issue already exists for the bug you encountered.
options: options:
- label: I have searched the existing open and closed issues - label: I have searched the existing issues
required: true required: true
- type: textarea - type: textarea
attributes: attributes:

View File

@@ -5,9 +5,9 @@ body:
- type: checkboxes - type: checkboxes
attributes: attributes:
label: Is there an existing issue for this? label: Is there an existing issue for this?
description: Please search to see if an open or closed issue already exists for the feature you are requesting. If a request exists and is closed note that it may only be fixed in an unstable branch. description: Please search to see if an issue already exists for the feature you are requesting.
options: options:
- label: I have searched the existing open and closed issues - label: I have searched the existing issues
required: true required: true
- type: textarea - type: textarea
attributes: attributes:

View File

@@ -1,132 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
<development@prowlarr.com>.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

View File

@@ -27,7 +27,10 @@ Prowlarr is an indexer manager/proxy built on the popular \*arr .net/reactjs bas
## Support ## Support
Note: Prowlarr is currently early in life, thus bugs should be expected
[![Wiki](https://img.shields.io/badge/servarr-wiki-181717.svg?maxAge=60)](https://wiki.servarr.com/prowlarr) [![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) [![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) [![Reddit](https://img.shields.io/badge/reddit-discussion-FF4500.svg?maxAge=60)](https://www.reddit.com/r/Prowlarr)

View File

@@ -9,13 +9,13 @@ variables:
testsFolder: './_tests' testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '1.0.1' majorVersion: '0.4.0'
minorVersion: $[counter('minorVersion', 1)] minorVersion: $[counter('minorVersion', 1)]
prowlarrVersion: '$(majorVersion).$(minorVersion)' prowlarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(prowlarrVersion)' buildName: '$(Build.SourceBranchName).$(prowlarrVersion)'
sentryOrg: 'servarr' sentryOrg: 'servarr'
sentryUrl: 'https://sentry.servarr.com' sentryUrl: 'https://sentry.servarr.com'
dotnetVersion: '6.0.404' dotnetVersion: '6.0.201'
innoVersion: '6.2.0' innoVersion: '6.2.0'
nodeVersion: '16.x' nodeVersion: '16.x'
windowsImage: 'windows-2022' windowsImage: 'windows-2022'
@@ -97,14 +97,15 @@ stages:
- bash: | - bash: |
BUNDLEDVERSIONS=${AGENT_TOOLSDIRECTORY}/dotnet/sdk/${DOTNETVERSION}/Microsoft.NETCoreSdk.BundledVersions.props BUNDLEDVERSIONS=${AGENT_TOOLSDIRECTORY}/dotnet/sdk/${DOTNETVERSION}/Microsoft.NETCoreSdk.BundledVersions.props
echo $BUNDLEDVERSIONS echo $BUNDLEDVERSIONS
grep osx-x64 $BUNDLEDVERSIONS
if grep -q freebsd-x64 $BUNDLEDVERSIONS; then if grep -q freebsd-x64 $BUNDLEDVERSIONS; then
echo "Extra platforms already enabled" echo "BSD already enabled"
else else
echo "Enabling extra platform support" echo "Enabling BSD support"
sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64;linux-x86/' $BUNDLEDVERSIONS sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64/' $BUNDLEDVERSIONS
fi fi
displayName: Enable Extra Platform Support displayName: Enable FreeBSD Support
- bash: ./build.sh --backend --enable-extra-platforms - bash: ./build.sh --backend --enable-bsd
displayName: Build Prowlarr Backend displayName: Build Prowlarr Backend
- bash: | - bash: |
find ${OUTPUTFOLDER} -type f ! -path "*/publish/*" -exec rm -rf {} \; find ${OUTPUTFOLDER} -type f ! -path "*/publish/*" -exec rm -rf {} \;
@@ -118,28 +119,24 @@ stages:
displayName: Publish Backend displayName: Publish Backend
condition: and(succeeded(), eq(variables['osName'], 'Windows')) condition: and(succeeded(), eq(variables['osName'], 'Windows'))
- publish: '$(testsFolder)/net6.0/win-x64/publish' - publish: '$(testsFolder)/net6.0/win-x64/publish'
artifact: win-x64-tests artifact: WindowsCoreTests
displayName: Publish win-x64 Test Package displayName: Publish Windows Test Package
condition: and(succeeded(), eq(variables['osName'], 'Windows')) condition: and(succeeded(), eq(variables['osName'], 'Windows'))
- publish: '$(testsFolder)/net6.0/linux-x64/publish' - publish: '$(testsFolder)/net6.0/linux-x64/publish'
artifact: linux-x64-tests artifact: LinuxCoreTests
displayName: Publish linux-x64 Test Package displayName: Publish Linux Test Package
condition: and(succeeded(), eq(variables['osName'], 'Windows'))
- publish: '$(testsFolder)/net6.0/linux-x86/publish'
artifact: linux-x86-tests
displayName: Publish linux-x86 Test Package
condition: and(succeeded(), eq(variables['osName'], 'Windows')) condition: and(succeeded(), eq(variables['osName'], 'Windows'))
- publish: '$(testsFolder)/net6.0/linux-musl-x64/publish' - publish: '$(testsFolder)/net6.0/linux-musl-x64/publish'
artifact: linux-musl-x64-tests artifact: LinuxMuslCoreTests
displayName: Publish linux-musl-x64 Test Package displayName: Publish Linux Musl Test Package
condition: and(succeeded(), eq(variables['osName'], 'Windows')) condition: and(succeeded(), eq(variables['osName'], 'Windows'))
- publish: '$(testsFolder)/net6.0/freebsd-x64/publish' - publish: '$(testsFolder)/net6.0/freebsd-x64/publish'
artifact: freebsd-x64-tests artifact: FreebsdCoreTests
displayName: Publish freebsd-x64 Test Package displayName: Publish FreeBSD Test Package
condition: and(succeeded(), eq(variables['osName'], 'Windows')) condition: and(succeeded(), eq(variables['osName'], 'Windows'))
- publish: '$(testsFolder)/net6.0/osx-x64/publish' - publish: '$(testsFolder)/net6.0/osx-x64/publish'
artifact: osx-x64-tests artifact: MacCoreTests
displayName: Publish osx-x64 Test Package displayName: Publish MacOS Test Package
condition: and(succeeded(), eq(variables['osName'], 'Windows')) condition: and(succeeded(), eq(variables['osName'], 'Windows'))
- stage: Build_Frontend - stage: Build_Frontend
@@ -242,35 +239,35 @@ stages:
artifactName: WindowsFrontend artifactName: WindowsFrontend
targetPath: _output targetPath: _output
displayName: Fetch Frontend displayName: Fetch Frontend
- bash: ./build.sh --packages --enable-extra-platforms - bash: ./build.sh --packages --enable-bsd
displayName: Create Packages displayName: Create Packages
- bash: | - bash: |
find . -name "Prowlarr" -exec chmod a+x {} \; find . -name "Prowlarr" -exec chmod a+x {} \;
find . -name "Prowlarr.Update" -exec chmod a+x {} \; find . -name "Prowlarr.Update" -exec chmod a+x {} \;
displayName: Set executable bits displayName: Set executable bits
- task: ArchiveFiles@2 - task: ArchiveFiles@2
displayName: Create win-x64 zip displayName: Create Windows Core zip
inputs: inputs:
archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).windows-core-x64.zip' archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).windows-core-x64.zip'
archiveType: 'zip' archiveType: 'zip'
includeRootFolder: false includeRootFolder: false
rootFolderOrFile: $(artifactsFolder)/win-x64/net6.0 rootFolderOrFile: $(artifactsFolder)/win-x64/net6.0
- task: ArchiveFiles@2 - task: ArchiveFiles@2
displayName: Create win-x86 zip displayName: Create Windows x86 Core zip
inputs: inputs:
archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).windows-core-x86.zip' archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).windows-core-x86.zip'
archiveType: 'zip' archiveType: 'zip'
includeRootFolder: false includeRootFolder: false
rootFolderOrFile: $(artifactsFolder)/win-x86/net6.0 rootFolderOrFile: $(artifactsFolder)/win-x86/net6.0
- task: ArchiveFiles@2 - task: ArchiveFiles@2
displayName: Create osx-x64 app displayName: Create MacOS x64 Core app
inputs: inputs:
archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).osx-app-core-x64.zip' archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).osx-app-core-x64.zip'
archiveType: 'zip' archiveType: 'zip'
includeRootFolder: false includeRootFolder: false
rootFolderOrFile: $(artifactsFolder)/osx-x64-app/net6.0 rootFolderOrFile: $(artifactsFolder)/osx-x64-app/net6.0
- task: ArchiveFiles@2 - task: ArchiveFiles@2
displayName: Create osx-x64 tar displayName: Create MacOS x64 Core tar
inputs: inputs:
archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).osx-core-x64.tar.gz' archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).osx-core-x64.tar.gz'
archiveType: 'tar' archiveType: 'tar'
@@ -278,14 +275,14 @@ stages:
includeRootFolder: false includeRootFolder: false
rootFolderOrFile: $(artifactsFolder)/osx-x64/net6.0 rootFolderOrFile: $(artifactsFolder)/osx-x64/net6.0
- task: ArchiveFiles@2 - task: ArchiveFiles@2
displayName: Create osx-arm64 app displayName: Create MacOS arm64 Core app
inputs: inputs:
archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).osx-app-core-arm64.zip' archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).osx-app-core-arm64.zip'
archiveType: 'zip' archiveType: 'zip'
includeRootFolder: false includeRootFolder: false
rootFolderOrFile: $(artifactsFolder)/osx-arm64-app/net6.0 rootFolderOrFile: $(artifactsFolder)/osx-arm64-app/net6.0
- task: ArchiveFiles@2 - task: ArchiveFiles@2
displayName: Create osx-arm64 tar displayName: Create MacOS arm64 Core tar
inputs: inputs:
archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).osx-core-arm64.tar.gz' archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).osx-core-arm64.tar.gz'
archiveType: 'tar' archiveType: 'tar'
@@ -293,7 +290,7 @@ stages:
includeRootFolder: false includeRootFolder: false
rootFolderOrFile: $(artifactsFolder)/osx-arm64/net6.0 rootFolderOrFile: $(artifactsFolder)/osx-arm64/net6.0
- task: ArchiveFiles@2 - task: ArchiveFiles@2
displayName: Create linux-x64 tar displayName: Create Linux Core tar
inputs: inputs:
archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).linux-core-x64.tar.gz' archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).linux-core-x64.tar.gz'
archiveType: 'tar' archiveType: 'tar'
@@ -301,7 +298,7 @@ stages:
includeRootFolder: false includeRootFolder: false
rootFolderOrFile: $(artifactsFolder)/linux-x64/net6.0 rootFolderOrFile: $(artifactsFolder)/linux-x64/net6.0
- task: ArchiveFiles@2 - task: ArchiveFiles@2
displayName: Create linux-musl-x64 tar displayName: Create Linux Musl Core tar
inputs: inputs:
archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).linux-musl-core-x64.tar.gz' archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).linux-musl-core-x64.tar.gz'
archiveType: 'tar' archiveType: 'tar'
@@ -309,15 +306,7 @@ stages:
includeRootFolder: false includeRootFolder: false
rootFolderOrFile: $(artifactsFolder)/linux-musl-x64/net6.0 rootFolderOrFile: $(artifactsFolder)/linux-musl-x64/net6.0
- task: ArchiveFiles@2 - task: ArchiveFiles@2
displayName: Create linux-x86 tar displayName: Create ARM32 Linux Core tar
inputs:
archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).linux-core-x86.tar.gz'
archiveType: 'tar'
tarCompression: 'gz'
includeRootFolder: false
rootFolderOrFile: $(artifactsFolder)/linux-x86/net6.0
- task: ArchiveFiles@2
displayName: Create linux-arm tar
inputs: inputs:
archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).linux-core-arm.tar.gz' archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).linux-core-arm.tar.gz'
archiveType: 'tar' archiveType: 'tar'
@@ -325,7 +314,7 @@ stages:
includeRootFolder: false includeRootFolder: false
rootFolderOrFile: $(artifactsFolder)/linux-arm/net6.0 rootFolderOrFile: $(artifactsFolder)/linux-arm/net6.0
- task: ArchiveFiles@2 - task: ArchiveFiles@2
displayName: Create linux-musl-arm tar displayName: Create ARM32 Linux Musl Core tar
inputs: inputs:
archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).linux-musl-core-arm.tar.gz' archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).linux-musl-core-arm.tar.gz'
archiveType: 'tar' archiveType: 'tar'
@@ -333,7 +322,7 @@ stages:
includeRootFolder: false includeRootFolder: false
rootFolderOrFile: $(artifactsFolder)/linux-musl-arm/net6.0 rootFolderOrFile: $(artifactsFolder)/linux-musl-arm/net6.0
- task: ArchiveFiles@2 - task: ArchiveFiles@2
displayName: Create linux-arm64 tar displayName: Create ARM64 Linux Core tar
inputs: inputs:
archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).linux-core-arm64.tar.gz' archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).linux-core-arm64.tar.gz'
archiveType: 'tar' archiveType: 'tar'
@@ -341,7 +330,7 @@ stages:
includeRootFolder: false includeRootFolder: false
rootFolderOrFile: $(artifactsFolder)/linux-arm64/net6.0 rootFolderOrFile: $(artifactsFolder)/linux-arm64/net6.0
- task: ArchiveFiles@2 - task: ArchiveFiles@2
displayName: Create linux-musl-arm64 tar displayName: Create ARM64 Linux Musl Core tar
inputs: inputs:
archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).linux-musl-core-arm64.tar.gz' archiveFile: '$(Build.ArtifactStagingDirectory)/Prowlarr.$(buildName).linux-musl-core-arm64.tar.gz'
archiveType: 'tar' archiveType: 'tar'
@@ -416,22 +405,22 @@ stages:
matrix: matrix:
MacCore: MacCore:
osName: 'Mac' osName: 'Mac'
testName: 'osx-x64' testName: 'MacCore'
poolName: 'Azure Pipelines' poolName: 'Azure Pipelines'
imageName: ${{ variables.macImage }} imageName: ${{ variables.macImage }}
WindowsCore: WindowsCore:
osName: 'Windows' osName: 'Windows'
testName: 'win-x64' testName: 'WindowsCore'
poolName: 'Azure Pipelines' poolName: 'Azure Pipelines'
imageName: ${{ variables.windowsImage }} imageName: ${{ variables.windowsImage }}
LinuxCore: LinuxCore:
osName: 'Linux' osName: 'Linux'
testName: 'linux-x64' testName: 'LinuxCore'
poolName: 'Azure Pipelines' poolName: 'Azure Pipelines'
imageName: ${{ variables.linuxImage }} imageName: ${{ variables.linuxImage }}
FreebsdCore: FreebsdCore:
osName: 'Linux' osName: 'Linux'
testName: 'freebsd-x64' testName: 'FreebsdCore'
poolName: 'FreeBSD' poolName: 'FreeBSD'
imageName: imageName:
@@ -450,7 +439,7 @@ stages:
displayName: Download Test Artifact displayName: Download Test Artifact
inputs: inputs:
buildType: 'current' buildType: 'current'
artifactName: '$(testName)-tests' artifactName: '$(testName)Tests'
targetPath: $(testsFolder) targetPath: $(testsFolder)
- powershell: Set-Service SCardSvr -StartupType Manual - powershell: Set-Service SCardSvr -StartupType Manual
displayName: Enable Windows Test Service displayName: Enable Windows Test Service
@@ -480,12 +469,8 @@ stages:
matrix: matrix:
alpine: alpine:
testName: 'Musl Net Core' testName: 'Musl Net Core'
artifactName: linux-musl-x64-tests artifactName: LinuxMuslCoreTests
containerImage: ghcr.io/servarr/testimages:alpine containerImage: ghcr.io/servarr/testimages:alpine
linux-x86:
testName: 'linux-x86'
artifactName: linux-x86-tests
containerImage: ghcr.io/servarr/testimages:linux-x86
pool: pool:
vmImage: ${{ variables.linuxImage }} vmImage: ${{ variables.linuxImage }}
@@ -496,15 +481,9 @@ stages:
steps: steps:
- task: UseDotNet@2 - task: UseDotNet@2
displayName: 'Install .NET' displayName: 'Install .net core'
inputs: inputs:
version: $(dotnetVersion) version: $(dotnetVersion)
condition: and(succeeded(), ne(variables['testName'], 'linux-x86'))
- bash: |
SDKURL=$(curl -s https://api.github.com/repos/Servarr/dotnet-linux-x86/releases | jq -rc '.[].assets[].browser_download_url' | grep sdk-${DOTNETVERSION}.*gz$)
curl -fsSL $SDKURL | tar xzf - -C /opt/dotnet
displayName: 'Install .NET'
condition: and(succeeded(), eq(variables['testName'], 'linux-x86'))
- checkout: none - checkout: none
- task: DownloadPipelineArtifact@2 - task: DownloadPipelineArtifact@2
displayName: Download Test Artifact displayName: Download Test Artifact
@@ -527,58 +506,6 @@ stages:
testResultsFiles: '**/TestResult.xml' testResultsFiles: '**/TestResult.xml'
testRunTitle: '$(testName) Unit Tests' testRunTitle: '$(testName) Unit Tests'
failTaskOnFailedTests: true failTaskOnFailedTests: true
- job: Unit_LinuxCore_Postgres
displayName: Unit 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'
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=postgres14 \
-e POSTGRES_PASSWORD=prowlarr \
-e POSTGRES_USER=prowlarr \
-p 5432:5432/tcp \
postgres:14
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 Postgres Unit Tests'
failTaskOnFailedTests: true
- stage: Integration - stage: Integration
displayName: Integration displayName: Integration
@@ -606,17 +533,17 @@ stages:
matrix: matrix:
MacCore: MacCore:
osName: 'Mac' osName: 'Mac'
testName: 'osx-x64' testName: 'MacCore'
imageName: ${{ variables.macImage }} imageName: ${{ variables.macImage }}
pattern: 'Prowlarr.*.osx-core-x64.tar.gz' pattern: 'Prowlarr.*.osx-core-x64.tar.gz'
WindowsCore: WindowsCore:
osName: 'Windows' osName: 'Windows'
testName: 'win-x64' testName: 'WindowsCore'
imageName: ${{ variables.windowsImage }} imageName: ${{ variables.windowsImage }}
pattern: 'Prowlarr.*.windows-core-x64.zip' pattern: 'Prowlarr.*.windows-core-x64.zip'
LinuxCore: LinuxCore:
osName: 'Linux' osName: 'Linux'
testName: 'linux-x64' testName: 'LinuxCore'
imageName: ${{ variables.linuxImage }} imageName: ${{ variables.linuxImage }}
pattern: 'Prowlarr.*.linux-core-x64.tar.gz' pattern: 'Prowlarr.*.linux-core-x64.tar.gz'
@@ -633,7 +560,7 @@ stages:
displayName: Download Test Artifact displayName: Download Test Artifact
inputs: inputs:
buildType: 'current' buildType: 'current'
artifactName: '$(testName)-tests' artifactName: '$(testName)Tests'
targetPath: $(testsFolder) targetPath: $(testsFolder)
- task: DownloadPipelineArtifact@2 - task: DownloadPipelineArtifact@2
displayName: Download Build Artifact displayName: Download Build Artifact
@@ -663,67 +590,6 @@ stages:
failTaskOnFailedTests: true failTaskOnFailedTests: true
displayName: Publish Test Results displayName: Publish Test Results
- job: Integration_LinuxCore_Postgres
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=postgres14 \
-e POSTGRES_PASSWORD=prowlarr \
-e POSTGRES_USER=prowlarr \
-p 5432:5432/tcp \
postgres:14
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 Postgres Database Integration Tests'
failTaskOnFailedTests: true
displayName: Publish Test Results
- job: Integration_FreeBSD - job: Integration_FreeBSD
displayName: Integration Native FreeBSD displayName: Integration Native FreeBSD
dependsOn: Prepare dependsOn: Prepare
@@ -741,14 +607,14 @@ stages:
displayName: Download Test Artifact displayName: Download Test Artifact
inputs: inputs:
buildType: 'current' buildType: 'current'
artifactName: 'freebsd-x64-tests' artifactName: 'FreebsdCoreTests'
targetPath: $(testsFolder) targetPath: $(testsFolder)
- task: DownloadPipelineArtifact@2 - task: DownloadPipelineArtifact@2
displayName: Download Build Artifact displayName: Download Build Artifact
inputs: inputs:
buildType: 'current' buildType: 'current'
artifactName: Packages artifactName: Packages
itemPattern: '**/$(pattern)' itemPattern: '/$(pattern)'
targetPath: $(Build.ArtifactStagingDirectory) targetPath: $(Build.ArtifactStagingDirectory)
- bash: | - bash: |
mkdir -p ${BUILD_ARTIFACTSTAGINGDIRECTORY}/bin mkdir -p ${BUILD_ARTIFACTSTAGINGDIRECTORY}/bin
@@ -777,15 +643,11 @@ stages:
strategy: strategy:
matrix: matrix:
alpine: alpine:
testName: 'linux-musl-x64' testName: 'Musl Net Core'
artifactName: linux-musl-x64-tests artifactName: LinuxMuslCoreTests
containerImage: ghcr.io/servarr/testimages:alpine containerImage: ghcr.io/servarr/testimages:alpine
pattern: 'Prowlarr.*.linux-musl-core-x64.tar.gz' pattern: 'Prowlarr.*.linux-musl-core-x64.tar.gz'
linux-x86:
testName: 'linux-x86'
artifactName: linux-x86-tests
containerImage: ghcr.io/servarr/testimages:linux-x86
pattern: 'Prowlarr.*.linux-core-x86.tar.gz'
pool: pool:
vmImage: ${{ variables.linuxImage }} vmImage: ${{ variables.linuxImage }}
@@ -795,15 +657,9 @@ stages:
steps: steps:
- task: UseDotNet@2 - task: UseDotNet@2
displayName: 'Install .NET' displayName: 'Install .net core'
inputs: inputs:
version: $(dotnetVersion) version: $(dotnetVersion)
condition: and(succeeded(), ne(variables['testName'], 'linux-x86'))
- bash: |
SDKURL=$(curl -s https://api.github.com/repos/Servarr/dotnet-linux-x86/releases | jq -rc '.[].assets[].browser_download_url' | grep sdk-${DOTNETVERSION}.*gz$)
curl -fsSL $SDKURL | tar xzf - -C /opt/dotnet
displayName: 'Install .NET'
condition: and(succeeded(), eq(variables['testName'], 'linux-x86'))
- checkout: none - checkout: none
- task: DownloadPipelineArtifact@2 - task: DownloadPipelineArtifact@2
displayName: Download Test Artifact displayName: Download Test Artifact
@@ -849,19 +705,16 @@ stages:
matrix: matrix:
Linux: Linux:
osName: 'Linux' osName: 'Linux'
artifactName: 'linux-x64'
imageName: ${{ variables.linuxImage }} imageName: ${{ variables.linuxImage }}
pattern: 'Prowlarr.*.linux-core-x64.tar.gz' pattern: 'Prowlarr.*.linux-core-x64.tar.gz'
failBuild: true failBuild: true
Mac: Mac:
osName: 'Mac' osName: 'Mac'
artifactName: 'osx-x64'
imageName: ${{ variables.macImage }} imageName: ${{ variables.macImage }}
pattern: 'Prowlarr.*.osx-core-x64.tar.gz' pattern: 'Prowlarr.*.osx-core-x64.tar.gz'
failBuild: true failBuild: true
Windows: Windows:
osName: 'Windows' osName: 'Windows'
artifactName: 'win-x64'
imageName: ${{ variables.windowsImage }} imageName: ${{ variables.windowsImage }}
pattern: 'Prowlarr.*.windows-core-x64.zip' pattern: 'Prowlarr.*.windows-core-x64.zip'
failBuild: true failBuild: true
@@ -879,7 +732,7 @@ stages:
displayName: Download Test Artifact displayName: Download Test Artifact
inputs: inputs:
buildType: 'current' buildType: 'current'
artifactName: '$(artifactName)-tests' artifactName: '$(osName)CoreTests'
targetPath: $(testsFolder) targetPath: $(testsFolder)
- task: DownloadPipelineArtifact@2 - task: DownloadPipelineArtifact@2
displayName: Download Build Artifact displayName: Download Build Artifact
@@ -1108,5 +961,4 @@ stages:
SYSTEM_ACCESSTOKEN: $(System.AccessToken) SYSTEM_ACCESSTOKEN: $(System.AccessToken)
DISCORDCHANNELID: $(discordChannelId) DISCORDCHANNELID: $(discordChannelId)
DISCORDWEBHOOKKEY: $(discordWebhookKey) DISCORDWEBHOOKKEY: $(discordWebhookKey)
DISCORDTHREADID: $(discordThreadId)

View File

@@ -25,22 +25,15 @@ UpdateVersionNumber()
fi fi
} }
EnableExtraPlatformsInSDK() EnableBsdSupport()
{ {
SDK_PATH=$(dotnet --list-sdks | grep -P '6\.\d\.\d+' | head -1 | sed 's/\(6\.[0-9]*\.[0-9]*\).*\[\(.*\)\]/\2\/\1/g') #todo enable sdk with
BUNDLEDVERSIONS="${SDK_PATH}/Microsoft.NETCoreSdk.BundledVersions.props" #SDK_PATH=$(dotnet --list-sdks | grep -P '5\.\d\.\d+' | head -1 | sed 's/\(5\.[0-9]*\.[0-9]*\).*\[\(.*\)\]/\2\/\1/g')
if grep -q freebsd-x64 $BUNDLEDVERSIONS; then # BUNDLED_VERSIONS="${SDK_PATH}/Microsoft.NETCoreSdk.BundledVersions.props"
echo "Extra platforms already enabled"
else
echo "Enabling extra platform support"
sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64;linux-x86/' $BUNDLEDVERSIONS
fi
}
EnableExtraPlatforms()
{
if grep -qv freebsd-x64 src/Directory.Build.props; then if grep -qv freebsd-x64 src/Directory.Build.props; then
sed -i'' -e "s^<RuntimeIdentifiers>\(.*\)</RuntimeIdentifiers>^<RuntimeIdentifiers>\1;freebsd-x64;linux-x86</RuntimeIdentifiers>^g" src/Directory.Build.props sed -i'' -e "s^<RuntimeIdentifiers>\(.*\)</RuntimeIdentifiers>^<RuntimeIdentifiers>\1;freebsd-x64</RuntimeIdentifiers>^g" src/Directory.Build.props
sed -i'' -e "s^<ExcludedRuntimeFrameworkPairs>\(.*\)</ExcludedRuntimeFrameworkPairs>^<ExcludedRuntimeFrameworkPairs>\1;freebsd-x64:net472</ExcludedRuntimeFrameworkPairs>^g" src/Directory.Build.props
fi fi
} }
@@ -300,8 +293,7 @@ if [ $# -eq 0 ]; then
PACKAGES=YES PACKAGES=YES
INSTALLER=NO INSTALLER=NO
LINT=YES LINT=YES
ENABLE_EXTRA_PLATFORMS=NO ENABLE_BSD=NO
ENABLE_EXTRA_PLATFORMS_IN_SDK=NO
fi fi
while [[ $# -gt 0 ]] while [[ $# -gt 0 ]]
@@ -313,12 +305,8 @@ case $key in
BACKEND=YES BACKEND=YES
shift # past argument shift # past argument
;; ;;
--enable-bsd|--enable-extra-platforms) --enable-bsd)
ENABLE_EXTRA_PLATFORMS=YES ENABLE_BSD=YES
shift # past argument
;;
--enable-extra-platforms-in-sdk)
ENABLE_EXTRA_PLATFORMS_IN_SDK=YES
shift # past argument shift # past argument
;; ;;
-r|--runtime) -r|--runtime)
@@ -362,17 +350,12 @@ esac
done done
set -- "${POSITIONAL[@]}" # restore positional parameters set -- "${POSITIONAL[@]}" # restore positional parameters
if [ "$ENABLE_EXTRA_PLATFORMS_IN_SDK" = "YES" ];
then
EnableExtraPlatformsInSDK
fi
if [ "$BACKEND" = "YES" ]; if [ "$BACKEND" = "YES" ];
then then
UpdateVersionNumber UpdateVersionNumber
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ]; if [ "$ENABLE_BSD" = "YES" ];
then then
EnableExtraPlatforms EnableBsdSupport
fi fi
Build Build
if [[ -z "$RID" || -z "$FRAMEWORK" ]]; if [[ -z "$RID" || -z "$FRAMEWORK" ]];
@@ -382,10 +365,9 @@ then
PackageTests "net6.0" "linux-x64" PackageTests "net6.0" "linux-x64"
PackageTests "net6.0" "linux-musl-x64" PackageTests "net6.0" "linux-musl-x64"
PackageTests "net6.0" "osx-x64" PackageTests "net6.0" "osx-x64"
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ]; if [ "$ENABLE_BSD" = "YES" ];
then then
PackageTests "net6.0" "freebsd-x64" PackageTests "net6.0" "freebsd-x64"
PackageTests "net6.0" "linux-x86"
fi fi
else else
PackageTests "$FRAMEWORK" "$RID" PackageTests "$FRAMEWORK" "$RID"
@@ -424,10 +406,9 @@ then
Package "net6.0" "linux-musl-arm" Package "net6.0" "linux-musl-arm"
Package "net6.0" "osx-x64" Package "net6.0" "osx-x64"
Package "net6.0" "osx-arm64" Package "net6.0" "osx-arm64"
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ]; if [ "$ENABLE_BSD" = "YES" ];
then then
Package "net6.0" "freebsd-x64" Package "net6.0" "freebsd-x64"
Package "net6.0" "linux-x86"
fi fi
else else
Package "$FRAMEWORK" "$RID" Package "$FRAMEWORK" "$RID"

View File

@@ -78,7 +78,7 @@
border: 1px solid var(--inputBorderColor); border: 1px solid var(--inputBorderColor);
border-radius: 4px; border-radius: 4px;
background-color: var(--inputBackgroundColor); background-color: var(--white);
} }
.loading { .loading {

View File

@@ -16,7 +16,6 @@ import FormInputHelpText from './FormInputHelpText';
import IndexerFlagsSelectInputConnector from './IndexerFlagsSelectInputConnector'; import IndexerFlagsSelectInputConnector from './IndexerFlagsSelectInputConnector';
import InfoInput from './InfoInput'; import InfoInput from './InfoInput';
import KeyValueListInput from './KeyValueListInput'; import KeyValueListInput from './KeyValueListInput';
import NewznabCategorySelectInputConnector from './NewznabCategorySelectInputConnector';
import NumberInput from './NumberInput'; import NumberInput from './NumberInput';
import OAuthInputConnector from './OAuthInputConnector'; import OAuthInputConnector from './OAuthInputConnector';
import PasswordInput from './PasswordInput'; import PasswordInput from './PasswordInput';
@@ -69,9 +68,6 @@ function getComponent(type) {
case inputTypes.PATH: case inputTypes.PATH:
return PathInputConnector; return PathInputConnector;
case inputTypes.CATEGORY_SELECT:
return NewznabCategorySelectInputConnector;
case inputTypes.INDEXER_FLAGS_SELECT: case inputTypes.INDEXER_FLAGS_SELECT:
return IndexerFlagsSelectInputConnector; return IndexerFlagsSelectInputConnector;

View File

@@ -31,7 +31,7 @@ function createMapStateToProps() {
}); });
return { return {
value: value || [], value,
values values
}; };
} }

View File

@@ -36,6 +36,7 @@ class TagInputInput extends Component {
<div <div
ref={forwardedRef} ref={forwardedRef}
className={className} className={className}
component="div"
onMouseDown={this.onMouseDown} onMouseDown={this.onMouseDown}
> >
{ {

View File

@@ -36,5 +36,5 @@
/** Outline **/ /** Outline **/
.outline { .outline {
background-color: var(--cardBackgroundColor); background-color: var(--white);
} }

View File

@@ -108,5 +108,5 @@
/** Outline **/ /** Outline **/
.outline { .outline {
background-color: var(--cardBackgroundColor); background-color: var(--white);
} }

View File

@@ -74,7 +74,7 @@ class PageHeader extends Component {
<IconButton <IconButton
className={styles.donate} className={styles.donate}
name={icons.HEART} name={icons.HEART}
to="https://prowlarr.com/donate" to="https://opencollective.com/prowlarr"
size={14} size={14}
/> />
<IconButton <IconButton

View File

@@ -5,7 +5,7 @@
text-align: center; text-align: center;
&:hover { &:hover {
color: #515253; color: var(--toobarButtonHoverColor);
} }
} }

View File

@@ -4,7 +4,6 @@ import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector';
import ColorImpairedContext from 'App/ColorImpairedContext'; import ColorImpairedContext from 'App/ColorImpairedContext';
import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector'; import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector';
import SignalRConnector from 'Components/SignalRConnector'; import SignalRConnector from 'Components/SignalRConnector';
import AuthenticationRequiredModal from 'FirstRun/AuthenticationRequiredModal';
import locationShape from 'Helpers/Props/Shapes/locationShape'; import locationShape from 'Helpers/Props/Shapes/locationShape';
import PageHeader from './Header/PageHeader'; import PageHeader from './Header/PageHeader';
import PageSidebar from './Sidebar/PageSidebar'; import PageSidebar from './Sidebar/PageSidebar';
@@ -76,7 +75,6 @@ class Page extends Component {
isSmallScreen, isSmallScreen,
isSidebarVisible, isSidebarVisible,
enableColorImpairedMode, enableColorImpairedMode,
authenticationEnabled,
onSidebarToggle, onSidebarToggle,
onSidebarVisibleChange onSidebarVisibleChange
} = this.props; } = this.props;
@@ -111,10 +109,6 @@ class Page extends Component {
isOpen={this.state.isConnectionLostModalOpen} isOpen={this.state.isConnectionLostModalOpen}
onModalClose={this.onConnectionLostModalClose} onModalClose={this.onConnectionLostModalClose}
/> />
<AuthenticationRequiredModal
isOpen={!authenticationEnabled}
/>
</div> </div>
</ColorImpairedContext.Provider> </ColorImpairedContext.Provider>
); );
@@ -130,7 +124,6 @@ Page.propTypes = {
isUpdated: PropTypes.bool.isRequired, isUpdated: PropTypes.bool.isRequired,
isDisconnected: PropTypes.bool.isRequired, isDisconnected: PropTypes.bool.isRequired,
enableColorImpairedMode: PropTypes.bool.isRequired, enableColorImpairedMode: PropTypes.bool.isRequired,
authenticationEnabled: PropTypes.bool.isRequired,
onResize: PropTypes.func.isRequired, onResize: PropTypes.func.isRequired,
onSidebarToggle: PropTypes.func.isRequired, onSidebarToggle: PropTypes.func.isRequired,
onSidebarVisibleChange: PropTypes.func.isRequired onSidebarVisibleChange: PropTypes.func.isRequired

View File

@@ -7,11 +7,10 @@ import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions';
import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
import { fetchIndexers } from 'Store/Actions/indexerActions'; import { fetchIndexers } from 'Store/Actions/indexerActions';
import { fetchIndexerStatus } from 'Store/Actions/indexerStatusActions'; import { fetchIndexerStatus } from 'Store/Actions/indexerStatusActions';
import { fetchAppProfiles, fetchGeneralSettings, fetchIndexerCategories, fetchUISettings } from 'Store/Actions/settingsActions'; import { fetchAppProfiles, fetchGeneralSettings, fetchIndexerCategories, fetchLanguages, fetchUISettings } from 'Store/Actions/settingsActions';
import { fetchStatus } from 'Store/Actions/systemActions'; import { fetchStatus } from 'Store/Actions/systemActions';
import { fetchTags } from 'Store/Actions/tagActions'; import { fetchTags } from 'Store/Actions/tagActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import ErrorPage from './ErrorPage'; import ErrorPage from './ErrorPage';
import LoadingPage from './LoadingPage'; import LoadingPage from './LoadingPage';
import Page from './Page'; import Page from './Page';
@@ -49,6 +48,7 @@ const selectIsPopulated = createSelector(
(state) => state.tags.isPopulated, (state) => state.tags.isPopulated,
(state) => state.settings.ui.isPopulated, (state) => state.settings.ui.isPopulated,
(state) => state.settings.general.isPopulated, (state) => state.settings.general.isPopulated,
(state) => state.settings.languages.isPopulated,
(state) => state.settings.appProfiles.isPopulated, (state) => state.settings.appProfiles.isPopulated,
(state) => state.indexers.isPopulated, (state) => state.indexers.isPopulated,
(state) => state.indexerStatus.isPopulated, (state) => state.indexerStatus.isPopulated,
@@ -59,6 +59,7 @@ const selectIsPopulated = createSelector(
tagsIsPopulated, tagsIsPopulated,
uiSettingsIsPopulated, uiSettingsIsPopulated,
generalSettingsIsPopulated, generalSettingsIsPopulated,
languagesIsPopulated,
appProfilesIsPopulated, appProfilesIsPopulated,
indexersIsPopulated, indexersIsPopulated,
indexerStatusIsPopulated, indexerStatusIsPopulated,
@@ -70,6 +71,7 @@ const selectIsPopulated = createSelector(
tagsIsPopulated && tagsIsPopulated &&
uiSettingsIsPopulated && uiSettingsIsPopulated &&
generalSettingsIsPopulated && generalSettingsIsPopulated &&
languagesIsPopulated &&
appProfilesIsPopulated && appProfilesIsPopulated &&
indexersIsPopulated && indexersIsPopulated &&
indexerStatusIsPopulated && indexerStatusIsPopulated &&
@@ -84,6 +86,7 @@ const selectErrors = createSelector(
(state) => state.tags.error, (state) => state.tags.error,
(state) => state.settings.ui.error, (state) => state.settings.ui.error,
(state) => state.settings.general.error, (state) => state.settings.general.error,
(state) => state.settings.languages.error,
(state) => state.settings.appProfiles.error, (state) => state.settings.appProfiles.error,
(state) => state.indexers.error, (state) => state.indexers.error,
(state) => state.indexerStatus.error, (state) => state.indexerStatus.error,
@@ -94,6 +97,7 @@ const selectErrors = createSelector(
tagsError, tagsError,
uiSettingsError, uiSettingsError,
generalSettingsError, generalSettingsError,
languagesError,
appProfilesError, appProfilesError,
indexersError, indexersError,
indexerStatusError, indexerStatusError,
@@ -105,6 +109,7 @@ const selectErrors = createSelector(
tagsError || tagsError ||
uiSettingsError || uiSettingsError ||
generalSettingsError || generalSettingsError ||
languagesError ||
appProfilesError || appProfilesError ||
indexersError || indexersError ||
indexerStatusError || indexerStatusError ||
@@ -118,6 +123,7 @@ const selectErrors = createSelector(
tagsError, tagsError,
uiSettingsError, uiSettingsError,
generalSettingsError, generalSettingsError,
languagesError,
appProfilesError, appProfilesError,
indexersError, indexersError,
indexerStatusError, indexerStatusError,
@@ -134,21 +140,18 @@ function createMapStateToProps() {
selectErrors, selectErrors,
selectAppProps, selectAppProps,
createDimensionsSelector(), createDimensionsSelector(),
createSystemStatusSelector(),
( (
enableColorImpairedMode, enableColorImpairedMode,
isPopulated, isPopulated,
errors, errors,
app, app,
dimensions, dimensions
systemStatus
) => { ) => {
return { return {
...app, ...app,
...errors, ...errors,
isPopulated, isPopulated,
isSmallScreen: dimensions.isSmallScreen, isSmallScreen: dimensions.isSmallScreen,
authenticationEnabled: systemStatus.authentication !== 'none',
enableColorImpairedMode enableColorImpairedMode
}; };
} }
@@ -163,6 +166,9 @@ function createMapDispatchToProps(dispatch, props) {
dispatchFetchTags() { dispatchFetchTags() {
dispatch(fetchTags()); dispatch(fetchTags());
}, },
dispatchFetchLanguages() {
dispatch(fetchLanguages());
},
dispatchFetchIndexers() { dispatchFetchIndexers() {
dispatch(fetchIndexers()); dispatch(fetchIndexers());
}, },
@@ -210,6 +216,7 @@ class PageConnector extends Component {
if (!this.props.isPopulated) { if (!this.props.isPopulated) {
this.props.dispatchFetchCustomFilters(); this.props.dispatchFetchCustomFilters();
this.props.dispatchFetchTags(); this.props.dispatchFetchTags();
this.props.dispatchFetchLanguages();
this.props.dispatchFetchAppProfiles(); this.props.dispatchFetchAppProfiles();
this.props.dispatchFetchIndexers(); this.props.dispatchFetchIndexers();
this.props.dispatchFetchIndexerStatus(); this.props.dispatchFetchIndexerStatus();
@@ -235,6 +242,7 @@ class PageConnector extends Component {
isPopulated, isPopulated,
hasError, hasError,
dispatchFetchTags, dispatchFetchTags,
dispatchFetchLanguages,
dispatchFetchAppProfiles, dispatchFetchAppProfiles,
dispatchFetchIndexers, dispatchFetchIndexers,
dispatchFetchIndexerStatus, dispatchFetchIndexerStatus,
@@ -275,6 +283,7 @@ PageConnector.propTypes = {
isSidebarVisible: PropTypes.bool.isRequired, isSidebarVisible: PropTypes.bool.isRequired,
dispatchFetchCustomFilters: PropTypes.func.isRequired, dispatchFetchCustomFilters: PropTypes.func.isRequired,
dispatchFetchTags: PropTypes.func.isRequired, dispatchFetchTags: PropTypes.func.isRequired,
dispatchFetchLanguages: PropTypes.func.isRequired,
dispatchFetchAppProfiles: PropTypes.func.isRequired, dispatchFetchAppProfiles: PropTypes.func.isRequired,
dispatchFetchIndexers: PropTypes.func.isRequired, dispatchFetchIndexers: PropTypes.func.isRequired,
dispatchFetchIndexerStatus: PropTypes.func.isRequired, dispatchFetchIndexerStatus: PropTypes.func.isRequired,

View File

@@ -20,9 +20,9 @@ const SIDEBAR_WIDTH = parseInt(dimensions.sidebarWidth);
const links = [ const links = [
{ {
iconName: icons.MOVIE_CONTINUING, iconName: icons.MOVIE_CONTINUING,
title: translate('Indexers'), title: 'Indexers',
to: '/', to: '/',
alias: '/indexers', alias: '/movies',
children: [ children: [
{ {
title: translate('Stats'), title: translate('Stats'),
@@ -33,13 +33,13 @@ const links = [
{ {
iconName: icons.SEARCH, iconName: icons.SEARCH,
title: translate('Search'), title: 'Search',
to: '/search' to: '/search'
}, },
{ {
iconName: icons.ACTIVITY, iconName: icons.ACTIVITY,
title: translate('History'), title: 'History',
to: '/history' to: '/history'
}, },

View File

@@ -7,7 +7,7 @@
position: relative; position: relative;
&.default { &.default {
background-color: var(--popoverBodyBackgroundColor); background-color: var(--white);
box-shadow: 0 5px 10px var(--popoverShadowColor); box-shadow: 0 5px 10px var(--popoverShadowColor);
} }

View File

@@ -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 AuthenticationRequiredModalContentConnector from './AuthenticationRequiredModalContentConnector';
function onModalClose() {
// No-op
}
function AuthenticationRequiredModal(props) {
const {
isOpen
} = props;
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
<AuthenticationRequiredModalContentConnector
onModalClose={onModalClose}
/>
</Modal>
);
}
AuthenticationRequiredModal.propTypes = {
isOpen: PropTypes.bool.isRequired
};
export default AuthenticationRequiredModal;

View File

@@ -1,5 +0,0 @@
.authRequiredAlert {
composes: alert from '~Components/Alert.css';
margin-bottom: 20px;
}

View File

@@ -1,165 +0,0 @@
import PropTypes from 'prop-types';
import React, { useEffect, useRef } from 'react';
import Alert from 'Components/Alert';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import SpinnerButton from 'Components/Link/SpinnerButton';
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 { inputTypes, kinds } from 'Helpers/Props';
import { authenticationMethodOptions, authenticationRequiredOptions, authenticationRequiredWarning } from 'Settings/General/SecuritySettings';
import translate from 'Utilities/String/translate';
import styles from './AuthenticationRequiredModalContent.css';
function onModalClose() {
// No-op
}
function AuthenticationRequiredModalContent(props) {
const {
isPopulated,
error,
isSaving,
settings,
onInputChange,
onSavePress,
dispatchFetchStatus
} = props;
const {
authenticationMethod,
authenticationRequired,
username,
password
} = settings;
const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none';
const didMount = useRef(false);
useEffect(() => {
if (!isSaving && didMount.current) {
dispatchFetchStatus();
}
didMount.current = true;
}, [isSaving, dispatchFetchStatus]);
return (
<ModalContent
showCloseButton={false}
onModalClose={onModalClose}
>
<ModalHeader>
{translate('AuthenticationRequired')}
</ModalHeader>
<ModalBody>
<Alert
className={styles.authRequiredAlert}
kind={kinds.WARNING}
>
{authenticationRequiredWarning}
</Alert>
{
isPopulated && !error ?
<div>
<FormGroup>
<FormLabel>{translate('Authentication')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="authenticationMethod"
values={authenticationMethodOptions}
helpText={translate('AuthenticationMethodHelpText')}
onChange={onInputChange}
{...authenticationMethod}
/>
</FormGroup>
{
authenticationEnabled ?
<FormGroup>
<FormLabel>{translate('AuthenticationRequired')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="authenticationRequired"
values={authenticationRequiredOptions}
helpText={translate('AuthenticationRequiredHelpText')}
onChange={onInputChange}
{...authenticationRequired}
/>
</FormGroup> :
null
}
{
authenticationEnabled ?
<FormGroup>
<FormLabel>{translate('Username')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="username"
onChange={onInputChange}
{...username}
/>
</FormGroup> :
null
}
{
authenticationEnabled ?
<FormGroup>
<FormLabel>{translate('Password')}</FormLabel>
<FormInputGroup
type={inputTypes.PASSWORD}
name="password"
onChange={onInputChange}
{...password}
/>
</FormGroup> :
null
}
</div> :
null
}
{
!isPopulated && !error ? <LoadingIndicator /> : null
}
</ModalBody>
<ModalFooter>
<SpinnerButton
kind={kinds.PRIMARY}
isSpinning={isSaving}
isDisabled={!authenticationEnabled}
onPress={onSavePress}
>
{translate('Save')}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
AuthenticationRequiredModalContent.propTypes = {
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
settings: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
dispatchFetchStatus: PropTypes.func.isRequired
};
export default AuthenticationRequiredModalContent;

View File

@@ -1,86 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import { fetchGeneralSettings, saveGeneralSettings, setGeneralSettingsValue } from 'Store/Actions/settingsActions';
import { fetchStatus } from 'Store/Actions/systemActions';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import AuthenticationRequiredModalContent from './AuthenticationRequiredModalContent';
const SECTION = 'general';
function createMapStateToProps() {
return createSelector(
createSettingsSectionSelector(SECTION),
(sectionSettings) => {
return {
...sectionSettings
};
}
);
}
const mapDispatchToProps = {
dispatchClearPendingChanges: clearPendingChanges,
dispatchSetGeneralSettingsValue: setGeneralSettingsValue,
dispatchSaveGeneralSettings: saveGeneralSettings,
dispatchFetchGeneralSettings: fetchGeneralSettings,
dispatchFetchStatus: fetchStatus
};
class AuthenticationRequiredModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchGeneralSettings();
}
componentWillUnmount() {
this.props.dispatchClearPendingChanges({ section: `settings.${SECTION}` });
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.dispatchSetGeneralSettingsValue({ name, value });
};
onSavePress = () => {
this.props.dispatchSaveGeneralSettings();
};
//
// Render
render() {
const {
dispatchClearPendingChanges,
dispatchFetchGeneralSettings,
dispatchSetGeneralSettingsValue,
dispatchSaveGeneralSettings,
...otherProps
} = this.props;
return (
<AuthenticationRequiredModalContent
{...otherProps}
onInputChange={this.onInputChange}
onSavePress={this.onSavePress}
/>
);
}
}
AuthenticationRequiredModalContentConnector.propTypes = {
dispatchClearPendingChanges: PropTypes.func.isRequired,
dispatchFetchGeneralSettings: PropTypes.func.isRequired,
dispatchSetGeneralSettingsValue: PropTypes.func.isRequired,
dispatchSaveGeneralSettings: PropTypes.func.isRequired,
dispatchFetchStatus: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AuthenticationRequiredModalContentConnector);

View File

@@ -8,7 +8,6 @@ export const DEVICE = 'device';
export const KEY_VALUE_LIST = 'keyValueList'; export const KEY_VALUE_LIST = 'keyValueList';
export const INFO = 'info'; export const INFO = 'info';
export const MOVIE_MONITORED_SELECT = 'movieMonitoredSelect'; export const MOVIE_MONITORED_SELECT = 'movieMonitoredSelect';
export const CATEGORY_SELECT = 'newznabCategorySelect';
export const NUMBER = 'number'; export const NUMBER = 'number';
export const OAUTH = 'oauth'; export const OAUTH = 'oauth';
export const PASSWORD = 'password'; export const PASSWORD = 'password';
@@ -33,7 +32,6 @@ export const all = [
KEY_VALUE_LIST, KEY_VALUE_LIST,
INFO, INFO,
MOVIE_MONITORED_SELECT, MOVIE_MONITORED_SELECT,
CATEGORY_SELECT,
NUMBER, NUMBER,
OAUTH, OAUTH,
PASSWORD, PASSWORD,

View File

@@ -226,42 +226,6 @@ class HistoryRow extends Component {
null null
} }
{
data.label ?
<HistoryRowParameter
title='Label'
value={data.label}
/> :
null
}
{
data.track ?
<HistoryRowParameter
title='Track'
value={data.track}
/> :
null
}
{
data.year ?
<HistoryRowParameter
title='Year'
value={data.year}
/> :
null
}
{
data.genre ?
<HistoryRowParameter
title='Genre'
value={data.genre}
/> :
null
}
{ {
data.author ? data.author ?
<HistoryRowParameter <HistoryRowParameter
@@ -279,15 +243,6 @@ class HistoryRow extends Component {
/> : /> :
null null
} }
{
data.publisher ?
<HistoryRowParameter
title='Publisher'
value={data.publisher}
/> :
null
}
</TableRowCell> </TableRowCell>
); );
} }

View File

@@ -123,7 +123,7 @@ class AddIndexerModalContent extends Component {
const filteredIndexers = indexers.filter((indexer) => { const filteredIndexers = indexers.filter((indexer) => {
const { filter, filterProtocols, filterLanguages, filterPrivacyLevels } = this.state; const { filter, filterProtocols, filterLanguages, filterPrivacyLevels } = this.state;
if (!indexer.name.toLowerCase().includes(filter.toLocaleLowerCase()) && !indexer.description.toLowerCase().includes(filter.toLocaleLowerCase())) { if (!indexer.name.toLowerCase().includes(filter.toLocaleLowerCase())) {
return false; return false;
} }

View File

@@ -221,7 +221,7 @@ class IndexerIndex extends Component {
onKeyUp = (event) => { onKeyUp = (event) => {
const jumpBarItems = this.state.jumpBarItems.order; const jumpBarItems = this.state.jumpBarItems.order;
if (event.composedPath && event.composedPath().length === 4) { if (event.path.length === 4) {
if (event.keyCode === keyCodes.HOME && event.ctrlKey) { if (event.keyCode === keyCodes.HOME && event.ctrlKey) {
this.setState({ jumpToCharacter: jumpBarItems[0] }); this.setState({ jumpToCharacter: jumpBarItems[0] });
} }
@@ -272,7 +272,6 @@ class IndexerIndex extends Component {
saveError, saveError,
isDeleting, isDeleting,
isTestingAll, isTestingAll,
isSyncingIndexers,
deleteError, deleteError,
onScroll, onScroll,
onSortSelect, onSortSelect,
@@ -310,15 +309,6 @@ class IndexerIndex extends Component {
onPress={this.onAddIndexerPress} onPress={this.onAddIndexerPress}
/> />
<PageToolbarSeparator />
<PageToolbarButton
label={translate('SyncAppIndexers')}
iconName={icons.REFRESH}
isSpinning={isSyncingIndexers}
onPress={this.props.onAppIndexerSyncPress}
/>
<PageToolbarButton <PageToolbarButton
label={translate('TestAllIndexers')} label={translate('TestAllIndexers')}
iconName={icons.TEST} iconName={icons.TEST}
@@ -503,12 +493,10 @@ IndexerIndex.propTypes = {
saveError: PropTypes.object, saveError: PropTypes.object,
isDeleting: PropTypes.bool.isRequired, isDeleting: PropTypes.bool.isRequired,
isTestingAll: PropTypes.bool.isRequired, isTestingAll: PropTypes.bool.isRequired,
isSyncingIndexers: PropTypes.bool.isRequired,
deleteError: PropTypes.object, deleteError: PropTypes.object,
onSortSelect: PropTypes.func.isRequired, onSortSelect: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired, onFilterSelect: PropTypes.func.isRequired,
onTestAllPress: PropTypes.func.isRequired, onTestAllPress: PropTypes.func.isRequired,
onAppIndexerSyncPress: PropTypes.func.isRequired,
onScroll: PropTypes.func.isRequired, onScroll: PropTypes.func.isRequired,
onSaveSelected: PropTypes.func.isRequired onSaveSelected: PropTypes.func.isRequired
}; };

View File

@@ -2,13 +2,10 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import withScrollPosition from 'Components/withScrollPosition'; import withScrollPosition from 'Components/withScrollPosition';
import { executeCommand } from 'Store/Actions/commandActions';
import { testAllIndexers } from 'Store/Actions/indexerActions'; import { testAllIndexers } from 'Store/Actions/indexerActions';
import { saveIndexerEditor, setMovieFilter, setMovieSort, setMovieTableOption } from 'Store/Actions/indexerIndexActions'; import { saveIndexerEditor, setMovieFilter, setMovieSort, setMovieTableOption } from 'Store/Actions/indexerIndexActions';
import scrollPositions from 'Store/scrollPositions'; import scrollPositions from 'Store/scrollPositions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createIndexerClientSideCollectionItemsSelector from 'Store/Selectors/createIndexerClientSideCollectionItemsSelector'; import createIndexerClientSideCollectionItemsSelector from 'Store/Selectors/createIndexerClientSideCollectionItemsSelector';
import IndexerIndex from './IndexerIndex'; import IndexerIndex from './IndexerIndex';
@@ -16,16 +13,13 @@ import IndexerIndex from './IndexerIndex';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
createIndexerClientSideCollectionItemsSelector('indexerIndex'), createIndexerClientSideCollectionItemsSelector('indexerIndex'),
createCommandExecutingSelector(commandNames.APP_INDEXER_SYNC),
createDimensionsSelector(), createDimensionsSelector(),
( (
indexers, indexers,
isSyncingIndexers,
dimensionsState dimensionsState
) => { ) => {
return { return {
...indexers, ...indexers,
isSyncingIndexers,
isSmallScreen: dimensionsState.isSmallScreen isSmallScreen: dimensionsState.isSmallScreen
}; };
} }
@@ -52,12 +46,6 @@ function createMapDispatchToProps(dispatch, props) {
onTestAllPress() { onTestAllPress() {
dispatch(testAllIndexers()); dispatch(testAllIndexers());
},
onAppIndexerSyncPress() {
dispatch(executeCommand({
name: commandNames.APP_INDEXER_SYNC
}));
} }
}; };
} }

View File

@@ -35,21 +35,21 @@ class IndexerIndexFooter extends PureComponent {
<div className={styles.legendItem}> <div className={styles.legendItem}>
<div className={styles.enabled} /> <div className={styles.enabled} />
<div> <div>
{translate('Enabled')} Enabled
</div> </div>
</div> </div>
<div className={styles.legendItem}> <div className={styles.legendItem}>
<div className={styles.redirected} /> <div className={styles.redirected} />
<div> <div>
{translate('EnabledRedirected')} Enabled, Redirected
</div> </div>
</div> </div>
<div className={styles.legendItem}> <div className={styles.legendItem}>
<div className={styles.disabled} /> <div className={styles.disabled} />
<div> <div>
{translate('Disabled')} Disabled
</div> </div>
</div> </div>
@@ -60,7 +60,7 @@ class IndexerIndexFooter extends PureComponent {
)} )}
/> />
<div> <div>
{translate('Error')} Error
</div> </div>
</div> </div>
</div> </div>

View File

@@ -14,7 +14,7 @@ function CapabilitiesLabel(props) {
let filteredList = categories.filter((item) => item.id < 100000); let filteredList = categories.filter((item) => item.id < 100000);
if (categoryFilter.length > 0) { if (categoryFilter.length > 0) {
filteredList = filteredList.filter((item) => categoryFilter.includes(item.id) || (item.subCategories && item.subCategories.some((r) => categoryFilter.includes(r.id)))); filteredList = filteredList.filter((item) => categoryFilter.includes(item.id));
} }
const nameList = filteredList.map((item) => item.name).sort(); const nameList = filteredList.map((item) => item.name).sort();

View File

@@ -1,49 +0,0 @@
$hoverScale: 1.05;
.content {
display: flex;
flex-grow: 0;
margin-left: 5px;
}
.container {
border-radius: 4px;
background-color: var(--cardBackgroundColor);
}
.info {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
}
.titleRow {
display: flex;
justify-content: space-between;
flex: 0 0 auto;
margin-bottom: 10px;
height: 38px;
}
.indexerRow {
color: var(--disabledColor);
}
.infoRow {
margin-bottom: 5px;
}
.title {
overflow: hidden;
width: 85%;
font-weight: 500;
font-size: 14px;
overflow-wrap: break-word;
}
.actions {
position: absolute;
right: 0;
white-space: nowrap;
}

View File

@@ -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 CategoryLabel from 'Search/Table/CategoryLabel';
import Peers from 'Search/Table/Peers';
import ProtocolLabel from 'Search/Table/ProtocolLabel';
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 (
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.info} style={{ height: contentHeight }}>
<div className={styles.titleRow}>
<div className={styles.title}>
<Link
to={infoUrl}
title={title}
>
<TextTruncate
line={2}
text={title}
/>
</Link>
</div>
<div className={styles.actions}>
<SpinnerIconButton
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
kind={grabError ? kinds.DANGER : kinds.DEFAULT}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isDisabled={isGrabbed}
isSpinning={isGrabbing}
onPress={this.onGrabPress}
/>
<IconButton
className={styles.downloadLink}
name={icons.SAVE}
title={translate('Save')}
to={downloadUrl}
/>
</div>
</div>
<div className={styles.indexerRow}>
{indexer}
</div>
<div className={styles.infoRow}>
<ProtocolLabel
protocol={protocol}
/>
{
protocol === 'torrent' &&
<Peers
seeders={seeders}
leechers={leechers}
/>
}
<Label>
{formatBytes(size)}
</Label>
<Label>
{formatAge(age, ageHours, ageMinutes)}
</Label>
<CategoryLabel
categories={categories}
/>
</div>
</div>
</div>
</div>
);
}
}
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;

View File

@@ -1,11 +0,0 @@
.grid {
flex: 1 0 auto;
}
.container {
&:hover {
.content {
background-color: var(--tableRowHoverBackgroundColor);
}
}
}

View File

@@ -1,209 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Grid, WindowScroller } from 'react-virtualized';
import Measure from 'Components/Measure';
import SearchIndexItemConnector from 'Search/Table/SearchIndexItemConnector';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import SearchIndexOverview from './SearchIndexOverview';
import styles from './SearchIndexOverviews.css';
class SearchIndexOverviews extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
width: 0,
columnCount: 1,
rowHeight: 100,
scrollRestored: false
};
this._grid = null;
}
componentDidUpdate(prevProps, prevState) {
const {
items,
sortKey,
jumpToCharacter,
scrollTop,
isSmallScreen
} = this.props;
const {
width,
rowHeight,
scrollRestored
} = this.state;
if (prevProps.sortKey !== sortKey) {
this.calculateGrid(this.state.width, isSmallScreen);
}
if (
this._grid &&
(prevState.width !== width ||
prevState.rowHeight !== rowHeight ||
hasDifferentItemsOrOrder(prevProps.items, items, 'guid')
)
) {
// recomputeGridSize also forces Grid to discard its cache of rendered cells
this._grid.recomputeGridSize();
}
if (this._grid && scrollTop !== 0 && !scrollRestored) {
this.setState({ scrollRestored: true });
this._grid.scrollToPosition({ scrollTop });
}
if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
const index = getIndexOfFirstCharacter(items, jumpToCharacter);
if (this._grid && index != null) {
this._grid.scrollToCell({
rowIndex: index,
columnIndex: 0
});
}
}
}
//
// Control
setGridRef = (ref) => {
this._grid = ref;
};
calculateGrid = (width = this.state.width, isSmallScreen) => {
const rowHeight = 100;
this.setState({
width,
rowHeight
});
};
cellRenderer = ({ key, rowIndex, style }) => {
const {
items,
showRelativeDates,
shortDateFormat,
longDateFormat,
timeFormat,
isSmallScreen,
onGrabPress
} = this.props;
const {
rowHeight
} = this.state;
const release = items[rowIndex];
return (
<div
className={styles.container}
key={key}
style={style}
>
<SearchIndexItemConnector
key={release.guid}
component={SearchIndexOverview}
rowHeight={rowHeight}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
isSmallScreen={isSmallScreen}
style={style}
guid={release.guid}
onGrabPress={onGrabPress}
/>
</div>
);
};
//
// Listeners
onMeasure = ({ width }) => {
this.calculateGrid(width, this.props.isSmallScreen);
};
//
// Render
render() {
const {
items
} = this.props;
const {
width,
rowHeight
} = this.state;
return (
<Measure
whitelist={['width']}
onMeasure={this.onMeasure}
>
<WindowScroller
scrollElement={undefined}
>
{({ height, registerChild, onChildScroll, scrollTop }) => {
if (!height) {
return <div />;
}
return (
<div ref={registerChild}>
<Grid
ref={this.setGridRef}
className={styles.grid}
autoHeight={true}
height={height}
columnCount={1}
columnWidth={width}
rowCount={items.length}
rowHeight={rowHeight}
width={width}
onScroll={onChildScroll}
scrollTop={scrollTop}
overscanRowCount={2}
cellRenderer={this.cellRenderer}
scrollToAlignment={'start'}
isScrollingOptOut={true}
/>
</div>
);
}
}
</WindowScroller>
</Measure>
);
}
}
SearchIndexOverviews.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
scrollTop: PropTypes.number.isRequired,
jumpToCharacter: PropTypes.string,
scroller: PropTypes.instanceOf(Element).isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
timeFormat: PropTypes.string.isRequired,
onGrabPress: PropTypes.func.isRequired
};
export default SearchIndexOverviews;

View File

@@ -1,32 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { grabRelease } from 'Store/Actions/releaseActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import SearchIndexOverviews from './SearchIndexOverviews';
function createMapStateToProps() {
return createSelector(
createUISettingsSelector(),
createDimensionsSelector(),
(uiSettings, dimensions) => {
return {
showRelativeDates: uiSettings.showRelativeDates,
shortDateFormat: uiSettings.shortDateFormat,
longDateFormat: uiSettings.longDateFormat,
timeFormat: uiSettings.timeFormat,
isSmallScreen: dimensions.isSmallScreen
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onGrabPress(payload) {
dispatch(grabRelease(payload));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(SearchIndexOverviews);

View File

@@ -24,7 +24,6 @@ const searchOptions = [
const seriesTokens = [ const seriesTokens = [
{ token: '{ImdbId:tt1234567}', example: 'tt12345' }, { token: '{ImdbId:tt1234567}', example: 'tt12345' },
{ token: '{TvdbId:12345}', example: '12345' }, { token: '{TvdbId:12345}', example: '12345' },
{ token: '{TmdbId:12345}', example: '12345' },
{ token: '{TvMazeId:12345}', example: '54321' }, { token: '{TvMazeId:12345}', example: '54321' },
{ token: '{Season:00}', example: '01' }, { token: '{Season:00}', example: '01' },
{ token: '{Episode:00}', example: '01' } { token: '{Episode:00}', example: '01' }

View File

@@ -11,6 +11,8 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import { align, icons, sortDirections } from 'Helpers/Props'; import { align, icons, sortDirections } from 'Helpers/Props';
import AddIndexerModal from 'Indexer/Add/AddIndexerModal';
import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector';
import NoIndexer from 'Indexer/NoIndexer'; import NoIndexer from 'Indexer/NoIndexer';
import * as keyCodes from 'Utilities/Constants/keyCodes'; import * as keyCodes from 'Utilities/Constants/keyCodes';
import getErrorMessage from 'Utilities/Object/getErrorMessage'; import getErrorMessage from 'Utilities/Object/getErrorMessage';
@@ -21,17 +23,12 @@ import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected'; import toggleSelected from 'Utilities/Table/toggleSelected';
import SearchIndexFilterMenu from './Menus/SearchIndexFilterMenu'; import SearchIndexFilterMenu from './Menus/SearchIndexFilterMenu';
import SearchIndexSortMenu from './Menus/SearchIndexSortMenu'; import SearchIndexSortMenu from './Menus/SearchIndexSortMenu';
import SearchIndexOverviewsConnector from './Mobile/SearchIndexOverviewsConnector';
import NoSearchResults from './NoSearchResults'; import NoSearchResults from './NoSearchResults';
import SearchFooterConnector from './SearchFooterConnector'; import SearchFooterConnector from './SearchFooterConnector';
import SearchIndexTableConnector from './Table/SearchIndexTableConnector'; import SearchIndexTableConnector from './Table/SearchIndexTableConnector';
import styles from './SearchIndex.css'; import styles from './SearchIndex.css';
function getViewComponent(isSmallScreen) { function getViewComponent() {
if (isSmallScreen) {
return SearchIndexOverviewsConnector;
}
return SearchIndexTableConnector; return SearchIndexTableConnector;
} }
@@ -47,6 +44,8 @@ class SearchIndex extends Component {
scroller: null, scroller: null,
jumpBarItems: { order: [] }, jumpBarItems: { order: [] },
jumpToCharacter: null, jumpToCharacter: null,
isAddIndexerModalOpen: false,
isEditIndexerModalOpen: false,
searchType: null, searchType: null,
lastToggled: null, lastToggled: null,
allSelected: false, allSelected: false,
@@ -178,6 +177,21 @@ class SearchIndex extends Component {
// //
// Listeners // Listeners
onAddIndexerPress = () => {
this.setState({ isAddIndexerModalOpen: true });
};
onAddIndexerModalClose = ({ indexerSelected = false } = {}) => {
this.setState({
isAddIndexerModalOpen: false,
isEditIndexerModalOpen: indexerSelected
});
};
onEditIndexerModalClose = () => {
this.setState({ isEditIndexerModalOpen: false });
};
onJumpBarItemPress = (jumpToCharacter) => { onJumpBarItemPress = (jumpToCharacter) => {
this.setState({ jumpToCharacter }); this.setState({ jumpToCharacter });
}; };
@@ -239,7 +253,6 @@ class SearchIndex extends Component {
onScroll, onScroll,
onSortSelect, onSortSelect,
onFilterSelect, onFilterSelect,
isSmallScreen,
hasIndexers, hasIndexers,
...otherProps ...otherProps
} = this.props; } = this.props;
@@ -247,6 +260,8 @@ class SearchIndex extends Component {
const { const {
scroller, scroller,
jumpBarItems, jumpBarItems,
isAddIndexerModalOpen,
isEditIndexerModalOpen,
jumpToCharacter, jumpToCharacter,
selectedState, selectedState,
allSelected, allSelected,
@@ -255,7 +270,7 @@ class SearchIndex extends Component {
const selectedIndexerIds = this.getSelectedIds(); const selectedIndexerIds = this.getSelectedIds();
const ViewComponent = getViewComponent(isSmallScreen); const ViewComponent = getViewComponent();
const isLoaded = !!(!error && isPopulated && items.length && scroller); const isLoaded = !!(!error && isPopulated && items.length && scroller);
const hasNoIndexer = !totalItems; const hasNoIndexer = !totalItems;
@@ -369,6 +384,16 @@ class SearchIndex extends Component {
onSearchPress={this.onSearchPress} onSearchPress={this.onSearchPress}
onBulkGrabPress={this.onBulkGrabPress} onBulkGrabPress={this.onBulkGrabPress}
/> />
<AddIndexerModal
isOpen={isAddIndexerModalOpen}
onModalClose={this.onAddIndexerModalClose}
/>
<EditIndexerModalConnector
isOpen={isEditIndexerModalOpen}
onModalClose={this.onEditIndexerModalClose}
/>
</PageContent> </PageContent>
); );
} }

View File

@@ -14,7 +14,7 @@
.category { .category {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 130px; flex: 0 0 110px;
} }
.age, .age,

View File

@@ -18,20 +18,20 @@ function createMapStateToProps() {
return createSelector( return createSelector(
createReleaseSelector(), createReleaseSelector(),
( (
release movie
) => { ) => {
// If a release is deleted this selector may fire before the parent // If a movie is deleted this selector may fire before the parent
// selecors, which will result in an undefined release, if that happens // selecors, which will result in an undefined movie, if that happens
// we want to return early here and again in the render function to avoid // we want to return early here and again in the render function to avoid
// trying to show a release that has no information available. // trying to show a movie that has no information available.
if (!release) { if (!movie) {
return {}; return {};
} }
return { return {
...release ...movie
}; };
} }
); );
@@ -41,7 +41,7 @@ const mapDispatchToProps = {
dispatchExecuteCommand: executeCommand dispatchExecuteCommand: executeCommand
}; };
class SearchIndexItemConnector extends Component { class MovieIndexItemConnector extends Component {
// //
// Render // Render
@@ -66,9 +66,9 @@ class SearchIndexItemConnector extends Component {
} }
} }
SearchIndexItemConnector.propTypes = { MovieIndexItemConnector.propTypes = {
guid: PropTypes.string, guid: PropTypes.string,
component: PropTypes.elementType.isRequired component: PropTypes.elementType.isRequired
}; };
export default connect(createMapStateToProps, mapDispatchToProps)(SearchIndexItemConnector); export default connect(createMapStateToProps, mapDispatchToProps)(MovieIndexItemConnector);

View File

@@ -21,7 +21,7 @@
.category { .category {
composes: cell; composes: cell;
flex: 0 0 130px; flex: 0 0 110px;
} }
.age, .age,

View File

@@ -1,27 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import AddCategoryModalContentConnector from './AddCategoryModalContentConnector';
function AddCategoryModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>
<AddCategoryModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
AddCategoryModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddCategoryModal;

View File

@@ -1,50 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import AddCategoryModal from './AddCategoryModal';
function createMapDispatchToProps(dispatch, props) {
const section = 'settings.downloadClientCategories';
return {
dispatchClearPendingChanges() {
dispatch(clearPendingChanges({ section }));
}
};
}
class AddCategoryModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.dispatchClearPendingChanges();
this.props.onModalClose();
};
//
// Render
render() {
const {
dispatchClearPendingChanges,
...otherProps
} = this.props;
return (
<AddCategoryModal
{...otherProps}
onModalClose={this.onModalClose}
/>
);
}
}
AddCategoryModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
dispatchClearPendingChanges: PropTypes.func.isRequired
};
export default connect(null, createMapDispatchToProps)(AddCategoryModalConnector);

View File

@@ -1,5 +0,0 @@
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: auto;
}

View File

@@ -1,111 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
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 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 { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './AddCategoryModalContent.css';
function AddCategoryModalContent(props) {
const {
advancedSettings,
item,
onInputChange,
onFieldChange,
onCancelPress,
onSavePress,
onDeleteSpecificationPress,
...otherProps
} = props;
const {
id,
clientCategory,
categories
} = item;
return (
<ModalContent onModalClose={onCancelPress}>
<ModalHeader>
{`${id ? 'Edit' : 'Add'} Category`}
</ModalHeader>
<ModalBody>
<Form
{...otherProps}
>
<FormGroup>
<FormLabel>
{translate('DownloadClientCategory')}
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="clientCategory"
{...clientCategory}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('MappedCategories')}
</FormLabel>
<FormInputGroup
type={inputTypes.CATEGORY_SELECT}
name="categories"
{...categories}
onChange={onInputChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
{
id &&
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteSpecificationPress}
>
{translate('Delete')}
</Button>
}
<Button
onPress={onCancelPress}
>
{translate('Cancel')}
</Button>
<SpinnerErrorButton
isSpinning={false}
onPress={onSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
AddCategoryModalContent.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
item: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired,
onFieldChange: PropTypes.func.isRequired,
onCancelPress: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onDeleteSpecificationPress: PropTypes.func
};
export default AddCategoryModalContent;

View File

@@ -1,78 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearDownloadClientCategoryPending, saveDownloadClientCategory, setDownloadClientCategoryFieldValue, setDownloadClientCategoryValue } from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import AddCategoryModalContent from './AddCategoryModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
createProviderSettingsSelector('downloadClientCategories'),
(advancedSettings, specification) => {
return {
advancedSettings,
...specification
};
}
);
}
const mapDispatchToProps = {
setDownloadClientCategoryValue,
setDownloadClientCategoryFieldValue,
saveDownloadClientCategory,
clearDownloadClientCategoryPending
};
class AddCategoryModalContentConnector extends Component {
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setDownloadClientCategoryValue({ name, value });
};
onFieldChange = ({ name, value }) => {
this.props.setDownloadClientCategoryFieldValue({ name, value });
};
onCancelPress = () => {
this.props.clearDownloadClientCategoryPending();
this.props.onModalClose();
};
onSavePress = () => {
this.props.saveDownloadClientCategory({ id: this.props.id });
this.props.onModalClose();
};
//
// Render
render() {
return (
<AddCategoryModalContent
{...this.props}
onCancelPress={this.onCancelPress}
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
);
}
}
AddCategoryModalContentConnector.propTypes = {
id: PropTypes.number,
item: PropTypes.object.isRequired,
setDownloadClientCategoryValue: PropTypes.func.isRequired,
setDownloadClientCategoryFieldValue: PropTypes.func.isRequired,
clearDownloadClientCategoryPending: PropTypes.func.isRequired,
saveDownloadClientCategory: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddCategoryModalContentConnector);

View File

@@ -1,32 +0,0 @@
.customFormat {
composes: card from '~Components/Card.css';
width: 300px;
}
.nameContainer {
display: flex;
justify-content: space-between;
}
.name {
@add-mixin truncate;
margin-bottom: 5px;
font-weight: 300;
font-size: 20px;
}
.labels {
display: flex;
flex-wrap: wrap;
margin-top: 5px;
pointer-events: all;
}
.tooltipLabel {
composes: label from '~Components/Label.css';
margin: 0;
border: none;
}

View File

@@ -1,111 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Card from 'Components/Card';
import Label from 'Components/Label';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import AddCategoryModalConnector from './AddCategoryModalConnector';
import styles from './Category.css';
class Category extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditSpecificationModalOpen: false,
isDeleteSpecificationModalOpen: false
};
}
//
// Listeners
onEditSpecificationPress = () => {
this.setState({ isEditSpecificationModalOpen: true });
};
onEditSpecificationModalClose = () => {
this.setState({ isEditSpecificationModalOpen: false });
};
onDeleteSpecificationPress = () => {
this.setState({
isEditSpecificationModalOpen: false,
isDeleteSpecificationModalOpen: true
});
};
onDeleteSpecificationModalClose = () => {
this.setState({ isDeleteSpecificationModalOpen: false });
};
onConfirmDeleteSpecification = () => {
this.props.onConfirmDeleteSpecification(this.props.id);
};
//
// Lifecycle
render() {
const {
id,
clientCategory,
categories
} = this.props;
return (
<Card
className={styles.customFormat}
overlayContent={true}
onPress={this.onEditSpecificationPress}
>
<div className={styles.nameContainer}>
<div className={styles.name}>
{clientCategory}
</div>
</div>
<Label kind={kinds.PRIMARY}>
{`${categories.length} ${categories.length > 1 ? translate('Categories') : translate('Category')}`}
</Label>
<AddCategoryModalConnector
id={id}
isOpen={this.state.isEditSpecificationModalOpen}
onModalClose={this.onEditSpecificationModalClose}
onDeleteSpecificationPress={this.onDeleteSpecificationPress}
/>
<ConfirmModal
isOpen={this.state.isDeleteSpecificationModalOpen}
kind={kinds.DANGER}
title={translate('DeleteClientCategory')}
message={
<div>
<div>
{translate('AreYouSureYouWantToDeleteCategory', [name])}
</div>
</div>
}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteSpecification}
onCancel={this.onDeleteSpecificationModalClose}
/>
</Card>
);
}
}
Category.propTypes = {
id: PropTypes.number.isRequired,
categories: PropTypes.arrayOf(PropTypes.number).isRequired,
clientCategory: PropTypes.string.isRequired,
onConfirmDeleteSpecification: PropTypes.func.isRequired
};
export default Category;

View File

@@ -1,14 +1,11 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form'; import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup'; import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup'; import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel'; import FormLabel from 'Components/Form/FormLabel';
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -16,33 +13,12 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent'; import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import { icons, inputTypes, kinds } from 'Helpers/Props'; import { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import AddCategoryModalConnector from './Categories/AddCategoryModalConnector';
import Category from './Categories/Category';
import styles from './EditDownloadClientModalContent.css'; import styles from './EditDownloadClientModalContent.css';
class EditDownloadClientModalContent extends Component { class EditDownloadClientModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddCategoryModalOpen: false
};
}
onAddCategoryPress = () => {
this.setState({ isAddCategoryModalOpen: true });
};
onAddCategoryModalClose = () => {
this.setState({ isAddCategoryModalOpen: false });
};
// //
// Render // Render
@@ -51,7 +27,6 @@ class EditDownloadClientModalContent extends Component {
advancedSettings, advancedSettings,
isFetching, isFetching,
error, error,
categories,
isSaving, isSaving,
isTesting, isTesting,
saveError, saveError,
@@ -62,21 +37,15 @@ class EditDownloadClientModalContent extends Component {
onSavePress, onSavePress,
onTestPress, onTestPress,
onDeleteDownloadClientPress, onDeleteDownloadClientPress,
onConfirmDeleteCategory,
...otherProps ...otherProps
} = this.props; } = this.props;
const {
isAddCategoryModalOpen
} = this.state;
const { const {
id, id,
implementationName, implementationName,
name, name,
enable, enable,
priority, priority,
supportsCategories,
fields, fields,
message message
} = item; } = item;
@@ -167,43 +136,6 @@ class EditDownloadClientModalContent extends Component {
/> />
</FormGroup> </FormGroup>
{
supportsCategories.value ?
<FieldSet legend={translate('MappedCategories')}>
<div className={styles.customFormats}>
{
categories.map((tag) => {
return (
<Category
key={tag.id}
{...tag}
onConfirmDeleteSpecification={onConfirmDeleteCategory}
/>
);
})
}
<Card
className={styles.addCategory}
onPress={this.onAddCategoryPress}
>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={25}
/>
</div>
</Card>
</div>
</FieldSet> :
null
}
<AddCategoryModalConnector
isOpen={isAddCategoryModalOpen}
onModalClose={this.onAddCategoryModalClose}
/>
</Form> </Form>
} }
</ModalBody> </ModalBody>
@@ -253,15 +185,13 @@ EditDownloadClientModalContent.propTypes = {
isSaving: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object, saveError: PropTypes.object,
isTesting: PropTypes.bool.isRequired, isTesting: PropTypes.bool.isRequired,
categories: PropTypes.arrayOf(PropTypes.object),
item: PropTypes.object.isRequired, item: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired, onInputChange: PropTypes.func.isRequired,
onFieldChange: PropTypes.func.isRequired, onFieldChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired, onSavePress: PropTypes.func.isRequired,
onTestPress: PropTypes.func.isRequired, onTestPress: PropTypes.func.isRequired,
onDeleteDownloadClientPress: PropTypes.func, onDeleteDownloadClientPress: PropTypes.func
onConfirmDeleteCategory: PropTypes.func.isRequired
}; };
export default EditDownloadClientModalContent; export default EditDownloadClientModalContent;

View File

@@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { deleteDownloadClientCategory, fetchDownloadClientCategories, saveDownloadClient, setDownloadClientFieldValue, setDownloadClientValue, testDownloadClient } from 'Store/Actions/settingsActions'; import { saveDownloadClient, setDownloadClientFieldValue, setDownloadClientValue, testDownloadClient } from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import EditDownloadClientModalContent from './EditDownloadClientModalContent'; import EditDownloadClientModalContent from './EditDownloadClientModalContent';
@@ -10,12 +10,10 @@ function createMapStateToProps() {
return createSelector( return createSelector(
(state) => state.settings.advancedSettings, (state) => state.settings.advancedSettings,
createProviderSettingsSelector('downloadClients'), createProviderSettingsSelector('downloadClients'),
(state) => state.settings.downloadClientCategories, (advancedSettings, downloadClient) => {
(advancedSettings, downloadClient, categories) => {
return { return {
advancedSettings, advancedSettings,
...downloadClient, ...downloadClient
categories: categories.items
}; };
} }
); );
@@ -25,9 +23,7 @@ const mapDispatchToProps = {
setDownloadClientValue, setDownloadClientValue,
setDownloadClientFieldValue, setDownloadClientFieldValue,
saveDownloadClient, saveDownloadClient,
testDownloadClient, testDownloadClient
fetchDownloadClientCategories,
deleteDownloadClientCategory
}; };
class EditDownloadClientModalContentConnector extends Component { class EditDownloadClientModalContentConnector extends Component {
@@ -35,14 +31,6 @@ class EditDownloadClientModalContentConnector extends Component {
// //
// Lifecycle // Lifecycle
componentDidMount() {
const {
id,
tagsFromId
} = this.props;
this.props.fetchDownloadClientCategories({ id: tagsFromId || id });
}
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose(); this.props.onModalClose();
@@ -68,10 +56,6 @@ class EditDownloadClientModalContentConnector extends Component {
this.props.testDownloadClient({ id: this.props.id }); this.props.testDownloadClient({ id: this.props.id });
}; };
onConfirmDeleteCategory = (id) => {
this.props.deleteDownloadClientCategory({ id });
};
// //
// Render // Render
@@ -83,7 +67,6 @@ class EditDownloadClientModalContentConnector extends Component {
onTestPress={this.onTestPress} onTestPress={this.onTestPress}
onInputChange={this.onInputChange} onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange} onFieldChange={this.onFieldChange}
onConfirmDeleteCategory={this.onConfirmDeleteCategory}
/> />
); );
} }
@@ -91,13 +74,10 @@ class EditDownloadClientModalContentConnector extends Component {
EditDownloadClientModalContentConnector.propTypes = { EditDownloadClientModalContentConnector.propTypes = {
id: PropTypes.number, id: PropTypes.number,
tagsFromId: PropTypes.number,
isFetching: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object, saveError: PropTypes.object,
item: PropTypes.object.isRequired, item: PropTypes.object.isRequired,
fetchDownloadClientCategories: PropTypes.func.isRequired,
deleteDownloadClientCategory: PropTypes.func.isRequired,
setDownloadClientValue: PropTypes.func.isRequired, setDownloadClientValue: PropTypes.func.isRequired,
setDownloadClientFieldValue: PropTypes.func.isRequired, setDownloadClientFieldValue: PropTypes.func.isRequired,
saveDownloadClient: PropTypes.func.isRequired, saveDownloadClient: PropTypes.func.isRequired,

View File

@@ -21,6 +21,7 @@ const requiresRestartKeys = [
'bindAddress', 'bindAddress',
'port', 'port',
'urlBase', 'urlBase',
'instanceName',
'enableSsl', 'enableSsl',
'sslPort', 'sslPort',
'sslCertPath', 'sslCertPath',

View File

@@ -11,20 +11,12 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
import { icons, inputTypes, kinds } from 'Helpers/Props'; import { icons, inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
export const authenticationRequiredWarning = translate('AuthenticationRequiredWarning'); const authenticationMethodOptions = [
{ key: 'none', value: 'None' },
export const authenticationMethodOptions = [
{ key: 'none', value: 'None', isDisabled: true },
{ key: 'external', value: 'External', isHidden: true },
{ key: 'basic', value: 'Basic (Browser Popup)' }, { key: 'basic', value: 'Basic (Browser Popup)' },
{ key: 'forms', value: 'Forms (Login Page)' } { key: 'forms', value: 'Forms (Login Page)' }
]; ];
export const authenticationRequiredOptions = [
{ key: 'enabled', value: 'Enabled' },
{ key: 'disabledForLocalAddresses', value: 'Disabled for Local Addresses' }
];
const certificateValidationOptions = [ const certificateValidationOptions = [
{ key: 'enabled', value: 'Enabled' }, { key: 'enabled', value: 'Enabled' },
{ key: 'disabledForLocalAddresses', value: 'Disabled for Local Addresses' }, { key: 'disabledForLocalAddresses', value: 'Disabled for Local Addresses' },
@@ -76,7 +68,6 @@ class SecuritySettings extends Component {
const { const {
authenticationMethod, authenticationMethod,
authenticationRequired,
username, username,
password, password,
apiKey, apiKey,
@@ -95,31 +86,13 @@ class SecuritySettings extends Component {
name="authenticationMethod" name="authenticationMethod"
values={authenticationMethodOptions} values={authenticationMethodOptions}
helpText={translate('AuthenticationMethodHelpText')} helpText={translate('AuthenticationMethodHelpText')}
helpTextWarning={authenticationRequiredWarning}
onChange={onInputChange} onChange={onInputChange}
{...authenticationMethod} {...authenticationMethod}
/> />
</FormGroup> </FormGroup>
{ {
authenticationEnabled ? authenticationEnabled &&
<FormGroup>
<FormLabel>{translate('AuthenticationRequired')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="authenticationRequired"
values={authenticationRequiredOptions}
helpText={translate('AuthenticationRequiredHelpText')}
onChange={onInputChange}
{...authenticationRequired}
/>
</FormGroup> :
null
}
{
authenticationEnabled ?
<FormGroup> <FormGroup>
<FormLabel>{translate('Username')}</FormLabel> <FormLabel>{translate('Username')}</FormLabel>
@@ -129,12 +102,11 @@ class SecuritySettings extends Component {
onChange={onInputChange} onChange={onInputChange}
{...username} {...username}
/> />
</FormGroup> : </FormGroup>
null
} }
{ {
authenticationEnabled ? authenticationEnabled &&
<FormGroup> <FormGroup>
<FormLabel>{translate('Password')}</FormLabel> <FormLabel>{translate('Password')}</FormLabel>
@@ -144,8 +116,7 @@ class SecuritySettings extends Component {
onChange={onInputChange} onChange={onInputChange}
{...password} {...password}
/> />
</FormGroup> : </FormGroup>
null
} }
<FormGroup> <FormGroup>

View File

@@ -62,6 +62,8 @@ class UISettings extends Component {
...otherProps ...otherProps
} = this.props; } = this.props;
const uiLanguages = languages.filter((item) => item.value !== 'Original');
const themeOptions = Object.keys(themes) const themeOptions = Object.keys(themes)
.map((theme) => ({ key: theme, value: titleCase(theme) })); .map((theme) => ({ key: theme, value: titleCase(theme) }));
@@ -170,7 +172,7 @@ class UISettings extends Component {
<FormInputGroup <FormInputGroup
type={inputTypes.SELECT} type={inputTypes.SELECT}
name="uiLanguage" name="uiLanguage"
values={languages} values={uiLanguages}
helpText={translate('UILanguageHelpText')} helpText={translate('UILanguageHelpText')}
helpTextWarning={translate('UILanguageHelpTextWarning')} helpTextWarning={translate('UILanguageHelpTextWarning')}
onChange={onInputChange} onChange={onInputChange}

View File

@@ -3,7 +3,6 @@ import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { clearPendingChanges } from 'Store/Actions/baseActions'; import { clearPendingChanges } from 'Store/Actions/baseActions';
import { fetchLocalizationOptions } from 'Store/Actions/localizationActions';
import { fetchUISettings, saveUISettings, setUISettingsValue } from 'Store/Actions/settingsActions'; import { fetchUISettings, saveUISettings, setUISettingsValue } from 'Store/Actions/settingsActions';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import UISettings from './UISettings'; import UISettings from './UISettings';
@@ -12,17 +11,18 @@ const SECTION = 'ui';
function createLanguagesSelector() { function createLanguagesSelector() {
return createSelector( return createSelector(
(state) => state.localization, (state) => state.settings.languages,
(localization) => { (languages) => {
const items = localization.items; const items = languages.items;
const filterItems = ['Any', 'Unknown'];
if (!items) { if (!items) {
return []; return [];
} }
const newItems = items.filter((lang) => !items.includes(lang.name)).map((item) => { const newItems = items.filter((lang) => !filterItems.includes(lang.name)).map((item) => {
return { return {
key: item.value, key: item.id,
value: item.name value: item.name
}; };
}); });
@@ -51,7 +51,6 @@ const mapDispatchToProps = {
setUISettingsValue, setUISettingsValue,
saveUISettings, saveUISettings,
fetchUISettings, fetchUISettings,
fetchLocalizationOptions,
clearPendingChanges clearPendingChanges
}; };
@@ -62,7 +61,6 @@ class UISettingsConnector extends Component {
componentDidMount() { componentDidMount() {
this.props.fetchUISettings(); this.props.fetchUISettings();
this.props.fetchLocalizationOptions();
} }
componentWillUnmount() { componentWillUnmount() {
@@ -98,7 +96,6 @@ UISettingsConnector.propTypes = {
setUISettingsValue: PropTypes.func.isRequired, setUISettingsValue: PropTypes.func.isRequired,
saveUISettings: PropTypes.func.isRequired, saveUISettings: PropTypes.func.isRequired,
fetchUISettings: PropTypes.func.isRequired, fetchUISettings: PropTypes.func.isRequired,
fetchLocalizationOptions: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired clearPendingChanges: PropTypes.func.isRequired
}; };

View File

@@ -1,169 +0,0 @@
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import createClearReducer from 'Store/Actions/Creators/Reducers/createClearReducer';
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
import getNextId from 'Utilities/State/getNextId';
import getProviderState from 'Utilities/State/getProviderState';
import getSectionState from 'Utilities/State/getSectionState';
import selectProviderSchema from 'Utilities/State/selectProviderSchema';
import { removeItem, set, update, updateItem } from '../baseActions';
//
// Variables
const section = 'settings.downloadClientCategories';
//
// Actions Types
export const FETCH_DOWNLOAD_CLIENT_CATEGORIES = 'settings/downloadClientCategories/fetchDownloadClientCategories';
export const FETCH_DOWNLOAD_CLIENT_CATEGORY_SCHEMA = 'settings/downloadClientCategories/fetchDownloadClientCategorySchema';
export const SELECT_DOWNLOAD_CLIENT_CATEGORY_SCHEMA = 'settings/downloadClientCategories/selectDownloadClientCategorySchema';
export const SET_DOWNLOAD_CLIENT_CATEGORY_VALUE = 'settings/downloadClientCategories/setDownloadClientCategoryValue';
export const SET_DOWNLOAD_CLIENT_CATEGORY_FIELD_VALUE = 'settings/downloadClientCategories/setDownloadClientCategoryFieldValue';
export const SAVE_DOWNLOAD_CLIENT_CATEGORY = 'settings/downloadClientCategories/saveDownloadClientCategory';
export const DELETE_DOWNLOAD_CLIENT_CATEGORY = 'settings/downloadClientCategories/deleteDownloadClientCategory';
export const DELETE_ALL_DOWNLOAD_CLIENT_CATEGORY = 'settings/downloadClientCategories/deleteAllDownloadClientCategory';
export const CLEAR_DOWNLOAD_CLIENT_CATEGORIES = 'settings/downloadClientCategories/clearDownloadClientCategories';
export const CLEAR_DOWNLOAD_CLIENT_CATEGORY_PENDING = 'settings/downloadClientCategories/clearDownloadClientCategoryPending';
//
// Action Creators
export const fetchDownloadClientCategories = createThunk(FETCH_DOWNLOAD_CLIENT_CATEGORIES);
export const fetchDownloadClientCategorySchema = createThunk(FETCH_DOWNLOAD_CLIENT_CATEGORY_SCHEMA);
export const selectDownloadClientCategorySchema = createAction(SELECT_DOWNLOAD_CLIENT_CATEGORY_SCHEMA);
export const saveDownloadClientCategory = createThunk(SAVE_DOWNLOAD_CLIENT_CATEGORY);
export const deleteDownloadClientCategory = createThunk(DELETE_DOWNLOAD_CLIENT_CATEGORY);
export const deleteAllDownloadClientCategory = createThunk(DELETE_ALL_DOWNLOAD_CLIENT_CATEGORY);
export const setDownloadClientCategoryValue = createAction(SET_DOWNLOAD_CLIENT_CATEGORY_VALUE, (payload) => {
return {
section,
...payload
};
});
export const setDownloadClientCategoryFieldValue = createAction(SET_DOWNLOAD_CLIENT_CATEGORY_FIELD_VALUE, (payload) => {
return {
section,
...payload
};
});
export const clearDownloadClientCategory = createAction(CLEAR_DOWNLOAD_CLIENT_CATEGORIES);
export const clearDownloadClientCategoryPending = createThunk(CLEAR_DOWNLOAD_CLIENT_CATEGORY_PENDING);
//
// Details
export default {
//
// State
defaultState: {
isPopulated: false,
error: null,
isSchemaFetching: false,
isSchemaPopulated: false,
schemaError: null,
schema: [],
selectedSchema: {},
isSaving: false,
saveError: null,
items: [],
pendingChanges: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_DOWNLOAD_CLIENT_CATEGORIES]: (getState, payload, dispatch) => {
let tags = [];
if (payload.id) {
const cfState = getSectionState(getState(), 'settings.downloadClients', true);
const cf = cfState.items[cfState.itemMap[payload.id]];
tags = cf.categories.map((tag, i) => {
return {
id: i + 1,
...tag
};
});
}
dispatch(batchActions([
update({ section, data: tags }),
set({
section,
isPopulated: true
})
]));
},
[SAVE_DOWNLOAD_CLIENT_CATEGORY]: (getState, payload, dispatch) => {
const {
id,
...otherPayload
} = payload;
const saveData = getProviderState({ id, ...otherPayload }, getState, section, false);
// we have to set id since not actually posting to server yet
if (!saveData.id) {
saveData.id = getNextId(getState().settings.downloadClientCategories.items);
}
dispatch(batchActions([
updateItem({ section, ...saveData }),
set({
section,
pendingChanges: {}
})
]));
},
[DELETE_DOWNLOAD_CLIENT_CATEGORY]: (getState, payload, dispatch) => {
const id = payload.id;
return dispatch(removeItem({ section, id }));
},
[DELETE_ALL_DOWNLOAD_CLIENT_CATEGORY]: (getState, payload, dispatch) => {
return dispatch(set({
section,
items: []
}));
},
[CLEAR_DOWNLOAD_CLIENT_CATEGORY_PENDING]: (getState, payload, dispatch) => {
return dispatch(set({
section,
pendingChanges: {}
}));
}
},
//
// Reducers
reducers: {
[SET_DOWNLOAD_CLIENT_CATEGORY_VALUE]: createSetSettingValueReducer(section),
[SET_DOWNLOAD_CLIENT_CATEGORY_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
[SELECT_DOWNLOAD_CLIENT_CATEGORY_SCHEMA]: (state, { payload }) => {
return selectProviderSchema(state, section, payload, (selectedSchema) => {
return selectedSchema;
});
},
[CLEAR_DOWNLOAD_CLIENT_CATEGORIES]: createClearReducer(section, {
isPopulated: false,
error: null,
items: []
})
}
};

View File

@@ -9,7 +9,6 @@ import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks'; import { createThunk } from 'Store/thunks';
import selectProviderSchema from 'Utilities/State/selectProviderSchema'; import selectProviderSchema from 'Utilities/State/selectProviderSchema';
import { set } from '../baseActions';
// //
// Variables // Variables
@@ -91,34 +90,10 @@ export default {
[FETCH_DOWNLOAD_CLIENTS]: createFetchHandler(section, '/downloadclient'), [FETCH_DOWNLOAD_CLIENTS]: createFetchHandler(section, '/downloadclient'),
[FETCH_DOWNLOAD_CLIENT_SCHEMA]: createFetchSchemaHandler(section, '/downloadclient/schema'), [FETCH_DOWNLOAD_CLIENT_SCHEMA]: createFetchSchemaHandler(section, '/downloadclient/schema'),
[SAVE_DOWNLOAD_CLIENT]: (getState, payload, dispatch) => { [SAVE_DOWNLOAD_CLIENT]: createSaveProviderHandler(section, '/downloadclient'),
// move the format tags in as a pending change
const state = getState();
const pendingChanges = state.settings.downloadClients.pendingChanges;
pendingChanges.categories = state.settings.downloadClientCategories.items;
dispatch(set({
section,
pendingChanges
}));
createSaveProviderHandler(section, '/downloadclient')(getState, payload, dispatch);
},
[CANCEL_SAVE_DOWNLOAD_CLIENT]: createCancelSaveProviderHandler(section), [CANCEL_SAVE_DOWNLOAD_CLIENT]: createCancelSaveProviderHandler(section),
[DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'), [DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'),
[TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'),
[TEST_DOWNLOAD_CLIENT]: (getState, payload, dispatch) => {
const state = getState();
const pendingChanges = state.settings.downloadClients.pendingChanges;
pendingChanges.categories = state.settings.downloadClientCategories.items;
dispatch(set({
section,
pendingChanges
}));
createTestProviderHandler(section, '/downloadclient')(getState, payload, dispatch);
},
[CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section), [CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section),
[TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient') [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient')
}, },

View File

@@ -0,0 +1,48 @@
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import { createThunk } from 'Store/thunks';
//
// Variables
const section = 'settings.languages';
//
// Actions Types
export const FETCH_LANGUAGES = 'settings/languages/fetchLanguages';
//
// Action Creators
export const fetchLanguages = createThunk(FETCH_LANGUAGES);
//
// Details
export default {
//
// State
defaultState: {
isFetching: false,
isPopulated: false,
error: null,
items: []
},
//
// Action Handlers
actionHandlers: {
[FETCH_LANGUAGES]: createFetchHandler(section, '/language')
},
//
// Reducers
reducers: {
}
};

View File

@@ -36,31 +36,31 @@ export const defaultState = {
}, },
{ {
name: 'indexer', name: 'indexer',
label: translate('Indexer'), label: 'Indexer',
isSortable: false, isSortable: false,
isVisible: true isVisible: true
}, },
{ {
name: 'query', name: 'query',
label: translate('Query'), label: 'Query',
isSortable: false, isSortable: false,
isVisible: true isVisible: true
}, },
{ {
name: 'parameters', name: 'parameters',
label: translate('Parameters'), label: 'Parameters',
isSortable: false, isSortable: false,
isVisible: false isVisible: false
}, },
{ {
name: 'grabTitle', name: 'grabTitle',
label: translate('Grab Title'), label: 'Grab Title',
isSortable: false, isSortable: false,
isVisible: false isVisible: false
}, },
{ {
name: 'categories', name: 'categories',
label: translate('Categories'), label: 'Categories',
isSortable: false, isSortable: false,
isVisible: true isVisible: true
}, },
@@ -72,13 +72,13 @@ export const defaultState = {
}, },
{ {
name: 'source', name: 'source',
label: translate('Source'), label: 'Source',
isSortable: false, isSortable: false,
isVisible: false isVisible: false
}, },
{ {
name: 'elapsedTime', name: 'elapsedTime',
label: translate('Elapsed Time'), label: 'Elapsed Time',
isSortable: false, isSortable: false,
isVisible: true isVisible: true
}, },

View File

@@ -7,7 +7,6 @@ import * as indexers from './indexerActions';
import * as indexerIndex from './indexerIndexActions'; import * as indexerIndex from './indexerIndexActions';
import * as indexerStats from './indexerStatsActions'; import * as indexerStats from './indexerStatsActions';
import * as indexerStatus from './indexerStatusActions'; import * as indexerStatus from './indexerStatusActions';
import * as localization from './localizationActions';
import * as oAuth from './oAuthActions'; import * as oAuth from './oAuthActions';
import * as paths from './pathActions'; import * as paths from './pathActions';
import * as providerOptions from './providerOptionActions'; import * as providerOptions from './providerOptionActions';
@@ -26,7 +25,6 @@ export default [
paths, paths,
providerOptions, providerOptions,
releases, releases,
localization,
indexers, indexers,
indexerIndex, indexerIndex,
indexerStats, indexerStats,

View File

@@ -36,7 +36,7 @@ export const defaultState = {
columns: [ columns: [
{ {
name: 'select', name: 'select',
columnLabel: translate('Select'), columnLabel: 'Select',
isSortable: false, isSortable: false,
isVisible: true, isVisible: true,
isModifiable: false, isModifiable: false,
@@ -51,7 +51,7 @@ export const defaultState = {
}, },
{ {
name: 'sortName', name: 'sortName',
label: translate('IndexerName'), label: 'Indexer Name',
isSortable: true, isSortable: true,
isVisible: true, isVisible: true,
isModifiable: false isModifiable: false
@@ -88,7 +88,7 @@ export const defaultState = {
}, },
{ {
name: 'capabilities', name: 'capabilities',
label: translate('Categories'), label: 'Categories',
isSortable: false, isSortable: false,
isVisible: true isVisible: true
}, },

View File

@@ -1,39 +0,0 @@
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import { createThunk, handleThunks } from 'Store/thunks';
import createHandleActions from './Creators/createHandleActions';
//
// Variables
export const section = 'localization';
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
items: []
};
//
// Actions Types
export const FETCH_LOCALIZATION_OPTIONS = 'localization/fetchLocalizationOptions';
//
// Action Creators
export const fetchLocalizationOptions = createThunk(FETCH_LOCALIZATION_OPTIONS);
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_LOCALIZATION_OPTIONS]: createFetchHandler(section, '/localization/options')
});
//
// Reducers
export const reducers = createHandleActions({}, defaultState, section);

View File

@@ -310,6 +310,8 @@ export const actionHandlers = handleThunks({
isGrabbing: true isGrabbing: true
})); }));
console.log(payload);
const promise = createAjaxRequest({ const promise = createAjaxRequest({
url: '/search/bulk', url: '/search/bulk',
method: 'POST', method: 'POST',

View File

@@ -4,19 +4,19 @@ import createHandleActions from './Creators/createHandleActions';
import applications from './Settings/applications'; import applications from './Settings/applications';
import appProfiles from './Settings/appProfiles'; import appProfiles from './Settings/appProfiles';
import development from './Settings/development'; import development from './Settings/development';
import downloadClientCategories from './Settings/downloadClientCategories';
import downloadClients from './Settings/downloadClients'; import downloadClients from './Settings/downloadClients';
import general from './Settings/general'; import general from './Settings/general';
import indexerCategories from './Settings/indexerCategories'; import indexerCategories from './Settings/indexerCategories';
import indexerProxies from './Settings/indexerProxies'; import indexerProxies from './Settings/indexerProxies';
import languages from './Settings/languages';
import notifications from './Settings/notifications'; import notifications from './Settings/notifications';
import ui from './Settings/ui'; import ui from './Settings/ui';
export * from './Settings/downloadClientCategories';
export * from './Settings/downloadClients'; export * from './Settings/downloadClients';
export * from './Settings/general'; export * from './Settings/general';
export * from './Settings/indexerCategories'; export * from './Settings/indexerCategories';
export * from './Settings/indexerProxies'; export * from './Settings/indexerProxies';
export * from './Settings/languages';
export * from './Settings/notifications'; export * from './Settings/notifications';
export * from './Settings/applications'; export * from './Settings/applications';
export * from './Settings/appProfiles'; export * from './Settings/appProfiles';
@@ -34,11 +34,11 @@ export const section = 'settings';
export const defaultState = { export const defaultState = {
advancedSettings: false, advancedSettings: false,
downloadClientCategories: downloadClientCategories.defaultState,
downloadClients: downloadClients.defaultState, downloadClients: downloadClients.defaultState,
general: general.defaultState, general: general.defaultState,
indexerCategories: indexerCategories.defaultState, indexerCategories: indexerCategories.defaultState,
indexerProxies: indexerProxies.defaultState, indexerProxies: indexerProxies.defaultState,
languages: languages.defaultState,
notifications: notifications.defaultState, notifications: notifications.defaultState,
applications: applications.defaultState, applications: applications.defaultState,
appProfiles: appProfiles.defaultState, appProfiles: appProfiles.defaultState,
@@ -64,11 +64,11 @@ export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS);
// Action Handlers // Action Handlers
export const actionHandlers = handleThunks({ export const actionHandlers = handleThunks({
...downloadClientCategories.actionHandlers,
...downloadClients.actionHandlers, ...downloadClients.actionHandlers,
...general.actionHandlers, ...general.actionHandlers,
...indexerCategories.actionHandlers, ...indexerCategories.actionHandlers,
...indexerProxies.actionHandlers, ...indexerProxies.actionHandlers,
...languages.actionHandlers,
...notifications.actionHandlers, ...notifications.actionHandlers,
...applications.actionHandlers, ...applications.actionHandlers,
...appProfiles.actionHandlers, ...appProfiles.actionHandlers,
@@ -85,11 +85,11 @@ export const reducers = createHandleActions({
return Object.assign({}, state, { advancedSettings: !state.advancedSettings }); return Object.assign({}, state, { advancedSettings: !state.advancedSettings });
}, },
...downloadClientCategories.reducers,
...downloadClients.reducers, ...downloadClients.reducers,
...general.reducers, ...general.reducers,
...indexerCategories.reducers, ...indexerCategories.reducers,
...indexerProxies.reducers, ...indexerProxies.reducers,
...languages.reducers,
...notifications.reducers, ...notifications.reducers,
...applications.reducers, ...applications.reducers,
...appProfiles.reducers, ...appProfiles.reducers,

View File

@@ -168,11 +168,10 @@ module.exports = {
// //
// Popover // Popover
popoverTitleBackgroundColor: '#424242', popoverTitleBackgroundColor: '#f7f7f7',
popoverTitleBorderColor: '#2a2a2a', popoverTitleBorderColor: '#ebebeb',
popoverBodyBackgroundColor: '#2a2a2a',
popoverShadowColor: 'rgba(0, 0, 0, 0.2)', popoverShadowColor: 'rgba(0, 0, 0, 0.2)',
popoverArrowBorderColor: '#2a2a2a', popoverArrowBorderColor: '#fff',
popoverTitleBackgroundInverseColor: '#595959', popoverTitleBackgroundInverseColor: '#595959',
popoverTitleBorderInverseColor: '#707070', popoverTitleBorderInverseColor: '#707070',

View File

@@ -1,11 +1,7 @@
import * as dark from './dark'; import * as dark from './dark';
import * as light from './light'; import * as light from './light';
const defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const auto = defaultDark ? { ...dark } : { ...light };
export default { export default {
auto,
light, light,
dark dark
}; };

View File

@@ -170,7 +170,6 @@ module.exports = {
popoverTitleBackgroundColor: '#f7f7f7', popoverTitleBackgroundColor: '#f7f7f7',
popoverTitleBorderColor: '#ebebeb', popoverTitleBorderColor: '#ebebeb',
popoverBodyBackgroundColor: '#e9e9e9',
popoverShadowColor: 'rgba(0, 0, 0, 0.2)', popoverShadowColor: 'rgba(0, 0, 0, 0.2)',
popoverArrowBorderColor: '#fff', popoverArrowBorderColor: '#fff',

View File

@@ -13,7 +13,7 @@ class Donations extends Component {
return ( return (
<FieldSet legend={translate('Donations')}> <FieldSet legend={translate('Donations')}>
<div className={styles.logoContainer} title="Radarr"> <div className={styles.logoContainer} title="Radarr">
<Link to="https://radarr.video/donate"> <Link to="https://opencollective.com/radarr">
<img <img
className={styles.logo} className={styles.logo}
src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-radarr.png`} src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-radarr.png`}
@@ -21,7 +21,7 @@ class Donations extends Component {
</Link> </Link>
</div> </div>
<div className={styles.logoContainer} title="Lidarr"> <div className={styles.logoContainer} title="Lidarr">
<Link to="https://lidarr.audio/donate"> <Link to="https://opencollective.com/lidarr">
<img <img
className={styles.logo} className={styles.logo}
src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-lidarr.png`} src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-lidarr.png`}
@@ -29,7 +29,7 @@ class Donations extends Component {
</Link> </Link>
</div> </div>
<div className={styles.logoContainer} title="Readarr"> <div className={styles.logoContainer} title="Readarr">
<Link to="https://readarr.com/donate"> <Link to="https://opencollective.com/readarr">
<img <img
className={styles.logo} className={styles.logo}
src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-readarr.png`} src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-readarr.png`}
@@ -37,7 +37,7 @@ class Donations extends Component {
</Link> </Link>
</div> </div>
<div className={styles.logoContainer} title="Prowlarr"> <div className={styles.logoContainer} title="Prowlarr">
<Link to="https://prowlarr.com/donate"> <Link to="https://opencollective.com/prowlarr">
<img <img
className={styles.logo} className={styles.logo}
src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-prowlarr.png`} src={`${window.Prowlarr.urlBase}/Content/Images/Icons/logo-prowlarr.png`}

View File

@@ -15,27 +15,27 @@ const columns = [
}, },
{ {
name: 'commandName', name: 'commandName',
label: translate('Name'), label: 'Name',
isVisible: true isVisible: true
}, },
{ {
name: 'queued', name: 'queued',
label: translate('Queued'), label: 'Queued',
isVisible: true isVisible: true
}, },
{ {
name: 'started', name: 'started',
label: translate('Started'), label: 'Started',
isVisible: true isVisible: true
}, },
{ {
name: 'ended', name: 'ended',
label: translate('Ended'), label: 'Ended',
isVisible: true isVisible: true
}, },
{ {
name: 'duration', name: 'duration',
label: translate('Duration'), label: 'Duration',
isVisible: true isVisible: true
}, },
{ {

View File

@@ -10,27 +10,27 @@ import ScheduledTaskRowConnector from './ScheduledTaskRowConnector';
const columns = [ const columns = [
{ {
name: 'name', name: 'name',
label: translate('Name'), label: 'Name',
isVisible: true isVisible: true
}, },
{ {
name: 'interval', name: 'interval',
label: translate('Interval'), label: 'Interval',
isVisible: true isVisible: true
}, },
{ {
name: 'lastExecution', name: 'lastExecution',
label: translate('LastExecution'), label: 'Last Execution',
isVisible: true isVisible: true
}, },
{ {
name: 'lastDuration', name: 'lastDuration',
label: translate('LastDuration'), label: 'Last Duration',
isVisible: true isVisible: true
}, },
{ {
name: 'nextExecution', name: 'nextExecution',
label: translate('NextExecution'), label: 'Next Execution',
isVisible: true isVisible: true
}, },
{ {

View File

@@ -1,4 +1,4 @@
import { filesize } from 'filesize'; import filesize from 'filesize';
function formatBytes(input) { function formatBytes(input) {
const size = Number(input); const size = Number(input);

View File

@@ -25,108 +25,107 @@
"not chrome < 60" "not chrome < 60"
], ],
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "6.2.1", "@fortawesome/fontawesome-free": "6.1.1",
"@fortawesome/fontawesome-svg-core": "6.2.1", "@fortawesome/fontawesome-svg-core": "6.1.1",
"@fortawesome/free-regular-svg-icons": "6.2.1", "@fortawesome/free-regular-svg-icons": "6.1.1",
"@fortawesome/free-solid-svg-icons": "6.2.1", "@fortawesome/free-solid-svg-icons": "6.1.1",
"@fortawesome/react-fontawesome": "0.2.0", "@fortawesome/react-fontawesome": "0.1.18",
"@microsoft/signalr": "6.0.11", "@microsoft/signalr": "6.0.3",
"@sentry/browser": "7.28.0", "@sentry/browser": "6.19.2",
"@sentry/integrations": "7.28.0", "@sentry/integrations": "6.19.2",
"chart.js": "4.1.1", "chart.js": "3.7.1",
"classnames": "2.3.2", "classnames": "2.3.1",
"clipboard": "2.0.11", "clipboard": "2.0.10",
"connected-react-router": "6.9.3", "connected-react-router": "6.9.1",
"element-class": "0.2.2", "element-class": "0.2.2",
"filesize": "10.0.6", "filesize": "6.3.0",
"history": "4.10.1", "history": "4.10.1",
"https-browserify": "1.0.0", "https-browserify": "1.0.0",
"jdu": "1.0.0", "jdu": "1.0.0",
"jquery": "3.6.2", "jquery": "3.6.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"mobile-detect": "1.4.5", "mobile-detect": "1.4.5",
"moment": "2.29.4", "moment": "2.29.2",
"mousetrap": "1.6.5", "mousetrap": "1.6.5",
"normalize.css": "8.0.1", "normalize.css": "8.0.1",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"qs": "6.11.0", "qs": "6.10.3",
"react": "17.0.2", "react": "17.0.2",
"react-addons-shallow-compare": "15.6.3", "react-addons-shallow-compare": "15.6.3",
"react-async-script": "1.2.0", "react-async-script": "1.2.0",
"react-autosuggest": "10.1.0", "react-autosuggest": "10.1.0",
"react-custom-scrollbars-2": "4.5.0", "react-custom-scrollbars-2": "4.4.0",
"react-dnd": "14.0.4", "react-dnd": "14.0.4",
"react-dnd-html5-backend": "14.0.2", "react-dnd-html5-backend": "14.0.2",
"react-dnd-multi-backend": "6.0.2", "react-dnd-multi-backend": "6.0.2",
"react-dnd-touch-backend": "14.1.1", "react-dnd-touch-backend": "14.1.1",
"react-document-title": "2.0.3", "react-document-title": "2.0.3",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-focus-lock": "2.9.2", "react-focus-lock": "2.5.0",
"react-google-recaptcha": "2.1.0", "react-google-recaptcha": "2.1.0",
"react-lazyload": "3.2.0", "react-lazyload": "3.2.0",
"react-measure": "1.4.7", "react-measure": "1.4.7",
"react-popper": "1.3.7", "react-popper": "1.3.7",
"react-redux": "8.0.5", "react-redux": "7.2.4",
"react-router": "5.2.0", "react-router": "5.2.0",
"react-router-dom": "5.2.0", "react-router-dom": "5.2.0",
"react-text-truncate": "0.19.0",
"react-virtualized": "9.21.1", "react-virtualized": "9.21.1",
"redux": "4.2.0", "redux": "4.1.0",
"redux-actions": "2.6.5", "redux-actions": "2.6.5",
"redux-batched-actions": "0.5.0", "redux-batched-actions": "0.5.0",
"redux-localstorage": "0.4.1", "redux-localstorage": "0.4.1",
"redux-thunk": "2.4.2", "redux-thunk": "2.3.0",
"reselect": "4.1.7" "reselect": "4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.20.5", "@babel/core": "7.17.8",
"@babel/eslint-parser": "7.19.1", "@babel/eslint-parser": "7.17.0",
"@babel/plugin-proposal-class-properties": "7.18.6", "@babel/plugin-proposal-class-properties": "7.16.7",
"@babel/plugin-proposal-decorators": "7.20.5", "@babel/plugin-proposal-decorators": "7.17.8",
"@babel/plugin-proposal-export-default-from": "7.18.10", "@babel/plugin-proposal-export-default-from": "7.16.7",
"@babel/plugin-proposal-export-namespace-from": "7.18.9", "@babel/plugin-proposal-export-namespace-from": "7.16.7",
"@babel/plugin-proposal-function-sent": "7.18.6", "@babel/plugin-proposal-function-sent": "7.16.7",
"@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6", "@babel/plugin-proposal-nullish-coalescing-operator": "7.16.7",
"@babel/plugin-proposal-numeric-separator": "7.18.6", "@babel/plugin-proposal-numeric-separator": "7.16.7",
"@babel/plugin-proposal-optional-chaining": "7.18.9", "@babel/plugin-proposal-optional-chaining": "7.16.7",
"@babel/plugin-proposal-throw-expressions": "7.18.6", "@babel/plugin-proposal-throw-expressions": "7.16.7",
"@babel/plugin-syntax-dynamic-import": "7.8.3", "@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/preset-env": "7.20.2", "@babel/preset-env": "7.16.11",
"@babel/preset-react": "7.18.6", "@babel/preset-react": "7.16.7",
"autoprefixer": "10.4.13", "autoprefixer": "10.4.4",
"babel-loader": "9.1.0", "babel-loader": "8.2.4",
"babel-plugin-inline-classnames": "2.0.1", "babel-plugin-inline-classnames": "2.0.1",
"babel-plugin-transform-react-remove-prop-types": "0.4.24", "babel-plugin-transform-react-remove-prop-types": "0.4.24",
"core-js": "3.26.1", "core-js": "3.21.1",
"css-loader": "6.7.3", "css-loader": "6.7.1",
"eslint": "8.30.0", "eslint": "8.11.0",
"eslint-plugin-filenames": "1.3.2", "eslint-plugin-filenames": "1.3.2",
"eslint-plugin-import": "2.26.0", "eslint-plugin-import": "2.25.4",
"eslint-plugin-react": "7.31.11", "eslint-plugin-react": "7.29.4",
"eslint-plugin-simple-import-sort": "8.0.0", "eslint-plugin-simple-import-sort": "7.0.0",
"esprint": "3.6.0", "esprint": "3.3.0",
"file-loader": "6.2.0", "file-loader": "6.2.0",
"filemanager-webpack-plugin": "8.0.0", "filemanager-webpack-plugin": "6.1.7",
"html-webpack-plugin": "5.5.0", "html-webpack-plugin": "5.5.0",
"loader-utils": "^3.2.1", "loader-utils": "^3.0.0",
"mini-css-extract-plugin": "2.7.2", "mini-css-extract-plugin": "2.6.0",
"postcss": "8.4.20", "postcss": "8.4.12",
"postcss-color-function": "4.1.0", "postcss-color-function": "4.1.0",
"postcss-loader": "7.0.2", "postcss-loader": "6.2.1",
"postcss-mixins": "9.0.4", "postcss-mixins": "9.0.2",
"postcss-nested": "6.0.0", "postcss-nested": "5.0.6",
"postcss-simple-vars": "7.0.1", "postcss-simple-vars": "6.0.3",
"postcss-url": "10.1.3", "postcss-url": "10.1.3",
"require-nocache": "1.0.0", "require-nocache": "1.0.0",
"rimraf": "3.0.2", "rimraf": "3.0.2",
"run-sequence": "2.2.1", "run-sequence": "2.2.1",
"streamqueue": "1.1.2", "streamqueue": "1.1.2",
"style-loader": "3.3.1", "style-loader": "3.3.1",
"stylelint": "14.16.0", "stylelint": "14.6.0",
"stylelint-order": "5.0.0", "stylelint-order": "5.0.0",
"url-loader": "4.1.1", "url-loader": "4.1.1",
"webpack": "5.75.0", "webpack": "5.70.0",
"webpack-cli": "5.0.1", "webpack-cli": "4.9.2",
"webpack-livereload-plugin": "3.0.2" "webpack-livereload-plugin": "3.0.2"
} }
} }

View File

@@ -94,7 +94,7 @@
<!-- Standard testing packages --> <!-- Standard testing packages -->
<ItemGroup Condition="'$(TestProject)'=='true'"> <ItemGroup Condition="'$(TestProject)'=='true'">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="NUnit" Version="3.13.3" /> <PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" /> <PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="NunitXml.TestLogger" Version="3.0.117" /> <PackageReference Include="NunitXml.TestLogger" Version="3.0.117" />

View File

@@ -44,9 +44,9 @@ namespace NzbDrone.Automation.Test
driver.Manage().Window.Size = new System.Drawing.Size(1920, 1080); driver.Manage().Window.Size = new System.Drawing.Size(1920, 1080);
_runner = new NzbDroneRunner(LogManager.GetCurrentClassLogger(), null); _runner = new NzbDroneRunner(LogManager.GetCurrentClassLogger());
_runner.KillAll(); _runner.KillAll();
_runner.Start(true); _runner.Start();
driver.Url = "http://localhost:9696"; driver.Url = "http://localhost:9696";

View File

@@ -10,16 +10,6 @@ namespace NzbDrone.Common.Test.DiskTests
public abstract class DiskProviderFixtureBase<TSubject> : TestBase<TSubject> public abstract class DiskProviderFixtureBase<TSubject> : TestBase<TSubject>
where TSubject : class, IDiskProvider where TSubject : class, IDiskProvider
{ {
[Test]
public void writealltext_should_truncate_existing()
{
var file = GetTempFilePath();
Subject.WriteAllText(file, "A pretty long string");
Subject.WriteAllText(file, "A short string");
Subject.ReadAllText(file).Should().Be("A short string");
}
[Test] [Test]
[Retry(5)] [Retry(5)]
public void directory_exist_should_be_able_to_find_existing_folder() public void directory_exist_should_be_able_to_find_existing_folder()

View File

@@ -402,40 +402,6 @@ namespace NzbDrone.Common.Test.DiskTests
VerifyCopyFolder(source.FullName, destination.FullName); VerifyCopyFolder(source.FullName, destination.FullName);
} }
[Test]
public void CopyFolder_should_detect_caseinsensitive_parents()
{
WindowsOnly();
WithRealDiskProvider();
var original = GetFilledTempFolder();
var root = new DirectoryInfo(GetTempFilePath());
var source = new DirectoryInfo(root.FullName + "A/series");
var destination = new DirectoryInfo(root.FullName + "a/series");
Subject.TransferFolder(original.FullName, source.FullName, TransferMode.Copy);
Assert.Throws<IOException>(() => Subject.TransferFolder(source.FullName, destination.FullName, TransferMode.Copy));
}
[Test]
public void CopyFolder_should_detect_caseinsensitive_folder()
{
WindowsOnly();
WithRealDiskProvider();
var original = GetFilledTempFolder();
var root = new DirectoryInfo(GetTempFilePath());
var source = new DirectoryInfo(root.FullName + "A/series");
var destination = new DirectoryInfo(root.FullName + "A/Series");
Subject.TransferFolder(original.FullName, source.FullName, TransferMode.Copy);
Assert.Throws<IOException>(() => Subject.TransferFolder(source.FullName, destination.FullName, TransferMode.Copy));
}
[Test] [Test]
public void CopyFolder_should_ignore_nfs_temp_file() public void CopyFolder_should_ignore_nfs_temp_file()
{ {
@@ -485,42 +451,6 @@ namespace NzbDrone.Common.Test.DiskTests
VerifyMoveFolder(original.FullName, source.FullName, destination.FullName); VerifyMoveFolder(original.FullName, source.FullName, destination.FullName);
} }
[Test]
public void MoveFolder_should_detect_caseinsensitive_parents()
{
WindowsOnly();
WithRealDiskProvider();
var original = GetFilledTempFolder();
var root = new DirectoryInfo(GetTempFilePath());
var source = new DirectoryInfo(root.FullName + "A/series");
var destination = new DirectoryInfo(root.FullName + "a/series");
Subject.TransferFolder(original.FullName, source.FullName, TransferMode.Copy);
Assert.Throws<IOException>(() => Subject.TransferFolder(source.FullName, destination.FullName, TransferMode.Move));
}
[Test]
public void MoveFolder_should_rename_caseinsensitive_folder()
{
WindowsOnly();
WithRealDiskProvider();
var original = GetFilledTempFolder();
var root = new DirectoryInfo(GetTempFilePath());
var source = new DirectoryInfo(root.FullName + "A/series");
var destination = new DirectoryInfo(root.FullName + "A/Series");
Subject.TransferFolder(original.FullName, source.FullName, TransferMode.Copy);
Subject.TransferFolder(source.FullName, destination.FullName, TransferMode.Move);
source.FullName.GetActualCasing().Should().Be(destination.FullName);
}
[Test] [Test]
public void should_throw_if_destination_is_readonly() public void should_throw_if_destination_is_readonly()
{ {
@@ -623,23 +553,6 @@ namespace NzbDrone.Common.Test.DiskTests
VerifyCopyFolder(original.FullName, destination.FullName); VerifyCopyFolder(original.FullName, destination.FullName);
} }
[Test]
public void MirrorFolder_should_handle_trailing_slash()
{
WithRealDiskProvider();
var original = GetFilledTempFolder();
var source = new DirectoryInfo(GetTempFilePath());
var destination = new DirectoryInfo(GetTempFilePath());
Subject.TransferFolder(original.FullName, source.FullName, TransferMode.Copy);
var count = Subject.MirrorFolder(source.FullName + Path.DirectorySeparatorChar, destination.FullName);
count.Should().Equals(3);
VerifyCopyFolder(original.FullName, destination.FullName);
}
[Test] [Test]
public void TransferFolder_should_use_movefolder_if_on_same_mount() public void TransferFolder_should_use_movefolder_if_on_same_mount()
{ {
@@ -839,10 +752,6 @@ namespace NzbDrone.Common.Test.DiskTests
.Setup(v => v.CreateFolder(It.IsAny<string>())) .Setup(v => v.CreateFolder(It.IsAny<string>()))
.Callback<string>(v => Directory.CreateDirectory(v)); .Callback<string>(v => Directory.CreateDirectory(v));
Mocker.GetMock<IDiskProvider>()
.Setup(v => v.MoveFolder(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()))
.Callback<string, string, bool>((v, r, b) => Directory.Move(v, r));
Mocker.GetMock<IDiskProvider>() Mocker.GetMock<IDiskProvider>()
.Setup(v => v.DeleteFolder(It.IsAny<string>(), It.IsAny<bool>())) .Setup(v => v.DeleteFolder(It.IsAny<string>(), It.IsAny<bool>()))
.Callback<string, bool>((v, r) => Directory.Delete(v, r)); .Callback<string, bool>((v, r) => Directory.Delete(v, r));

View File

@@ -28,6 +28,14 @@ namespace NzbDrone.Common.Test.DiskTests
Subject.GetAvailableSpace(Path.Combine(path, "invalidFolder")).Should().NotBe(0); Subject.GetAvailableSpace(Path.Combine(path, "invalidFolder")).Should().NotBe(0);
} }
[Ignore("Docker")]
[Test]
public void should_be_able_to_check_space_on_ramdrive()
{
PosixOnly();
Subject.GetAvailableSpace("/run/").Should().NotBe(0);
}
[Ignore("Docker")] [Ignore("Docker")]
[Test] [Test]
public void should_return_free_disk_space() public void should_return_free_disk_space()
@@ -36,6 +44,35 @@ namespace NzbDrone.Common.Test.DiskTests
result.Should().BeGreaterThan(0); result.Should().BeGreaterThan(0);
} }
[Test]
public void should_be_able_to_get_space_on_unc()
{
WindowsOnly();
var result = Subject.GetAvailableSpace(@"\\localhost\c$\Windows");
result.Should().BeGreaterThan(0);
}
[Test]
public void should_throw_if_drive_doesnt_exist()
{
WindowsOnly();
// Find a drive that doesn't exist.
for (char driveletter = 'Z'; driveletter > 'D'; driveletter--)
{
if (new DriveInfo(driveletter.ToString()).IsReady)
{
continue;
}
Assert.Throws<DirectoryNotFoundException>(() => Subject.GetAvailableSpace(driveletter + @":\NOT_A_REAL_PATH\DOES_NOT_EXIST".AsOsAgnostic()));
return;
}
Assert.Inconclusive("No drive available for testing.");
}
[Ignore("Docker")] [Ignore("Docker")]
[Test] [Test]
public void should_be_able_to_get_space_on_folder_that_doesnt_exist() public void should_be_able_to_get_space_on_folder_that_doesnt_exist()

View File

@@ -1,25 +0,0 @@
using System.Globalization;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.Extensions;
namespace NzbDrone.Common.Test.ExtensionTests.StringExtensionTests
{
[TestFixture]
public class IsValidIPAddressFixture
{
[TestCase("192.168.0.1")]
[TestCase("::1")]
[TestCase("2001:db8:4006:812::200e")]
public void should_validate_ip_address(string input)
{
input.IsValidIpAddress().Should().BeTrue();
}
[TestCase("sonarr.tv")]
public void should_not_parse_non_ip_address(string input)
{
input.IsValidIpAddress().Should().BeFalse();
}
}
}

View File

@@ -4,7 +4,6 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Http;
using System.Threading; using System.Threading;
using FluentAssertions; using FluentAssertions;
using Moq; using Moq;
@@ -16,11 +15,8 @@ using NzbDrone.Common.Http;
using NzbDrone.Common.Http.Dispatchers; using NzbDrone.Common.Http.Dispatchers;
using NzbDrone.Common.Http.Proxy; using NzbDrone.Common.Http.Proxy;
using NzbDrone.Common.TPL; using NzbDrone.Common.TPL;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Security;
using NzbDrone.Test.Common; using NzbDrone.Test.Common;
using NzbDrone.Test.Common.Categories; using NzbDrone.Test.Common.Categories;
using HttpClient = NzbDrone.Common.Http.HttpClient;
namespace NzbDrone.Common.Test.Http namespace NzbDrone.Common.Test.Http
{ {
@@ -35,8 +31,6 @@ namespace NzbDrone.Common.Test.Http
private string _httpBinHost; private string _httpBinHost;
private string _httpBinHost2; private string _httpBinHost2;
private System.Net.Http.HttpClient _httpClient = new ();
[OneTimeSetUp] [OneTimeSetUp]
public void FixtureSetUp() public void FixtureSetUp()
{ {
@@ -44,7 +38,7 @@ namespace NzbDrone.Common.Test.Http
var mainHost = "httpbin.servarr.com"; var mainHost = "httpbin.servarr.com";
// Use mirrors for tests that use two hosts // Use mirrors for tests that use two hosts
var candidates = new[] { "httpbin1.servarr.com" }; var candidates = new[] { "eu.httpbin.org", /* "httpbin.org", */ "www.httpbin.org" };
// httpbin.org is broken right now, occassionally redirecting to https if it's unavailable. // httpbin.org is broken right now, occassionally redirecting to https if it's unavailable.
_httpBinHost = mainHost; _httpBinHost = mainHost;
@@ -52,22 +46,31 @@ namespace NzbDrone.Common.Test.Http
TestLogger.Info($"{candidates.Length} TestSites available."); TestLogger.Info($"{candidates.Length} TestSites available.");
_httpBinSleep = 10; _httpBinSleep = _httpBinHosts.Length < 2 ? 100 : 10;
} }
private bool IsTestSiteAvailable(string site) private bool IsTestSiteAvailable(string site)
{ {
try try
{ {
var res = _httpClient.GetAsync($"https://{site}/get").GetAwaiter().GetResult(); var req = WebRequest.Create($"https://{site}/get") as HttpWebRequest;
var res = req.GetResponse() as HttpWebResponse;
if (res.StatusCode != HttpStatusCode.OK) if (res.StatusCode != HttpStatusCode.OK)
{ {
return false; return false;
} }
res = _httpClient.GetAsync($"https://{site}/status/429").GetAwaiter().GetResult(); try
{
req = WebRequest.Create($"https://{site}/status/429") as HttpWebRequest;
res = req.GetResponse() as HttpWebResponse;
}
catch (WebException ex)
{
res = ex.Response as HttpWebResponse;
}
if (res == null || res.StatusCode != HttpStatusCode.TooManyRequests) if (res == null || res.StatusCode != (HttpStatusCode)429)
{ {
return false; return false;
} }
@@ -92,13 +95,10 @@ namespace NzbDrone.Common.Test.Http
Mocker.GetMock<IOsInfo>().Setup(c => c.Name).Returns("TestOS"); Mocker.GetMock<IOsInfo>().Setup(c => c.Name).Returns("TestOS");
Mocker.GetMock<IOsInfo>().Setup(c => c.Version).Returns("9.0.0"); Mocker.GetMock<IOsInfo>().Setup(c => c.Version).Returns("9.0.0");
Mocker.GetMock<IConfigService>().SetupGet(x => x.CertificateValidation).Returns(CertificateValidationType.Enabled);
Mocker.SetConstant<IUserAgentBuilder>(Mocker.Resolve<UserAgentBuilder>()); Mocker.SetConstant<IUserAgentBuilder>(Mocker.Resolve<UserAgentBuilder>());
Mocker.SetConstant<ICacheManager>(Mocker.Resolve<CacheManager>()); Mocker.SetConstant<ICacheManager>(Mocker.Resolve<CacheManager>());
Mocker.SetConstant<ICreateManagedWebProxy>(Mocker.Resolve<ManagedWebProxyFactory>()); Mocker.SetConstant<ICreateManagedWebProxy>(Mocker.Resolve<ManagedWebProxyFactory>());
Mocker.SetConstant<ICertificateValidationService>(new X509CertificateValidationService(Mocker.GetMock<IConfigService>().Object, TestLogger));
Mocker.SetConstant<IRateLimitService>(Mocker.Resolve<RateLimitService>()); Mocker.SetConstant<IRateLimitService>(Mocker.Resolve<RateLimitService>());
Mocker.SetConstant<IEnumerable<IHttpRequestInterceptor>>(Array.Empty<IHttpRequestInterceptor>()); Mocker.SetConstant<IEnumerable<IHttpRequestInterceptor>>(Array.Empty<IHttpRequestInterceptor>());
Mocker.SetConstant<IHttpDispatcher>(Mocker.Resolve<TDispatcher>()); Mocker.SetConstant<IHttpDispatcher>(Mocker.Resolve<TDispatcher>());
@@ -138,28 +138,6 @@ namespace NzbDrone.Common.Test.Http
response.Content.Should().NotBeNullOrWhiteSpace(); response.Content.Should().NotBeNullOrWhiteSpace();
} }
[TestCase(CertificateValidationType.Enabled)]
[TestCase(CertificateValidationType.DisabledForLocalAddresses)]
public void bad_ssl_should_fail_when_remote_validation_enabled(CertificateValidationType validationType)
{
Mocker.GetMock<IConfigService>().SetupGet(x => x.CertificateValidation).Returns(validationType);
var request = new HttpRequest($"https://expired.badssl.com");
Assert.Throws<HttpRequestException>(() => Subject.Execute(request));
ExceptionVerification.ExpectedErrors(1);
}
[Test]
public void bad_ssl_should_pass_if_remote_validation_disabled()
{
Mocker.GetMock<IConfigService>().SetupGet(x => x.CertificateValidation).Returns(CertificateValidationType.Disabled);
var request = new HttpRequest($"https://expired.badssl.com");
Subject.Execute(request);
ExceptionVerification.ExpectedErrors(0);
}
[Test] [Test]
public void should_execute_typed_get() public void should_execute_typed_get()
{ {
@@ -184,45 +162,15 @@ namespace NzbDrone.Common.Test.Http
response.Resource.Data.Should().Be(message); response.Resource.Data.Should().Be(message);
} }
[Test] [TestCase("gzip")]
public void should_execute_post_with_content_type() public void should_execute_get_using_gzip(string compression)
{ {
var message = "{ my: 1 }"; var request = new HttpRequest($"https://{_httpBinHost}/{compression}");
var request = new HttpRequest($"https://{_httpBinHost}/post");
request.SetContent(message);
request.Headers.ContentType = "application/json";
var response = Subject.Post<HttpBinResource>(request);
response.Resource.Data.Should().Be(message);
}
[Test]
public void should_execute_get_using_gzip()
{
var request = new HttpRequest($"https://{_httpBinHost}/gzip");
var response = Subject.Get<HttpBinResource>(request); var response = Subject.Get<HttpBinResource>(request);
response.Resource.Headers["Accept-Encoding"].ToString().Should().Contain("gzip"); response.Resource.Headers["Accept-Encoding"].ToString().Should().Be(compression);
response.Resource.Gzipped.Should().BeTrue(); response.Resource.Gzipped.Should().BeTrue();
response.Resource.Brotli.Should().BeFalse();
}
[Test]
[Platform(Exclude = "MacOsX", Reason = "Azure agent update prevents brotli on OSX")]
public void should_execute_get_using_brotli()
{
var request = new HttpRequest($"https://{_httpBinHost}/brotli");
var response = Subject.Get<HttpBinResource>(request);
response.Resource.Headers["Accept-Encoding"].ToString().Should().Contain("br");
response.Resource.Gzipped.Should().BeFalse();
response.Resource.Brotli.Should().BeTrue();
} }
[TestCase(HttpStatusCode.Unauthorized)] [TestCase(HttpStatusCode.Unauthorized)]
@@ -242,28 +190,6 @@ namespace NzbDrone.Common.Test.Http
ExceptionVerification.IgnoreWarns(); ExceptionVerification.IgnoreWarns();
} }
[Test]
public void should_not_throw_on_suppressed_status_codes()
{
var request = new HttpRequest($"https://{_httpBinHost}/status/{HttpStatusCode.NotFound}");
request.SuppressHttpErrorStatusCodes = new[] { HttpStatusCode.NotFound };
Assert.Throws<HttpException>(() => Subject.Get<HttpBinResource>(request));
ExceptionVerification.IgnoreWarns();
}
[Test]
public void should_not_log_unsuccessful_status_codes()
{
var request = new HttpRequest($"https://{_httpBinHost}/status/{HttpStatusCode.NotFound}");
request.LogHttpError = false;
Assert.Throws<HttpException>(() => Subject.Get<HttpBinResource>(request));
ExceptionVerification.ExpectedWarns(0);
}
[Test] [Test]
public void should_not_follow_redirects_when_not_in_production() public void should_not_follow_redirects_when_not_in_production()
{ {
@@ -389,38 +315,13 @@ namespace NzbDrone.Common.Test.Http
{ {
var file = GetTempFilePath(); var file = GetTempFilePath();
Assert.Throws<HttpException>(() => Subject.DownloadFile("https://download.sonarr.tv/wrongpath", file)); Assert.Throws<WebException>(() => Subject.DownloadFile("https://download.sonarr.tv/wrongpath", file));
File.Exists(file).Should().BeFalse(); File.Exists(file).Should().BeFalse();
ExceptionVerification.ExpectedWarns(1); ExceptionVerification.ExpectedWarns(1);
} }
[Test]
public void should_not_write_redirect_content_to_stream()
{
var file = GetTempFilePath();
using (var fileStream = new FileStream(file, FileMode.Create))
{
var request = new HttpRequest($"http://{_httpBinHost}/redirect/1");
request.AllowAutoRedirect = false;
request.ResponseStream = fileStream;
var response = Subject.Get(request);
response.StatusCode.Should().Be(HttpStatusCode.Moved);
}
ExceptionVerification.ExpectedErrors(1);
File.Exists(file).Should().BeTrue();
var fileInfo = new FileInfo(file);
fileInfo.Length.Should().Be(0);
}
[Test] [Test]
public void should_send_cookie() public void should_send_cookie()
{ {
@@ -852,7 +753,6 @@ namespace NzbDrone.Common.Test.Http
public string Url { get; set; } public string Url { get; set; }
public string Data { get; set; } public string Data { get; set; }
public bool Gzipped { get; set; } public bool Gzipped { get; set; }
public bool Brotli { get; set; }
} }
public class HttpCookieResource public class HttpCookieResource

View File

@@ -1,4 +1,4 @@
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Common.Http; using NzbDrone.Common.Http;
using NzbDrone.Test.Common; using NzbDrone.Test.Common;
@@ -10,7 +10,6 @@ namespace NzbDrone.Common.Test.Http
[TestCase("abc://my_host.com:8080/root/api/")] [TestCase("abc://my_host.com:8080/root/api/")]
[TestCase("abc://my_host.com:8080//root/api/")] [TestCase("abc://my_host.com:8080//root/api/")]
[TestCase("abc://my_host.com:8080/root//api/")] [TestCase("abc://my_host.com:8080/root//api/")]
[TestCase("abc://[::1]:8080/root//api/")]
public void should_parse(string uri) public void should_parse(string uri)
{ {
var newUri = new HttpUri(uri); var newUri = new HttpUri(uri);

View File

@@ -28,12 +28,9 @@ namespace NzbDrone.Common.Test.InstrumentationTests
// Indexer and Download Client Responses // Indexer and Download Client Responses
// avistaz response // avistaz response
[TestCase(@"""download"":""https://avistaz.to/rss/download/2b51db35e1910123321025a12b9933d2/tb51db35e1910123321025a12b9933d2.torrent"",")] [TestCase(@"""download"":""https:\/\/avistaz.to\/rss\/download\/2b51db35e1910123321025a12b9933d2\/tb51db35e1910123321025a12b9933d2.torrent"",")]
[TestCase(@",""info_hash"":""2b51db35e1910123321025a12b9933d2"",")] [TestCase(@",""info_hash"":""2b51db35e1910123321025a12b9933d2"",")]
// animebytes response
[TestCase(@"""Link"":""https://animebytes.tv/torrent/994064/download/tb51db35e1910123321025a12b9933d2"",")]
// danish bytes response // danish bytes response
[TestCase(@",""rsskey"":""2b51db35e1910123321025a12b9933d2"",")] [TestCase(@",""rsskey"":""2b51db35e1910123321025a12b9933d2"",")]
[TestCase(@",""passkey"":""2b51db35e1910123321025a12b9933d2"",")] [TestCase(@",""passkey"":""2b51db35e1910123321025a12b9933d2"",")]
@@ -80,48 +77,29 @@ namespace NzbDrone.Common.Test.InstrumentationTests
// Download Station // Download Station
[TestCase(@"webapi/entry.cgi?api=(removed)&version=2&method=login&account=01233210&passwd=mySecret&format=sid&session=DownloadStation")] [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""")]
// BroadcastheNet // BroadcastheNet
[TestCase(@"method: ""getTorrents"", ""params"": [ ""mySecret"",")] [TestCase(@"method: ""getTorrents"", ""params"": [ ""mySecret"",")]
[TestCase(@"getTorrents(""mySecret"", [asdfasdf], 100, 0)")] [TestCase(@"getTorrents(""mySecret"", [asdfasdf], 100, 0)")]
[TestCase(@"""DownloadURL"":""https://broadcasthe.net/torrents.php?action=download&id=123&authkey=mySecret&torrent_pass=mySecret""")] [TestCase(@"""DownloadURL"":""https:\/\/broadcasthe.net\/torrents.php?action=download&id=123&authkey=mySecret&torrent_pass=mySecret""")]
// Webhooks - Notifiarr // Notifiarr
[TestCase(@"https://xxx.yyy/api/v1/notification/prowlarr/9pr04sg6-0123-3210-imav-eql2tyu8xyui")]
[TestCase("https://notifiarr.com/notifier.php: api=1234530f-422f-4aac-b6b3-01233210aaaa&radarr_health_issue_message=Download")] [TestCase("https://notifiarr.com/notifier.php: api=1234530f-422f-4aac-b6b3-01233210aaaa&radarr_health_issue_message=Download")]
[TestCase("/readarr/signalr/messages/negotiate?access_token=1234530f422f4aacb6b301233210aaaa&negotiateVersion=1")]
// RSS // RSS
[TestCase(@"<atom:link href = ""https://api.nzb.su/api?t=search&amp;extended=1&amp;cat=3030&apikey=mySecret&amp;q=Diggers"" rel=""self"" type=""application/rss+xml"" />")] [TestCase(@"<atom:link href = ""https://api.nzb.su/api?t=search&amp;extended=1&amp;cat=3030&apikey=mySecret&amp;q=Diggers"" rel=""self"" type=""application/rss+xml"" />")]
// Internal // Internal
[TestCase(@"[Info] MigrationController: *** Migrating Database=prowlarr-main;Host=postgres14;Username=mySecret;Password=mySecret;Port=5432;Enlist=False ***")] [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;")]
public void should_clean_message(string message) public void should_clean_message(string message)
{ {
var cleansedMessage = CleanseLogMessage.Cleanse(message); var cleansedMessage = CleanseLogMessage.Cleanse(message);
cleansedMessage.Should().NotContain("mySecret"); cleansedMessage.Should().NotContain("mySecret");
cleansedMessage.Should().NotContain("123%@%_@!#^#@");
cleansedMessage.Should().NotContain("01233210"); cleansedMessage.Should().NotContain("01233210");
} }
[TestCase(@"[Info] MigrationController: *** Migrating Database=radarr-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;")]
public void should_keep_message(string message)
{
var cleansedMessage = CleanseLogMessage.Cleanse(message);
cleansedMessage.Should().NotContain("mySecret");
cleansedMessage.Should().NotContain("123%@%_@!#^#@");
cleansedMessage.Should().NotContain("01233210");
cleansedMessage.Should().Contain("shouldkeep1");
cleansedMessage.Should().Contain("shouldkeep2");
cleansedMessage.Should().Contain("shouldkeep3");
}
[TestCase(@"Some message (from 32.2.3.5 user agent)")] [TestCase(@"Some message (from 32.2.3.5 user agent)")]
[TestCase(@"Auth-Invalidated ip 32.2.3.5")] [TestCase(@"Auth-Invalidated ip 32.2.3.5")]
[TestCase(@"Auth-Success ip 32.2.3.5")] [TestCase(@"Auth-Success ip 32.2.3.5")]

View File

@@ -170,7 +170,7 @@ namespace NzbDrone.Common.Test
var processStarted = new ManualResetEventSlim(); var processStarted = new ManualResetEventSlim();
string suffix; string suffix;
if (OsInfo.IsWindows) if (OsInfo.IsWindows || PlatformInfo.IsMono)
{ {
suffix = ".exe"; suffix = ".exe";
} }

View File

@@ -4,13 +4,11 @@ using DryIoc.Microsoft.DependencyInjection;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Common.Composition.Extensions; using NzbDrone.Common.Composition.Extensions;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Datastore.Extensions; using NzbDrone.Core.Datastore.Extensions;
using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Lifecycle;
using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Messaging.Events;
@@ -31,8 +29,7 @@ namespace NzbDrone.Common.Test
.AddDummyDatabase() .AddDummyDatabase()
.AddStartupContext(new StartupContext("first", "second")); .AddStartupContext(new StartupContext("first", "second"));
container.RegisterInstance(new Mock<IHostLifetime>().Object); container.RegisterInstance<IHostLifetime>(new Mock<IHostLifetime>().Object);
container.RegisterInstance(new Mock<IOptions<PostgresOptions>>().Object);
var serviceProvider = container.GetServiceProvider(); var serviceProvider = container.GetServiceProvider();

View File

@@ -30,8 +30,7 @@ namespace NzbDrone.Common.Disk
public abstract long? GetAvailableSpace(string path); public abstract long? GetAvailableSpace(string path);
public abstract void InheritFolderPermissions(string filename); public abstract void InheritFolderPermissions(string filename);
public abstract void SetEveryonePermissions(string filename); public abstract void SetEveryonePermissions(string filename);
public abstract void SetFilePermissions(string path, string mask, string group); public abstract void SetPermissions(string path, string mask);
public abstract void SetPermissions(string path, string mask, string group);
public abstract void CopyPermissions(string sourcePath, string targetPath); public abstract void CopyPermissions(string sourcePath, string targetPath);
public abstract long? GetTotalSize(string path); public abstract long? GetTotalSize(string path);
@@ -131,7 +130,7 @@ namespace NzbDrone.Common.Disk
{ {
var testPath = Path.Combine(path, "prowlarr_write_test.txt"); var testPath = Path.Combine(path, "prowlarr_write_test.txt");
var testContent = string.Format("This file was created to verify if '{0}' is writable. It should've been automatically deleted. Feel free to delete it.", path); var testContent = string.Format("This file was created to verify if '{0}' is writable. It should've been automatically deleted. Feel free to delete it.", path);
WriteAllText(testPath, testContent); File.WriteAllText(testPath, testContent);
File.Delete(testPath); File.Delete(testPath);
return true; return true;
} }
@@ -259,6 +258,17 @@ namespace NzbDrone.Common.Disk
Ensure.That(source, () => source).IsValidPath(); Ensure.That(source, () => source).IsValidPath();
Ensure.That(destination, () => destination).IsValidPath(); Ensure.That(destination, () => destination).IsValidPath();
if (source.PathEquals(destination))
{
throw new IOException(string.Format("Source and destination can't be the same {0}", source));
}
if (FolderExists(destination) && overwrite)
{
DeleteFolder(destination, true);
}
RemoveReadOnlyFolder(source);
Directory.Move(source, destination); Directory.Move(source, destination);
} }
@@ -300,16 +310,7 @@ namespace NzbDrone.Common.Disk
{ {
Ensure.That(filename, () => filename).IsValidPath(); Ensure.That(filename, () => filename).IsValidPath();
RemoveReadOnly(filename); RemoveReadOnly(filename);
File.WriteAllText(filename, contents);
// File.WriteAllText is broken on net core when writing to some CIFS mounts
// This workaround from https://github.com/dotnet/runtime/issues/42790#issuecomment-700362617
using (var fs = new FileStream(filename, FileMode.Create, FileAccess.Write, FileShare.None))
{
using (var writer = new StreamWriter(fs))
{
writer.Write(contents);
}
}
} }
public void FolderSetLastWriteTime(string path, DateTime dateTime) public void FolderSetLastWriteTime(string path, DateTime dateTime)
@@ -549,7 +550,7 @@ namespace NzbDrone.Common.Disk
} }
} }
public virtual bool IsValidFolderPermissionMask(string mask) public virtual bool IsValidFilePermissionMask(string mask)
{ {
throw new NotSupportedException(); throw new NotSupportedException();
} }

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using NLog; using NLog;
using NzbDrone.Common.EnsureThat; using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
namespace NzbDrone.Common.Disk namespace NzbDrone.Common.Disk
@@ -26,56 +27,11 @@ namespace NzbDrone.Common.Disk
_logger = logger; _logger = logger;
} }
private string ResolveRealParentPath(string path)
{
var parentPath = path.GetParentPath();
if (!_diskProvider.FolderExists(parentPath))
{
return path;
}
var realParentPath = parentPath.GetActualCasing();
var partialChildPath = path.Substring(parentPath.Length);
return realParentPath + partialChildPath;
}
public TransferMode TransferFolder(string sourcePath, string targetPath, TransferMode mode) public TransferMode TransferFolder(string sourcePath, string targetPath, TransferMode mode)
{ {
Ensure.That(sourcePath, () => sourcePath).IsValidPath(); Ensure.That(sourcePath, () => sourcePath).IsValidPath();
Ensure.That(targetPath, () => targetPath).IsValidPath(); Ensure.That(targetPath, () => targetPath).IsValidPath();
sourcePath = ResolveRealParentPath(sourcePath);
targetPath = ResolveRealParentPath(targetPath);
_logger.Debug("{0} Directory [{1}] > [{2}]", mode, sourcePath, targetPath);
if (sourcePath == targetPath)
{
throw new IOException(string.Format("Source and destination can't be the same {0}", sourcePath));
}
if (mode == TransferMode.Move && sourcePath.PathEquals(targetPath, StringComparison.InvariantCultureIgnoreCase) && _diskProvider.FolderExists(targetPath))
{
// Move folder out of the way to allow case-insensitive renames
var tempPath = sourcePath + ".backup~";
_logger.Trace("Rename Intermediate Directory [{0}] > [{1}]", sourcePath, tempPath);
_diskProvider.MoveFolder(sourcePath, tempPath);
if (!_diskProvider.FolderExists(targetPath))
{
_logger.Trace("Rename Intermediate Directory [{0}] > [{1}]", tempPath, targetPath);
_logger.Debug("Rename Directory [{0}] > [{1}]", sourcePath, targetPath);
_diskProvider.MoveFolder(tempPath, targetPath);
return mode;
}
// There were two separate folders, revert the intermediate rename and let the recursion deal with it
_logger.Trace("Rename Intermediate Directory [{0}] > [{1}]", tempPath, sourcePath);
_diskProvider.MoveFolder(tempPath, sourcePath);
}
if (mode == TransferMode.Move && !_diskProvider.FolderExists(targetPath)) if (mode == TransferMode.Move && !_diskProvider.FolderExists(targetPath))
{ {
var sourceMount = _diskProvider.GetMount(sourcePath); var sourceMount = _diskProvider.GetMount(sourcePath);
@@ -84,7 +40,7 @@ namespace NzbDrone.Common.Disk
// If we're on the same mount, do a simple folder move. // If we're on the same mount, do a simple folder move.
if (sourceMount != null && targetMount != null && sourceMount.RootDirectory == targetMount.RootDirectory) if (sourceMount != null && targetMount != null && sourceMount.RootDirectory == targetMount.RootDirectory)
{ {
_logger.Debug("Rename Directory [{0}] > [{1}]", sourcePath, targetPath); _logger.Debug("Move Directory [{0}] > [{1}]", sourcePath, targetPath);
_diskProvider.MoveFolder(sourcePath, targetPath); _diskProvider.MoveFolder(sourcePath, targetPath);
return mode; return mode;
} }
@@ -123,13 +79,6 @@ namespace NzbDrone.Common.Disk
if (mode.HasFlag(TransferMode.Move)) if (mode.HasFlag(TransferMode.Move))
{ {
var totalSize = _diskProvider.GetFileInfos(sourcePath).Sum(v => v.Length);
if (totalSize > (100 * 1024L * 1024L))
{
throw new IOException($"Large files still exist in {sourcePath} after folder move, not deleting source folder");
}
_diskProvider.DeleteFolder(sourcePath, true); _diskProvider.DeleteFolder(sourcePath, true);
} }
@@ -143,10 +92,7 @@ namespace NzbDrone.Common.Disk
Ensure.That(sourcePath, () => sourcePath).IsValidPath(); Ensure.That(sourcePath, () => sourcePath).IsValidPath();
Ensure.That(targetPath, () => targetPath).IsValidPath(); Ensure.That(targetPath, () => targetPath).IsValidPath();
sourcePath = ResolveRealParentPath(sourcePath); _logger.Debug("Mirror [{0}] > [{1}]", sourcePath, targetPath);
targetPath = ResolveRealParentPath(targetPath);
_logger.Debug("Mirror Folder [{0}] > [{1}]", sourcePath, targetPath);
if (!_diskProvider.FolderExists(targetPath)) if (!_diskProvider.FolderExists(targetPath))
{ {
@@ -258,9 +204,6 @@ namespace NzbDrone.Common.Disk
Ensure.That(sourcePath, () => sourcePath).IsValidPath(); Ensure.That(sourcePath, () => sourcePath).IsValidPath();
Ensure.That(targetPath, () => targetPath).IsValidPath(); Ensure.That(targetPath, () => targetPath).IsValidPath();
sourcePath = ResolveRealParentPath(sourcePath);
targetPath = ResolveRealParentPath(targetPath);
_logger.Debug("{0} [{1}] > [{2}]", mode, sourcePath, targetPath); _logger.Debug("{0} [{1}] > [{2}]", mode, sourcePath, targetPath);
var originalSize = _diskProvider.GetFileSize(sourcePath); var originalSize = _diskProvider.GetFileSize(sourcePath);

View File

@@ -11,8 +11,7 @@ namespace NzbDrone.Common.Disk
long? GetAvailableSpace(string path); long? GetAvailableSpace(string path);
void InheritFolderPermissions(string filename); void InheritFolderPermissions(string filename);
void SetEveryonePermissions(string filename); void SetEveryonePermissions(string filename);
void SetFilePermissions(string path, string mask, string group); void SetPermissions(string path, string mask);
void SetPermissions(string path, string mask, string group);
void CopyPermissions(string sourcePath, string targetPath); void CopyPermissions(string sourcePath, string targetPath);
long? GetTotalSize(string path); long? GetTotalSize(string path);
DateTime FolderGetCreationTime(string path); DateTime FolderGetCreationTime(string path);
@@ -57,6 +56,6 @@ namespace NzbDrone.Common.Disk
List<FileInfo> GetFileInfos(string path, SearchOption searchOption = SearchOption.TopDirectoryOnly); List<FileInfo> GetFileInfos(string path, SearchOption searchOption = SearchOption.TopDirectoryOnly);
void RemoveEmptySubfolders(string path); void RemoveEmptySubfolders(string path);
void SaveStream(Stream stream, string path); void SaveStream(Stream stream, string path);
bool IsValidFolderPermissionMask(string mask); bool IsValidFilePermissionMask(string mask);
} }
} }

View File

@@ -1,79 +1,15 @@
using System; using System;
using System.IO;
using NzbDrone.Common.EnvironmentInfo;
namespace NzbDrone.Common.Disk namespace NzbDrone.Common.Disk
{ {
public static class LongPathSupport public static class LongPathSupport
{ {
private static int MAX_PATH;
private static int MAX_NAME;
public static void Enable() public static void Enable()
{ {
// Mono has an issue with enabling long path support via app.config. // Mono has an issue with enabling long path support via app.config.
// This works for both mono and .net on Windows. // This works for both mono and .net on Windows.
AppContext.SetSwitch("Switch.System.IO.UseLegacyPathHandling", false); AppContext.SetSwitch("Switch.System.IO.UseLegacyPathHandling", false);
AppContext.SetSwitch("Switch.System.IO.BlockLongPaths", false); AppContext.SetSwitch("Switch.System.IO.BlockLongPaths", false);
DetectLongPathLimits();
}
private static void DetectLongPathLimits()
{
if (!int.TryParse(Environment.GetEnvironmentVariable("MAX_PATH"), out MAX_PATH))
{
if (OsInfo.IsLinux)
{
MAX_PATH = 4096;
}
else
{
try
{
// Windows paths can be up to 32,767 characters long, but each component of the path must be less than 255.
// If the OS does not have Long Path enabled, then the following will throw an exception
// ref: https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation
Path.GetDirectoryName($@"C:\{new string('a', 254)}\{new string('a', 254)}");
MAX_PATH = 4096;
}
catch
{
MAX_PATH = 260 - 1;
}
}
}
if (!int.TryParse(Environment.GetEnvironmentVariable("MAX_NAME"), out MAX_NAME))
{
MAX_NAME = 255;
}
}
public static int MaxFilePathLength
{
get
{
if (MAX_PATH == 0)
{
DetectLongPathLimits();
}
return MAX_PATH;
}
}
public static int MaxFileNameLength
{
get
{
if (MAX_NAME == 0)
{
DetectLongPathLimits();
}
return MAX_NAME;
}
} }
} }
} }

View File

@@ -7,50 +7,34 @@ namespace NzbDrone.Common.Extensions
{ {
public static bool IsLocalAddress(this IPAddress ipAddress) public static bool IsLocalAddress(this IPAddress ipAddress)
{ {
// Map back to IPv4 if mapped to IPv6, for example "::ffff:1.2.3.4" to "1.2.3.4". if (ipAddress.IsIPv6LinkLocal)
if (ipAddress.IsIPv4MappedToIPv6)
{ {
ipAddress = ipAddress.MapToIPv4(); return true;
} }
// Checks loopback ranges for both IPv4 and IPv6.
if (IPAddress.IsLoopback(ipAddress)) if (IPAddress.IsLoopback(ipAddress))
{ {
return true; return true;
} }
// IPv4
if (ipAddress.AddressFamily == AddressFamily.InterNetwork) if (ipAddress.AddressFamily == AddressFamily.InterNetwork)
{ {
return IsLocalIPv4(ipAddress.GetAddressBytes()); byte[] bytes = ipAddress.GetAddressBytes();
} switch (bytes[0])
{
// IPv6 case 10:
if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6) case 127:
{ return true;
return ipAddress.IsIPv6LinkLocal || case 172:
ipAddress.IsIPv6UniqueLocal || return bytes[1] < 32 && bytes[1] >= 16;
ipAddress.IsIPv6SiteLocal; case 192:
return bytes[1] == 168;
default:
return false;
}
} }
return false; return false;
} }
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;
// Class A private range: 10.0.0.0 10.255.255.255 (10.0.0.0/8)
bool 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;
// 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;
return IsLinkLocal() || IsClassA() || IsClassC() || IsClassB();
}
} }
} }

View File

@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Sockets;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@@ -211,51 +210,5 @@ namespace NzbDrone.Common.Extensions
return result.TrimStart(' ', '.').TrimEnd(' '); return result.TrimStart(' ', '.').TrimEnd(' ');
} }
public static string EncodeRFC3986(this string value)
{
// From Twitterizer http://www.twitterizer.net/
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
var encoded = Uri.EscapeDataString(value);
return Regex
.Replace(encoded, "(%[0-9a-f][0-9a-f])", c => c.Value.ToUpper())
.Replace("(", "%28")
.Replace(")", "%29")
.Replace("$", "%24")
.Replace("!", "%21")
.Replace("*", "%2A")
.Replace("'", "%27")
.Replace("%7E", "~");
}
public static bool IsValidIpAddress(this string value)
{
if (!IPAddress.TryParse(value, out var parsedAddress))
{
return false;
}
if (parsedAddress.Equals(IPAddress.Parse("255.255.255.255")))
{
return false;
}
if (parsedAddress.IsIPv6Multicast)
{
return false;
}
return parsedAddress.AddressFamily == AddressFamily.InterNetwork || parsedAddress.AddressFamily == AddressFamily.InterNetworkV6;
}
public static string ToUrlHost(this string input)
{
return input.Contains(":") ? $"[{input}]" : input;
}
} }
} }

View File

@@ -1,12 +0,0 @@
using System.Net;
namespace NzbDrone.Common.Http
{
public class BasicNetworkCredential : NetworkCredential
{
public BasicNetworkCredential(string user, string pass)
: base(user, pass)
{
}
}
}

View File

@@ -10,7 +10,6 @@ namespace NzbDrone.Common.Http
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
// NOTE: we are not checking non-ascii characters and we should // NOTE: we are not checking non-ascii characters and we should
private static readonly Regex _CookieRegex = new Regex(@"([^\(\)<>@,;:\\""/\[\]\?=\{\}\s]+)=([^,;\\""\s]+)"); private static readonly Regex _CookieRegex = new Regex(@"([^\(\)<>@,;:\\""/\[\]\?=\{\}\s]+)=([^,;\\""\s]+)");
private static readonly string[] FilterProps = { "COMMENT", "COMMENTURL", "DISCORD", "DOMAIN", "EXPIRES", "MAX-AGE", "PATH", "PORT", "SECURE", "VERSION", "HTTPONLY", "SAMESITE" };
private static readonly char[] InvalidKeyChars = { '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' ', '\t', '\n' }; private static readonly char[] InvalidKeyChars = { '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' ', '\t', '\n' };
private static readonly char[] InvalidValueChars = { '"', ',', ';', '\\', ' ', '\t', '\n' }; private static readonly char[] InvalidValueChars = { '"', ',', ';', '\\', ' ', '\t', '\n' };
@@ -25,7 +24,7 @@ namespace NzbDrone.Common.Http
var matches = _CookieRegex.Match(cookieHeader); var matches = _CookieRegex.Match(cookieHeader);
while (matches.Success) while (matches.Success)
{ {
if (matches.Groups.Count > 2 && !FilterProps.Contains(matches.Groups[1].Value.ToUpperInvariant())) if (matches.Groups.Count > 2)
{ {
cookieDictionary[matches.Groups[1].Value] = matches.Groups[2].Value; cookieDictionary[matches.Groups[1].Value] = matches.Groups[2].Value;
} }

View File

@@ -1,11 +0,0 @@
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
namespace NzbDrone.Common.Http.Dispatchers
{
public interface ICertificateValidationService
{
bool ShouldByPassValidationError(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors);
}
}

View File

@@ -6,5 +6,6 @@ namespace NzbDrone.Common.Http.Dispatchers
public interface IHttpDispatcher public interface IHttpDispatcher
{ {
Task<HttpResponse> GetResponseAsync(HttpRequest request, CookieContainer cookies); Task<HttpResponse> GetResponseAsync(HttpRequest request, CookieContainer cookies);
Task DownloadFileAsync(string url, string fileName);
} }
} }

View File

@@ -1,16 +1,13 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.IO.Compression;
using System.Net; using System.Net;
using System.Net.Http; using System.Reflection;
using System.Net.Security;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using NLog; using NLog;
using NzbDrone.Common.Cache; using NLog.Fluent;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http.Proxy; using NzbDrone.Common.Http.Proxy;
@@ -18,225 +15,221 @@ namespace NzbDrone.Common.Http.Dispatchers
{ {
public class ManagedHttpDispatcher : IHttpDispatcher public class ManagedHttpDispatcher : IHttpDispatcher
{ {
private const string NO_PROXY_KEY = "no-proxy";
private const int connection_establish_timeout = 2000;
private static bool useIPv6 = Socket.OSSupportsIPv6;
private static bool hasResolvedIPv6Availability;
private readonly IHttpProxySettingsProvider _proxySettingsProvider; private readonly IHttpProxySettingsProvider _proxySettingsProvider;
private readonly ICreateManagedWebProxy _createManagedWebProxy; private readonly ICreateManagedWebProxy _createManagedWebProxy;
private readonly ICertificateValidationService _certificateValidationService;
private readonly IUserAgentBuilder _userAgentBuilder; private readonly IUserAgentBuilder _userAgentBuilder;
private readonly ICached<System.Net.Http.HttpClient> _httpClientCache; private readonly IPlatformInfo _platformInfo;
private readonly ICached<CredentialCache> _credentialCache; private readonly Logger _logger;
public ManagedHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider, public ManagedHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider, ICreateManagedWebProxy createManagedWebProxy, IUserAgentBuilder userAgentBuilder, IPlatformInfo platformInfo, Logger logger)
ICreateManagedWebProxy createManagedWebProxy,
ICertificateValidationService certificateValidationService,
IUserAgentBuilder userAgentBuilder,
ICacheManager cacheManager)
{ {
_proxySettingsProvider = proxySettingsProvider; _proxySettingsProvider = proxySettingsProvider;
_createManagedWebProxy = createManagedWebProxy; _createManagedWebProxy = createManagedWebProxy;
_certificateValidationService = certificateValidationService;
_userAgentBuilder = userAgentBuilder; _userAgentBuilder = userAgentBuilder;
_platformInfo = platformInfo;
_httpClientCache = cacheManager.GetCache<System.Net.Http.HttpClient>(typeof(ManagedHttpDispatcher)); _logger = logger;
_credentialCache = cacheManager.GetCache<CredentialCache>(typeof(ManagedHttpDispatcher), "credentialcache");
} }
public async Task<HttpResponse> GetResponseAsync(HttpRequest request, CookieContainer cookies) public async Task<HttpResponse> GetResponseAsync(HttpRequest request, CookieContainer cookies)
{ {
var requestMessage = new HttpRequestMessage(request.Method, (Uri)request.Url); var webRequest = (HttpWebRequest)WebRequest.Create((Uri)request.Url);
requestMessage.Headers.UserAgent.ParseAdd(_userAgentBuilder.GetUserAgent(request.UseSimplifiedUserAgent));
requestMessage.Headers.ConnectionClose = !request.ConnectionKeepAlive;
var cookieHeader = cookies.GetCookieHeader((Uri)request.Url); // Deflate is not a standard and could break depending on implementation.
if (cookieHeader.IsNotNullOrWhiteSpace()) // we should just stick with the more compatible Gzip
{ //http://stackoverflow.com/questions/8490718/how-to-decompress-stream-deflated-with-java-util-zip-deflater-in-net
requestMessage.Headers.Add("Cookie", cookieHeader); webRequest.AutomaticDecompression = DecompressionMethods.GZip;
}
webRequest.Method = request.Method.ToString();
webRequest.UserAgent = _userAgentBuilder.GetUserAgent(request.UseSimplifiedUserAgent);
webRequest.KeepAlive = request.ConnectionKeepAlive;
webRequest.AllowAutoRedirect = false;
webRequest.CookieContainer = cookies;
using var cts = new CancellationTokenSource();
if (request.RequestTimeout != TimeSpan.Zero) if (request.RequestTimeout != TimeSpan.Zero)
{ {
cts.CancelAfter(request.RequestTimeout); webRequest.Timeout = (int)Math.Ceiling(request.RequestTimeout.TotalMilliseconds);
}
else
{
// The default for System.Net.Http.HttpClient
cts.CancelAfter(TimeSpan.FromSeconds(100));
} }
if (request.Credentials != null) webRequest.Proxy = request.Proxy ?? GetProxy(request.Url);
{
if (request.Credentials is BasicNetworkCredential bc)
{
// Manually set header to avoid initial challenge response
var authInfo = bc.UserName + ":" + bc.Password;
authInfo = Convert.ToBase64String(Encoding.GetEncoding("ISO-8859-1").GetBytes(authInfo));
requestMessage.Headers.Add("Authorization", "Basic " + authInfo);
}
else if (request.Credentials is NetworkCredential nc)
{
var creds = GetCredentialCache();
foreach (var authtype in new[] { "Basic", "Digest" })
{
creds.Remove((Uri)request.Url, authtype);
creds.Add((Uri)request.Url, authtype, nc);
}
}
}
if (request.ContentData != null)
{
requestMessage.Content = new ByteArrayContent(request.ContentData);
}
if (request.Headers != null) if (request.Headers != null)
{ {
AddRequestHeaders(requestMessage, request.Headers); AddRequestHeaders(webRequest, request.Headers);
} }
var httpClient = GetClient(request.Url, request.ProxySettings); HttpWebResponse httpWebResponse;
var sw = new Stopwatch(); var sw = new Stopwatch();
sw.Start(); sw.Start();
using var responseMessage = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cts.Token); try
{ {
byte[] data = null; if (request.ContentData != null)
try
{ {
if (request.ResponseStream != null && responseMessage.StatusCode == HttpStatusCode.OK) webRequest.ContentLength = request.ContentData.Length;
using (var writeStream = webRequest.GetRequestStream())
{ {
responseMessage.Content.CopyTo(request.ResponseStream, null, cts.Token); writeStream.Write(request.ContentData, 0, request.ContentData.Length);
}
}
httpWebResponse = (HttpWebResponse)await webRequest.GetResponseAsync();
}
catch (WebException e)
{
httpWebResponse = (HttpWebResponse)e.Response;
if (httpWebResponse == null)
{
// The default messages for WebException on mono are pretty horrible.
if (e.Status == WebExceptionStatus.NameResolutionFailure)
{
throw new WebException($"DNS Name Resolution Failure: '{webRequest.RequestUri.Host}'", e.Status);
}
else if (e.ToString().Contains("TLS Support not"))
{
throw new TlsFailureException(webRequest, e);
}
else if (e.ToString().Contains("The authentication or decryption has failed."))
{
throw new TlsFailureException(webRequest, e);
}
else if (OsInfo.IsNotWindows)
{
throw new WebException($"{e.Message}: '{webRequest.RequestUri}'", e, e.Status, e.Response);
} }
else else
{ {
data = responseMessage.Content.ReadAsByteArrayAsync(cts.Token).GetAwaiter().GetResult(); throw;
} }
} }
catch (Exception ex) }
byte[] data = null;
using (var responseStream = httpWebResponse.GetResponseStream())
{
if (responseStream != null && responseStream != Stream.Null)
{ {
throw new WebException("Failed to read complete http response", ex, WebExceptionStatus.ReceiveFailure, null); try
}
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 data = await responseStream.ToBytes();
{ }
cookies.SetCookies(responseMessage.RequestMessage.RequestUri, responseCookieHeader); catch (Exception ex)
} {
catch throw new WebException("Failed to read complete http response", ex, WebExceptionStatus.ReceiveFailure, httpWebResponse);
{
// Ignore invalid cookies
}
} }
} }
}
var cookieCollection = cookies.GetCookies(responseMessage.RequestMessage.RequestUri); sw.Stop();
sw.Stop(); return new HttpResponse(request, new HttpHeader(httpWebResponse.Headers), httpWebResponse.Cookies, data, sw.ElapsedMilliseconds, httpWebResponse.StatusCode);
}
return new HttpResponse(request, new HttpHeader(headers), cookieCollection, data, sw.ElapsedMilliseconds, responseMessage.StatusCode); public async Task DownloadFileAsync(string url, string fileName)
{
try
{
var fileInfo = new FileInfo(fileName);
if (fileInfo.Directory != null && !fileInfo.Directory.Exists)
{
fileInfo.Directory.Create();
}
_logger.Debug("Downloading [{0}] to [{1}]", url, fileName);
var stopWatch = Stopwatch.StartNew();
var uri = new HttpUri(url);
using (var webClient = new GZipWebClient())
{
webClient.Headers.Add(HttpRequestHeader.UserAgent, _userAgentBuilder.GetUserAgent());
webClient.Proxy = GetProxy(uri);
await webClient.DownloadFileTaskAsync(url, fileName);
stopWatch.Stop();
_logger.Debug("Downloading Completed. took {0:0}s", stopWatch.Elapsed.Seconds);
}
}
catch (WebException e)
{
_logger.Warn("Failed to get response from: {0} {1}", url, e.Message);
if (File.Exists(fileName))
{
File.Delete(fileName);
}
throw;
}
catch (Exception e)
{
_logger.Warn(e, "Failed to get response from: " + url);
if (File.Exists(fileName))
{
File.Delete(fileName);
}
throw;
} }
} }
protected virtual System.Net.Http.HttpClient GetClient(HttpUri uri, HttpProxySettings requestProxy) protected virtual IWebProxy GetProxy(HttpUri uri)
{ {
var proxySettings = requestProxy ?? _proxySettingsProvider.GetProxySettings(uri); IWebProxy proxy = null;
var key = proxySettings?.Key ?? NO_PROXY_KEY; var proxySettings = _proxySettingsProvider.GetProxySettings(uri);
return _httpClientCache.Get(key, () => CreateHttpClient(proxySettings));
}
protected virtual System.Net.Http.HttpClient CreateHttpClient(HttpProxySettings proxySettings)
{
var handler = new SocketsHttpHandler()
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Brotli,
UseCookies = false, // sic - we don't want to use a shared cookie container
AllowAutoRedirect = false,
Credentials = GetCredentialCache(),
PreAuthenticate = true,
MaxConnectionsPerServer = 12,
ConnectCallback = onConnect,
PooledConnectionLifetime = TimeSpan.FromMinutes(10),
SslOptions = new SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = _certificateValidationService.ShouldByPassValidationError
}
};
if (proxySettings != null) if (proxySettings != null)
{ {
handler.Proxy = _createManagedWebProxy.GetWebProxy(proxySettings); proxy = _createManagedWebProxy.GetWebProxy(proxySettings);
} }
var client = new System.Net.Http.HttpClient(handler) return proxy;
{
Timeout = Timeout.InfiniteTimeSpan
};
return client;
} }
protected virtual void AddRequestHeaders(HttpRequestMessage webRequest, HttpHeader headers) protected virtual void AddRequestHeaders(HttpWebRequest webRequest, HttpHeader headers)
{ {
foreach (var header in headers) foreach (var header in headers)
{ {
switch (header.Key) switch (header.Key)
{ {
case "Accept": case "Accept":
webRequest.Headers.Accept.ParseAdd(header.Value); webRequest.Accept = header.Value;
break; break;
case "Connection": case "Connection":
webRequest.Headers.Connection.Clear(); webRequest.Connection = header.Value;
webRequest.Headers.Connection.Add(header.Value);
break; break;
case "Content-Length": case "Content-Length":
AddContentHeader(webRequest, "Content-Length", header.Value); webRequest.ContentLength = Convert.ToInt64(header.Value);
break; break;
case "Content-Type": case "Content-Type":
AddContentHeader(webRequest, "Content-Type", header.Value); webRequest.ContentType = header.Value;
break; break;
case "Date": case "Date":
webRequest.Headers.Remove("Date"); webRequest.Date = HttpHeader.ParseDateTime(header.Value);
webRequest.Headers.Date = HttpHeader.ParseDateTime(header.Value);
break; break;
case "Expect": case "Expect":
webRequest.Headers.Expect.ParseAdd(header.Value); webRequest.Expect = header.Value;
break; break;
case "Host": case "Host":
webRequest.Headers.Host = header.Value; webRequest.Host = header.Value;
break; break;
case "If-Modified-Since": case "If-Modified-Since":
webRequest.Headers.IfModifiedSince = HttpHeader.ParseDateTime(header.Value); webRequest.IfModifiedSince = HttpHeader.ParseDateTime(header.Value);
break; break;
case "Range": case "Range":
throw new NotImplementedException(); throw new NotImplementedException();
case "Referer": case "Referer":
webRequest.Headers.Add("Referer", header.Value); webRequest.Referer = header.Value;
break; break;
case "Transfer-Encoding": case "Transfer-Encoding":
webRequest.Headers.TransferEncoding.ParseAdd(header.Value); webRequest.TransferEncoding = header.Value;
break; break;
case "User-Agent": case "User-Agent":
webRequest.Headers.UserAgent.Clear(); webRequest.UserAgent = header.Value;
webRequest.Headers.UserAgent.ParseAdd(header.Value);
break; break;
case "Proxy-Connection": case "Proxy-Connection":
throw new NotImplementedException(); throw new NotImplementedException();
@@ -246,84 +239,5 @@ namespace NzbDrone.Common.Http.Dispatchers
} }
} }
} }
private void AddContentHeader(HttpRequestMessage request, string header, string value)
{
var headers = request.Content?.Headers;
if (headers == null)
{
return;
}
headers.Remove(header);
headers.Add(header, value);
}
private CredentialCache GetCredentialCache()
{
return _credentialCache.Get("credentialCache", () => new CredentialCache());
}
private static async ValueTask<Stream> 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.
if (useIPv6)
{
try
{
var localToken = cancellationToken;
if (!hasResolvedIPv6Availability)
{
// to make things move fast, use a very low timeout for the initial ipv6 attempt.
var quickFailCts = new CancellationTokenSource(connection_establish_timeout);
var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, quickFailCts.Token);
localToken = linkedTokenSource.Token;
}
return await attemptConnection(AddressFamily.InterNetworkV6, context, localToken);
}
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;
}
finally
{
hasResolvedIPv6Availability = true;
}
}
// fallback to IPv4.
return await attemptConnection(AddressFamily.InterNetwork, context, cancellationToken);
}
private static async ValueTask<Stream> attemptConnection(AddressFamily addressFamily, SocketsHttpConnectionContext context, CancellationToken cancellationToken)
{
// The following socket constructor will create a dual-mode socket on systems where IPV6 is available.
var socket = new Socket(addressFamily, SocketType.Stream, ProtocolType.Tcp)
{
// Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios.
NoDelay = true
};
try
{
await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false);
// The stream should take the ownership of the underlying socket,
// closing it when it's disposed.
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
} }
} }

View File

@@ -0,0 +1,15 @@
using System;
using System.Net;
namespace NzbDrone.Common.Http
{
public class GZipWebClient : WebClient
{
protected override WebRequest GetWebRequest(Uri address)
{
var request = (HttpWebRequest)base.GetWebRequest(address);
request.AutomaticDecompression = DecompressionMethods.GZip;
return request;
}
}
}

View File

@@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
@@ -87,21 +86,13 @@ namespace NzbDrone.Common.Http
} }
// 302 or 303 should default to GET on redirect even if POST on original // 302 or 303 should default to GET on redirect even if POST on original
if (RequestRequiresForceGet(response.StatusCode, response.Request.Method)) if (response.StatusCode == HttpStatusCode.Redirect || response.StatusCode == HttpStatusCode.RedirectMethod)
{ {
request.Method = HttpMethod.Get; request.Method = HttpMethod.Get;
request.ContentData = null; request.ContentData = null;
} }
// Save to add to final response response = await ExecuteRequestAsync(request, cookieContainer);
var responseCookies = response.Cookies;
// Update cookiecontainer for next request with any cookies recieved on last request
var responseContainer = HandleRedirectCookies(request, response);
response = await ExecuteRequestAsync(request, responseContainer);
response.Cookies.Add(responseCookies);
} }
while (response.HasHttpRedirect); while (response.HasHttpRedirect);
} }
@@ -111,14 +102,11 @@ namespace NzbDrone.Common.Http
_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.Headers["Location"]);
} }
if (!request.SuppressHttpError && response.HasHttpError && (request.SuppressHttpErrorStatusCodes == null || !request.SuppressHttpErrorStatusCodes.Contains(response.StatusCode))) if (!request.SuppressHttpError && response.HasHttpError)
{ {
if (request.LogHttpError) _logger.Warn("HTTP Error - {0}", response);
{
_logger.Warn("HTTP Error - {0}", response);
}
if (response.StatusCode == HttpStatusCode.TooManyRequests) if ((int)response.StatusCode == 429)
{ {
throw new TooManyRequestsException(request, response); throw new TooManyRequestsException(request, response);
} }
@@ -136,21 +124,6 @@ namespace NzbDrone.Common.Http
return ExecuteAsync(request).GetAwaiter().GetResult(); return ExecuteAsync(request).GetAwaiter().GetResult();
} }
private static bool RequestRequiresForceGet(HttpStatusCode statusCode, HttpMethod requestMethod)
{
switch (statusCode)
{
case HttpStatusCode.Moved:
case HttpStatusCode.Found:
case HttpStatusCode.MultipleChoices:
return requestMethod == HttpMethod.Post;
case HttpStatusCode.SeeOther:
return requestMethod != HttpMethod.Get && requestMethod != HttpMethod.Head;
default:
return false;
}
}
private async Task<HttpResponse> ExecuteRequestAsync(HttpRequest request, CookieContainer cookieContainer) private async Task<HttpResponse> ExecuteRequestAsync(HttpRequest request, CookieContainer cookieContainer)
{ {
foreach (var interceptor in _requestInterceptors) foreach (var interceptor in _requestInterceptors)
@@ -167,6 +140,8 @@ namespace NzbDrone.Common.Http
var stopWatch = Stopwatch.StartNew(); var stopWatch = Stopwatch.StartNew();
PrepareRequestCookies(request, cookieContainer);
var response = await _httpDispatcher.GetResponseAsync(request, cookieContainer); var response = await _httpDispatcher.GetResponseAsync(request, cookieContainer);
HandleResponseCookies(response, cookieContainer); HandleResponseCookies(response, cookieContainer);
@@ -233,125 +208,52 @@ namespace NzbDrone.Common.Http
} }
} }
private CookieContainer HandleRedirectCookies(HttpRequest request, HttpResponse response) private void PrepareRequestCookies(HttpRequest request, CookieContainer cookieContainer)
{ {
var sourceContainer = new CookieContainer(); // Don't collect persistnet cookies for intermediate/redirected urls.
var responseCookies = response.GetCookies(); /*lock (_cookieContainerCache)
if (responseCookies.Count != 0)
{ {
foreach (var pair in responseCookies) var presistentContainer = _cookieContainerCache.Get("container", () => new CookieContainer());
{ var persistentCookies = presistentContainer.GetCookies((Uri)request.Url);
Cookie cookie; var existingCookies = cookieContainer.GetCookies((Uri)request.Url);
if (pair.Value == null)
{
cookie = new Cookie(pair.Key, "", "/")
{
Expires = DateTime.Now.AddDays(-1)
};
}
else
{
cookie = new Cookie(pair.Key, pair.Value, "/")
{
// Use Now rather than UtcNow to work around Mono cookie expiry bug.
// See https://gist.github.com/ta264/7822b1424f72e5b4c961
Expires = DateTime.Now.AddHours(1)
};
}
sourceContainer.Add((Uri)request.Url, cookie); cookieContainer.Add(persistentCookies);
} cookieContainer.Add(existingCookies);
} }*/
return sourceContainer;
} }
private void HandleResponseCookies(HttpResponse response, CookieContainer container) private void HandleResponseCookies(HttpResponse response, CookieContainer cookieContainer)
{ {
foreach (Cookie cookie in container.GetAllCookies())
{
cookie.Expired = true;
}
var cookieHeaders = response.GetCookieHeaders(); var cookieHeaders = response.GetCookieHeaders();
if (cookieHeaders.Empty()) if (cookieHeaders.Empty())
{ {
return; return;
} }
AddCookiesToContainer(response.Request.Url, cookieHeaders, container);
if (response.Request.StoreResponseCookie) if (response.Request.StoreResponseCookie)
{ {
lock (_cookieContainerCache) lock (_cookieContainerCache)
{ {
var persistentCookieContainer = _cookieContainerCache.Get("container", () => new CookieContainer()); var persistentCookieContainer = _cookieContainerCache.Get("container", () => new CookieContainer());
AddCookiesToContainer(response.Request.Url, cookieHeaders, persistentCookieContainer); foreach (var cookieHeader in cookieHeaders)
} {
} try
} {
persistentCookieContainer.SetCookies((Uri)response.Request.Url, cookieHeader);
private void AddCookiesToContainer(HttpUri url, string[] cookieHeaders, CookieContainer container) }
{ catch (Exception ex)
foreach (var cookieHeader in cookieHeaders) {
{ _logger.Debug(ex, "Invalid cookie in {0}", response.Request.Url);
try }
{ }
container.SetCookies((Uri)url, cookieHeader);
}
catch (Exception ex)
{
_logger.Debug(ex, "Invalid cookie in {0}", url);
} }
} }
} }
public async Task DownloadFileAsync(string url, string fileName) public async Task DownloadFileAsync(string url, string fileName)
{ {
var fileNamePart = fileName + ".part"; await _httpDispatcher.DownloadFileAsync(url, fileName);
try
{
var fileInfo = new FileInfo(fileName);
if (fileInfo.Directory != null && !fileInfo.Directory.Exists)
{
fileInfo.Directory.Create();
}
_logger.Debug("Downloading [{0}] to [{1}]", url, fileName);
var stopWatch = Stopwatch.StartNew();
using (var fileStream = new FileStream(fileNamePart, FileMode.Create, FileAccess.ReadWrite))
{
var request = new HttpRequest(url);
request.AllowAutoRedirect = true;
request.ResponseStream = fileStream;
var response = await GetAsync(request);
if (response.Headers.ContentType != null && response.Headers.ContentType.Contains("text/html"))
{
throw new HttpException(request, response, "Site responded with html content.");
}
}
stopWatch.Stop();
if (File.Exists(fileName))
{
File.Delete(fileName);
}
File.Move(fileNamePart, fileName);
_logger.Debug("Downloading Completed. took {0:0}s", stopWatch.Elapsed.Seconds);
}
finally
{
if (File.Exists(fileNamePart))
{
File.Delete(fileNamePart);
}
}
} }
public void DownloadFile(string url, string fileName) public void DownloadFile(string url, string fileName)

View File

@@ -4,27 +4,11 @@ using System.Collections.Generic;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Net;
using System.Net.Http.Headers;
using System.Text; using System.Text;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
namespace NzbDrone.Common.Http namespace NzbDrone.Common.Http
{ {
public static class WebHeaderCollectionExtensions
{
public static NameValueCollection ToNameValueCollection(this HttpHeaders headers)
{
var result = new NameValueCollection();
foreach (var header in headers)
{
result.Add(header.Key, header.Value.ConcatToString(";"));
}
return result;
}
}
public class HttpHeader : NameValueCollection, IEnumerable<KeyValuePair<string, string>>, IEnumerable public class HttpHeader : NameValueCollection, IEnumerable<KeyValuePair<string, string>>, IEnumerable
{ {
public HttpHeader(NameValueCollection headers) public HttpHeader(NameValueCollection headers)
@@ -32,11 +16,6 @@ namespace NzbDrone.Common.Http
{ {
} }
public HttpHeader(HttpHeaders headers)
: base(headers.ToNameValueCollection())
{
}
public HttpHeader() public HttpHeader()
{ {
} }
@@ -128,30 +107,6 @@ namespace NzbDrone.Common.Http
} }
} }
public string ContentEncoding
{
get
{
return GetSingleValue("Content-Encoding");
}
set
{
SetSingleValue("Content-Encoding", value);
}
}
public string Vary
{
get
{
return GetSingleValue("Vary");
}
set
{
SetSingleValue("Vary", value);
}
}
public string UserAgent public string UserAgent
{ {
get get

View File

@@ -1,12 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Text; using System.Text;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http.Proxy;
namespace NzbDrone.Common.Http namespace NzbDrone.Common.Http
{ {
@@ -14,13 +12,11 @@ namespace NzbDrone.Common.Http
{ {
public HttpRequest(string url, HttpAccept httpAccept = null) public HttpRequest(string url, HttpAccept httpAccept = null)
{ {
Method = HttpMethod.Get;
Url = new HttpUri(url); Url = new HttpUri(url);
Headers = new HttpHeader(); Headers = new HttpHeader();
Method = HttpMethod.Get; Method = HttpMethod.Get;
ConnectionKeepAlive = true; ConnectionKeepAlive = true;
AllowAutoRedirect = true; AllowAutoRedirect = true;
LogHttpError = true;
Cookies = new Dictionary<string, string>(); Cookies = new Dictionary<string, string>();
if (!RuntimeInfo.IsProduction) if (!RuntimeInfo.IsProduction)
@@ -38,23 +34,19 @@ namespace NzbDrone.Common.Http
public HttpMethod Method { get; set; } public HttpMethod Method { get; set; }
public HttpHeader Headers { get; set; } public HttpHeader Headers { get; set; }
public Encoding Encoding { get; set; } public Encoding Encoding { get; set; }
public HttpProxySettings ProxySettings { get; set; } public IWebProxy Proxy { get; set; }
public byte[] ContentData { get; set; } public byte[] ContentData { get; set; }
public string ContentSummary { get; set; } public string ContentSummary { get; set; }
public ICredentials Credentials { get; set; }
public bool SuppressHttpError { get; set; } public bool SuppressHttpError { get; set; }
public IEnumerable<HttpStatusCode> SuppressHttpErrorStatusCodes { get; set; }
public bool UseSimplifiedUserAgent { get; set; } public bool UseSimplifiedUserAgent { get; set; }
public bool AllowAutoRedirect { get; set; } public bool AllowAutoRedirect { get; set; }
public bool ConnectionKeepAlive { get; set; } public bool ConnectionKeepAlive { get; set; }
public bool LogResponseContent { get; set; } public bool LogResponseContent { get; set; }
public bool LogHttpError { get; set; }
public Dictionary<string, string> Cookies { get; private set; } public Dictionary<string, string> Cookies { get; private set; }
public bool StoreRequestCookie { get; set; } public bool StoreRequestCookie { get; set; }
public bool StoreResponseCookie { get; set; } public bool StoreResponseCookie { get; set; }
public TimeSpan RequestTimeout { get; set; } public TimeSpan RequestTimeout { get; set; }
public TimeSpan RateLimit { get; set; } public TimeSpan RateLimit { get; set; }
public Stream ResponseStream { get; set; }
public override string ToString() public override string ToString()
{ {
@@ -111,5 +103,12 @@ namespace NzbDrone.Common.Http
return encoding.GetString(ContentData); return encoding.GetString(ContentData);
} }
} }
public void AddBasicAuthentication(string username, string password)
{
var authInfo = Convert.ToBase64String(Encoding.GetEncoding("ISO-8859-1").GetBytes($"{username}:{password}"));
Headers.Set("Authorization", "Basic " + authInfo);
}
} }
} }

View File

@@ -21,13 +21,12 @@ namespace NzbDrone.Common.Http
public Dictionary<string, string> Segments { get; private set; } public Dictionary<string, string> Segments { get; private set; }
public HttpHeader Headers { get; private set; } public HttpHeader Headers { get; private set; }
public bool SuppressHttpError { get; set; } public bool SuppressHttpError { get; set; }
public bool LogHttpError { get; set; }
public bool UseSimplifiedUserAgent { get; set; } public bool UseSimplifiedUserAgent { get; set; }
public bool AllowAutoRedirect { get; set; } public bool AllowAutoRedirect { get; set; }
public bool ConnectionKeepAlive { get; set; } public bool ConnectionKeepAlive { get; set; }
public TimeSpan RateLimit { get; set; } public TimeSpan RateLimit { get; set; }
public bool LogResponseContent { get; set; } public bool LogResponseContent { get; set; }
public ICredentials NetworkCredential { get; set; } public NetworkCredential NetworkCredential { get; set; }
public Dictionary<string, string> Cookies { get; private set; } public Dictionary<string, string> Cookies { get; private set; }
public bool StoreRequestCookie { get; set; } public bool StoreRequestCookie { get; set; }
public bool StoreResponseCookie { get; set; } public bool StoreResponseCookie { get; set; }
@@ -47,7 +46,6 @@ namespace NzbDrone.Common.Http
Headers = new HttpHeader(); Headers = new HttpHeader();
Cookies = new Dictionary<string, string>(); Cookies = new Dictionary<string, string>();
FormData = new List<HttpFormData>(); FormData = new List<HttpFormData>();
LogHttpError = true;
} }
public HttpRequestBuilder(bool useHttps, string host, int port, string urlBase = null) public HttpRequestBuilder(bool useHttps, string host, int port, string urlBase = null)
@@ -108,7 +106,6 @@ namespace NzbDrone.Common.Http
request.Method = Method; request.Method = Method;
request.Encoding = Encoding; request.Encoding = Encoding;
request.SuppressHttpError = SuppressHttpError; request.SuppressHttpError = SuppressHttpError;
request.LogHttpError = LogHttpError;
request.UseSimplifiedUserAgent = UseSimplifiedUserAgent; request.UseSimplifiedUserAgent = UseSimplifiedUserAgent;
request.AllowAutoRedirect = AllowAutoRedirect; request.AllowAutoRedirect = AllowAutoRedirect;
request.StoreRequestCookie = StoreRequestCookie; request.StoreRequestCookie = StoreRequestCookie;
@@ -116,7 +113,13 @@ namespace NzbDrone.Common.Http
request.ConnectionKeepAlive = ConnectionKeepAlive; request.ConnectionKeepAlive = ConnectionKeepAlive;
request.RateLimit = RateLimit; request.RateLimit = RateLimit;
request.LogResponseContent = LogResponseContent; request.LogResponseContent = LogResponseContent;
request.Credentials = NetworkCredential;
if (NetworkCredential != null)
{
var authInfo = NetworkCredential.UserName + ":" + NetworkCredential.Password;
authInfo = Convert.ToBase64String(Encoding.GetEncoding("ISO-8859-1").GetBytes(authInfo));
request.Headers.Set("Authorization", "Basic " + authInfo);
}
foreach (var header in Headers) foreach (var header in Headers)
{ {
@@ -209,7 +212,7 @@ namespace NzbDrone.Common.Http
} }
else else
{ {
summary.AppendFormat("\r\n{0}={1}", formData.Name, Encoding.GetString(formData.ContentData)); summary.AppendFormat("\r\n{0}={1}", formData.Name, Encoding.UTF8.GetString(formData.ContentData));
} }
} }
@@ -229,9 +232,9 @@ namespace NzbDrone.Common.Http
} }
else else
{ {
var parameters = FormData.Select(v => string.Format("{0}={1}", v.Name, Uri.EscapeDataString(Encoding.GetString(v.ContentData)))); var parameters = FormData.Select(v => string.Format("{0}={1}", v.Name, Uri.EscapeDataString(Encoding.UTF8.GetString(v.ContentData))));
var urlencoded = string.Join("&", parameters); var urlencoded = string.Join("&", parameters);
var body = Encoding.GetBytes(urlencoded); var body = Encoding.UTF8.GetBytes(urlencoded);
request.Headers.ContentType = "application/x-www-form-urlencoded"; request.Headers.ContentType = "application/x-www-form-urlencoded";
request.SetContent(body); request.SetContent(body);
@@ -403,7 +406,7 @@ namespace NzbDrone.Common.Http
FormData.Add(new HttpFormData FormData.Add(new HttpFormData
{ {
Name = key, Name = key,
ContentData = Encoding.GetBytes(Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)) ContentData = Encoding.UTF8.GetBytes(Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture))
}); });
return this; return this;

View File

@@ -64,11 +64,10 @@ namespace NzbDrone.Common.Http
public bool HasHttpError => (int)StatusCode >= 400; public bool HasHttpError => (int)StatusCode >= 400;
public bool HasHttpRedirect => StatusCode == HttpStatusCode.Moved || public bool HasHttpRedirect => StatusCode == HttpStatusCode.Moved ||
StatusCode == HttpStatusCode.Found || StatusCode == HttpStatusCode.MovedPermanently ||
StatusCode == HttpStatusCode.SeeOther || StatusCode == HttpStatusCode.RedirectMethod ||
StatusCode == HttpStatusCode.TemporaryRedirect || StatusCode == HttpStatusCode.TemporaryRedirect ||
StatusCode == HttpStatusCode.MultipleChoices || StatusCode == HttpStatusCode.Found ||
StatusCode == HttpStatusCode.PermanentRedirect ||
Headers.ContainsKey("Refresh"); Headers.ContainsKey("Refresh");
public string RedirectUrl public string RedirectUrl
@@ -89,13 +88,13 @@ namespace NzbDrone.Common.Http
if (match.Success) if (match.Success)
{ {
return (Request.Url + new HttpUri(match.Groups[2].Value)).FullUri; return (Request.Url += new HttpUri(match.Groups[2].Value)).FullUri;
} }
return string.Empty; return string.Empty;
} }
return (Request.Url + new HttpUri(newUrl)).FullUri; return (Request.Url += new HttpUri(newUrl)).FullUri;
} }
} }
@@ -118,7 +117,7 @@ namespace NzbDrone.Common.Http
public override string ToString() 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 = string.Format("Res: [{0}] {1}: {2}.{3}", Request.Method, Request.Url, (int)StatusCode, StatusCode);
if (HasHttpError && Headers.ContentType.IsNotNullOrWhiteSpace() && !Headers.ContentType.Equals("text/html", StringComparison.InvariantCultureIgnoreCase)) if (HasHttpError && Headers.ContentType.IsNotNullOrWhiteSpace() && !Headers.ContentType.Equals("text/html", StringComparison.InvariantCultureIgnoreCase))
{ {

View File

@@ -8,7 +8,7 @@ namespace NzbDrone.Common.Http
{ {
public class HttpUri : IEquatable<HttpUri> public class HttpUri : IEquatable<HttpUri>
{ {
private static readonly Regex RegexUri = new Regex(@"^(?:(?<scheme>[a-z]+):)?(?://(?<host>[-_A-Z0-9.]+|\[[[A-F0-9:]+\])(?::(?<port>[0-9]{1,5}))?)?(?<path>(?:(?:(?<=^)|/+)[^/?#\r\n]+)+/*|/+)?(?:\?(?<query>[^#\r\n]*))?(?:\#(?<fragment>.*))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex RegexUri = new Regex(@"^(?:(?<scheme>[a-z]+):)?(?://(?<host>[-_A-Z0-9.]+)(?::(?<port>[0-9]{1,5}))?)?(?<path>(?:(?:(?<=^)|/+)[^/?#\r\n]+)+/*|/+)?(?:\?(?<query>[^#\r\n]*))?(?:\#(?<fragment>.*))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private readonly string _uri; private readonly string _uri;
public string FullUri => _uri; public string FullUri => _uri;
@@ -70,8 +70,6 @@ namespace NzbDrone.Common.Http
private void Parse() private void Parse()
{ {
var parseSuccess = Uri.TryCreate(_uri, UriKind.RelativeOrAbsolute, out var uri);
var match = RegexUri.Match(_uri); var match = RegexUri.Match(_uri);
var scheme = match.Groups["scheme"]; var scheme = match.Groups["scheme"];
@@ -81,7 +79,7 @@ namespace NzbDrone.Common.Http
var query = match.Groups["query"]; var query = match.Groups["query"];
var fragment = match.Groups["fragment"]; var fragment = match.Groups["fragment"];
if (!parseSuccess || (scheme.Success && !host.Success && path.Success)) if (!match.Success || (scheme.Success && !host.Success && path.Success))
{ {
throw new ArgumentException("Uri didn't match expected pattern: " + _uri); throw new ArgumentException("Uri didn't match expected pattern: " + _uri);
} }

View File

@@ -1,4 +1,4 @@
using System; using System;
namespace NzbDrone.Common.Http namespace NzbDrone.Common.Http
{ {
@@ -25,11 +25,5 @@ namespace NzbDrone.Common.Http
} }
} }
} }
public TooManyRequestsException(HttpRequest request, HttpResponse response, TimeSpan retryWait)
: base(request, response)
{
RetryAfter = retryWait;
}
} }
} }

View File

@@ -1,103 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Xml.Linq;
using NLog;
using NzbDrone.Common.Instrumentation;
namespace NzbDrone.Common.Http
{
public class XmlRpcRequestBuilder : HttpRequestBuilder
{
public static string XmlRpcContentType = "text/xml";
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(XmlRpcRequestBuilder));
public string XmlMethod { get; private set; }
public List<object> XmlParameters { get; private set; }
public XmlRpcRequestBuilder(string baseUrl)
: base(baseUrl)
{
Method = HttpMethod.Post;
XmlParameters = new List<object>();
}
public XmlRpcRequestBuilder(bool useHttps, string host, int port, string urlBase = null)
: this(BuildBaseUrl(useHttps, host, port, urlBase))
{
}
public override HttpRequestBuilder Clone()
{
var clone = base.Clone() as XmlRpcRequestBuilder;
clone.XmlParameters = new List<object>(XmlParameters);
return clone;
}
public XmlRpcRequestBuilder Call(string method, params object[] parameters)
{
var clone = Clone() as XmlRpcRequestBuilder;
clone.XmlMethod = method;
clone.XmlParameters = parameters.ToList();
return clone;
}
protected override void Apply(HttpRequest request)
{
base.Apply(request);
request.Headers.ContentType = XmlRpcContentType;
var methodCallElements = new List<XElement> { new XElement("methodName", XmlMethod) };
if (XmlParameters.Any())
{
var argElements = XmlParameters.Select(x => new XElement("param", ConvertParameter(x))).ToList();
var paramsElement = new XElement("params", argElements);
methodCallElements.Add(paramsElement);
}
var message = new XDocument(
new XDeclaration("1.0", "utf-8", "yes"),
new XElement("methodCall", methodCallElements));
var body = message.ToString();
Logger.Debug($"Executing remote method: {XmlMethod}");
Logger.Trace($"methodCall {XmlMethod} body:\n{body}");
request.SetContent(body);
}
private static XElement ConvertParameter(object value)
{
XElement data;
if (value is string s)
{
data = new XElement("string", s);
}
else if (value is List<string> l)
{
data = new XElement("array", new XElement("data", l.Select(x => new XElement("value", new XElement("string", x)))));
}
else if (value is int i)
{
data = new XElement("int", i);
}
else if (value is byte[] bytes)
{
data = new XElement("base64", Convert.ToBase64String(bytes));
}
else
{
throw new InvalidOperationException($"Unhandled argument type {value.GetType().Name}");
}
return new XElement("value", data);
}
}
}

Some files were not shown because too many files have changed in this diff Show More