Compare commits

..

1 Commits

Author SHA1 Message Date
Bogdan
2fbeec8291 New: Add Year specification to Auto Tagging
(cherry picked from commit c69b5fc72aa681b954550d578b6f96f44b708a5f)
2023-08-01 23:59:18 +00:00
287 changed files with 2566 additions and 5783 deletions

3
.gitattributes vendored
View File

@@ -3,7 +3,8 @@
# Explicitly set bash scripts to have unix endings
*.sh text eol=lf
distribution/osx/Readarr text eol=lf
distribution/debian/* text eol=lf
macOS/Readarr text eol=lf
# Custom for Visual Studio
*.cs diff=csharp

View File

@@ -30,6 +30,7 @@ Note that only one type of a given book is supported. If you want both an audiob
[![Wiki](https://img.shields.io/badge/servarr-wiki-181717.svg?maxAge=60)](https://wiki.servarr.com/readarr)
[![Discord](https://img.shields.io/badge/discord-chat-7289DA.svg?maxAge=60)](https://readarr.com/discord)
[![Reddit](https://img.shields.io/badge/reddit-discussion-FF4500.svg?maxAge=60)](https://www.reddit.com/r/readarr)
Note: GitHub Issues are for Bugs and Feature Requests Only

View File

@@ -9,13 +9,13 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '0.3.4'
majorVersion: '0.3.1'
minorVersion: $[counter('minorVersion', 1)]
readarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(readarrVersion)'
sentryOrg: 'servarr'
sentryUrl: 'https://sentry.servarr.com'
dotnetVersion: '6.0.413'
dotnetVersion: '6.0.408'
nodeVersion: '16.X'
innoVersion: '6.2.0'
windowsImage: 'windows-2022'
@@ -27,10 +27,6 @@ trigger:
include:
- develop
- master
paths:
exclude:
- .github
- src/Readarr.Api.*/openapi.json
pr:
branches:
@@ -38,37 +34,82 @@ pr:
- develop
paths:
exclude:
- .github
- src/NzbDrone.Core/Localization/Core
- src/Readarr.Api.*/openapi.json
stages:
- stage: Setup
displayName: Setup
- stage: Build_Backend_Windows
displayName: Build Backend
dependsOn: []
jobs:
- job:
displayName: Build Variables
- job: Backend
strategy:
matrix:
Windows:
osName: 'Windows'
imageName: ${{ variables.windowsImage }}
enableAnalysis: 'false'
pool:
vmImage: ${{ variables.linuxImage }}
vmImage: $(imageName)
variables:
# Disable stylecop here - linting errors get caught by the analyze task
EnableAnalyzers: $(enableAnalysis)
steps:
# Set the build name properly. The 'name' property won't recursively expand so hack here:
- bash: echo "##vso[build.updatebuildnumber]$READARRVERSION"
displayName: Set Build Name
- checkout: self
submodules: true
fetchDepth: 1
- task: UseDotNet@2
displayName: 'Install .net core'
inputs:
version: $(dotnetVersion)
- bash: |
if [[ $BUILD_REASON == "PullRequest" ]]; then
git diff origin/develop...HEAD --name-only | grep -E "^(src/|azure-pipelines.yml)"
echo $? > not_backend_update
else
echo 0 > not_backend_update
SDK_PATH="${AGENT_TOOLSDIRECTORY}/dotnet/sdk/${DOTNETVERSION}"
BUNDLEDVERSIONS="${SDK_PATH}/Microsoft.NETCoreSdk.BundledVersions.props"
if ! grep -q freebsd-x64 $BUNDLEDVERSIONS; then
sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64;linux-x86/' $BUNDLEDVERSIONS
fi
cat not_backend_update
displayName: Check for Backend File Changes
- publish: not_backend_update
artifact: not_backend_update
displayName: Publish update type
- stage: Build_Backend
displayName: Build Backend
dependsOn: Setup
displayName: Extra Platform Support
- task: Cache@2
inputs:
key: 'nuget | "$(Agent.OS)" | $(Build.SourcesDirectory)/src/Directory.Packages.props'
path: $(nugetCacheFolder)
displayName: Cache NuGet packages
- bash: ./build.sh --backend --enable-bsd
displayName: Build Readarr Backend
env:
NUGET_PACKAGES: $(nugetCacheFolder)
- powershell: Get-ChildItem _output\net6.0*,_output\*.Update\* -Recurse | Where { $_.Fullname -notlike "*\publish\*" -and $_.attributes -notlike "*directory*" } | Remove-Item
displayName: Clean up intermediate output
- publish: $(outputFolder)
artifact: '$(osName)Backend'
displayName: Publish Backend
- publish: '$(testsFolder)/net6.0/win-x64/publish'
artifact: win-x64-tests
displayName: Publish win-x64 Test Package
- publish: '$(testsFolder)/net6.0/linux-x64/publish'
artifact: linux-x64-tests
displayName: Publish linux-x64 Test Package
- publish: '$(testsFolder)/net6.0/linux-x86/publish'
artifact: linux-x86-tests
displayName: Publish linux-x86 Test Package
- publish: '$(testsFolder)/net6.0/linux-musl-x64/publish'
artifact: linux-musl-x64-tests
displayName: Publish linux-musl-x64 Test Package
- publish: '$(testsFolder)/net6.0/freebsd-x64/publish'
artifact: freebsd-x64-tests
displayName: Publish freebsd-x64 Test Package
- publish: '$(testsFolder)/net6.0/osx-x64/publish'
artifact: osx-x64-tests
displayName: Publish osx-x64 Test Package
- stage: Build_Backend_Other
displayName: Build Backend (Other OS)
dependsOn: []
jobs:
- job: Backend
strategy:
@@ -81,10 +122,6 @@ stages:
osName: 'Mac'
imageName: ${{ variables.macImage }}
enableAnalysis: 'false'
Windows:
osName: 'Windows'
imageName: ${{ variables.windowsImage }}
enableAnalysis: 'false'
pool:
vmImage: $(imageName)
@@ -100,17 +137,22 @@ stages:
inputs:
version: $(dotnetVersion)
- bash: |
BUNDLEDVERSIONS=${AGENT_TOOLSDIRECTORY}/dotnet/sdk/${DOTNETVERSION}/Microsoft.NETCoreSdk.BundledVersions.props
echo $BUNDLEDVERSIONS
if grep -q freebsd-x64 $BUNDLEDVERSIONS; then
echo "Extra platforms already enabled"
else
echo "Enabling extra platform support"
SDK_PATH="${AGENT_TOOLSDIRECTORY}/dotnet/sdk/${DOTNETVERSION}"
BUNDLEDVERSIONS="${SDK_PATH}/Microsoft.NETCoreSdk.BundledVersions.props"
if ! grep -q freebsd-x64 $BUNDLEDVERSIONS; then
sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64;linux-x86/' $BUNDLEDVERSIONS
fi
displayName: Enable Extra Platform Support
displayName: Extra Platform Support
- task: Cache@2
inputs:
key: 'nuget | "$(Agent.OS)" | $(Build.SourcesDirectory)/src/Directory.Packages.props'
path: $(nugetCacheFolder)
displayName: Cache NuGet packages
- bash: ./build.sh --backend --enable-extra-platforms
displayName: Build Readarr Backend
env:
NUGET_PACKAGES: $(nugetCacheFolder)
- bash: |
find ${OUTPUTFOLDER} -type f ! -path "*/publish/*" -exec rm -rf {} \;
find ${OUTPUTFOLDER} -depth -empty -type d -exec rm -r "{}" \;
@@ -118,38 +160,10 @@ stages:
find ${TESTSFOLDER} -depth -empty -type d -exec rm -r "{}" \;
displayName: Clean up intermediate output
condition: and(succeeded(), ne(variables['osName'], 'Windows'))
- publish: $(outputFolder)
artifact: '$(osName)Backend'
displayName: Publish Backend
condition: and(succeeded(), eq(variables['osName'], 'Windows'))
- publish: '$(testsFolder)/net6.0/win-x64/publish'
artifact: win-x64-tests
displayName: Publish win-x64 Test Package
condition: and(succeeded(), eq(variables['osName'], 'Windows'))
- publish: '$(testsFolder)/net6.0/linux-x64/publish'
artifact: linux-x64-tests
displayName: Publish linux-x64 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'))
- publish: '$(testsFolder)/net6.0/linux-musl-x64/publish'
artifact: linux-musl-x64-tests
displayName: Publish linux-musl-x64 Test Package
condition: and(succeeded(), eq(variables['osName'], 'Windows'))
- publish: '$(testsFolder)/net6.0/freebsd-x64/publish'
artifact: freebsd-x64-tests
displayName: Publish freebsd-x64 Test Package
condition: and(succeeded(), eq(variables['osName'], 'Windows'))
- publish: '$(testsFolder)/net6.0/osx-x64/publish'
artifact: osx-x64-tests
displayName: Publish osx-x64 Test Package
condition: and(succeeded(), eq(variables['osName'], 'Windows'))
- stage: Build_Frontend
displayName: Frontend
dependsOn: Setup
dependsOn: []
jobs:
- job: Build
strategy:
@@ -178,6 +192,7 @@ stages:
key: 'yarn | "$(osName)" | yarn.lock'
restoreKeys: |
yarn | "$(osName)"
yarn
path: $(yarnCacheFolder)
displayName: Cache Yarn packages
- bash: ./build.sh --frontend
@@ -189,10 +204,10 @@ stages:
artifact: '$(osName)Frontend'
displayName: Publish Frontend
condition: and(succeeded(), eq(variables['osName'], 'Windows'))
- stage: Installer
dependsOn:
- Build_Backend
- Build_Backend_Windows
- Build_Frontend
jobs:
- job: Windows_Installer
@@ -216,8 +231,8 @@ stages:
displayName: Fetch Frontend
- bash: |
./build.sh --packages --installer
cp distribution/windows/setup/output/Readarr.*win-x64.exe ${BUILD_ARTIFACTSTAGINGDIRECTORY}/Readarr.${BUILDNAME}.windows-core-x64-installer.exe
cp distribution/windows/setup/output/Readarr.*win-x86.exe ${BUILD_ARTIFACTSTAGINGDIRECTORY}/Readarr.${BUILDNAME}.windows-core-x86-installer.exe
cp setup/output/Readarr.*win-x64.exe ${BUILD_ARTIFACTSTAGINGDIRECTORY}/Readarr.${BUILDNAME}.windows-core-x64-installer.exe
cp setup/output/Readarr.*win-x86.exe ${BUILD_ARTIFACTSTAGINGDIRECTORY}/Readarr.${BUILDNAME}.windows-core-x86-installer.exe
displayName: Create Installers
- publish: $(Build.ArtifactStagingDirectory)
artifact: 'WindowsInstaller'
@@ -225,7 +240,7 @@ stages:
- stage: Packages
dependsOn:
- Build_Backend
- Build_Backend_Windows
- Build_Frontend
jobs:
- job: Other_Packages
@@ -391,29 +406,14 @@ stages:
SENTRY_AUTH_TOKEN: $(sentryAuthTokenServarr)
SENTRY_ORG: $(sentryOrg)
SENTRY_URL: $(sentryUrl)
- stage: Unit_Test
displayName: Unit Tests
dependsOn: Build_Backend
dependsOn: Build_Backend_Windows
condition: succeeded()
jobs:
- job: Prepare
pool:
vmImage: ${{ variables.linuxImage }}
steps:
- checkout: none
- task: DownloadPipelineArtifact@2
inputs:
buildType: 'current'
artifactName: 'not_backend_update'
targetPath: '.'
- bash: echo "##vso[task.setvariable variable=backendNotUpdated;isOutput=true]$(cat not_backend_update)"
name: setVar
- job: Unit
displayName: Unit Native
dependsOn: Prepare
condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0'))
workspace:
clean: all
@@ -479,8 +479,6 @@ stages:
- job: Unit_Docker
displayName: Unit Docker
dependsOn: Prepare
condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0'))
strategy:
matrix:
alpine:
@@ -494,11 +492,11 @@ stages:
pool:
vmImage: ${{ variables.linuxImage }}
container: $[ variables['containerImage'] ]
timeoutInMinutes: 10
steps:
- task: UseDotNet@2
displayName: 'Install .NET'
@@ -532,14 +530,12 @@ stages:
testResultsFiles: '**/TestResult.xml'
testRunTitle: '$(testName) Unit Tests'
failTaskOnFailedTests: true
- job: Unit_LinuxCore_Postgres14
displayName: Unit Native LinuxCore with Postgres14 Database
dependsOn: Prepare
condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0'))
- job: Unit_LinuxCore_Postgres
displayName: Unit Native LinuxCore with Postgres Database
variables:
pattern: 'Readarr.*.linux-core-x64.tar.gz'
artifactName: linux-x64-tests
artifactName: LinuxCoreTests
Readarr__Postgres__Host: 'localhost'
Readarr__Postgres__Port: '5432'
Readarr__Postgres__User: 'readarr'
@@ -549,7 +545,7 @@ stages:
vmImage: ${{ variables.linuxImage }}
timeoutInMinutes: 10
steps:
- task: UseDotNet@2
displayName: 'Install .net core'
@@ -560,7 +556,7 @@ stages:
displayName: Download Test Artifact
inputs:
buildType: 'current'
artifactName: $(artifactName)
artifactName: 'linux-x64-Tests'
targetPath: $(testsFolder)
- bash: find ${TESTSFOLDER} -name "Readarr.Test.Dummy" -exec chmod a+x {} \;
displayName: Make Test Dummy Executable
@@ -583,84 +579,15 @@ stages:
inputs:
testResultsFormat: 'NUnit'
testResultsFiles: '**/TestResult.xml'
testRunTitle: 'LinuxCore Postgres14 Unit Tests'
failTaskOnFailedTests: true
- job: Unit_LinuxCore_Postgres15
displayName: Unit Native LinuxCore with Postgres15 Database
dependsOn: Prepare
condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0'))
variables:
pattern: 'Readarr.*.linux-core-x64.tar.gz'
artifactName: linux-x64-tests
Readarr__Postgres__Host: 'localhost'
Readarr__Postgres__Port: '5432'
Readarr__Postgres__User: 'readarr'
Readarr__Postgres__Password: 'readarr'
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 "Readarr.Test.Dummy" -exec chmod a+x {} \;
displayName: Make Test Dummy Executable
condition: and(succeeded(), ne(variables['osName'], 'Windows'))
- bash: |
docker run -d --name=postgres15 \
-e POSTGRES_PASSWORD=readarr \
-e POSTGRES_USER=readarr \
-p 5432:5432/tcp \
-v /usr/share/zoneinfo/America/Chicago:/etc/localtime:ro \
postgres:15
displayName: Start postgres
- bash: |
chmod a+x ${TESTSFOLDER}/test.sh
ls -lR ${TESTSFOLDER}
${TESTSFOLDER}/test.sh Linux Unit Test
displayName: Run Tests
- task: PublishTestResults@2
displayName: Publish Test Results
inputs:
testResultsFormat: 'NUnit'
testResultsFiles: '**/TestResult.xml'
testRunTitle: 'LinuxCore Postgres15 Unit Tests'
testRunTitle: 'LinuxCore Postgres Unit Tests'
failTaskOnFailedTests: true
- stage: Integration
displayName: Integration
dependsOn: Packages
jobs:
- job: Prepare
pool:
vmImage: ${{ variables.linuxImage }}
steps:
- checkout: none
- task: DownloadPipelineArtifact@2
inputs:
buildType: 'current'
artifactName: 'not_backend_update'
targetPath: '.'
- bash: echo "##vso[task.setvariable variable=backendNotUpdated;isOutput=true]$(cat not_backend_update)"
name: setVar
- job: Integration_Native
displayName: Integration Native
dependsOn: Prepare
condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0'))
strategy:
matrix:
MacCore:
@@ -681,7 +608,7 @@ stages:
pool:
vmImage: $(imageName)
steps:
- task: UseDotNet@2
displayName: 'Install .net core'
@@ -703,7 +630,7 @@ stages:
targetPath: $(Build.ArtifactStagingDirectory)
- task: ExtractFiles@1
inputs:
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
destinationFolder: '$(Build.ArtifactStagingDirectory)/bin'
displayName: Extract Package
- bash: |
@@ -722,10 +649,8 @@ stages:
failTaskOnFailedTests: true
displayName: Publish Test Results
- job: Integration_LinuxCore_Postgres14
displayName: Integration Native LinuxCore with Postgres14 Database
dependsOn: Prepare
condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0'))
- job: Integration_LinuxCore_Postgres
displayName: Integration Native LinuxCore with Postgres Database
variables:
pattern: 'Readarr.*.linux-core-x64.tar.gz'
Readarr__Postgres__Host: 'localhost'
@@ -757,7 +682,7 @@ stages:
targetPath: $(Build.ArtifactStagingDirectory)
- task: ExtractFiles@1
inputs:
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
destinationFolder: '$(Build.ArtifactStagingDirectory)/bin'
displayName: Extract Package
- bash: |
@@ -780,77 +705,12 @@ stages:
inputs:
testResultsFormat: 'NUnit'
testResultsFiles: '**/TestResult.xml'
testRunTitle: 'Integration LinuxCore Postgres14 Database Integration Tests'
failTaskOnFailedTests: true
displayName: Publish Test Results
- job: Integration_LinuxCore_Postgres15
displayName: Integration Native LinuxCore with Postgres Database
dependsOn: Prepare
condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0'))
variables:
pattern: 'Readarr.*.linux-core-x64.tar.gz'
Readarr__Postgres__Host: 'localhost'
Readarr__Postgres__Port: '5432'
Readarr__Postgres__User: 'readarr'
Readarr__Postgres__Password: 'readarr'
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/Readarr/. ./bin/
displayName: Move Package Contents
- bash: |
docker run -d --name=postgres15 \
-e POSTGRES_PASSWORD=readarr \
-e POSTGRES_USER=readarr \
-p 5432:5432/tcp \
-v /usr/share/zoneinfo/America/Chicago:/etc/localtime:ro \
postgres:15
displayName: Start postgres
- bash: |
chmod a+x ${TESTSFOLDER}/test.sh
${TESTSFOLDER}/test.sh Linux Integration Test
displayName: Run Integration Tests
- task: PublishTestResults@2
inputs:
testResultsFormat: 'NUnit'
testResultsFiles: '**/TestResult.xml'
testRunTitle: 'Integration LinuxCore Postgres15 Database Integration Tests'
testRunTitle: 'Integration LinuxCore Postgres Database Integration Tests'
failTaskOnFailedTests: true
displayName: Publish Test Results
- job: Integration_FreeBSD
displayName: Integration Native FreeBSD
dependsOn: Prepare
condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0'))
workspace:
clean: all
variables:
@@ -895,8 +755,6 @@ stages:
- job: Integration_Docker
displayName: Integration Docker
dependsOn: Prepare
condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0'))
strategy:
matrix:
alpine:
@@ -915,7 +773,7 @@ stages:
container: $[ variables['containerImage'] ]
timeoutInMinutes: 15
steps:
- task: UseDotNet@2
displayName: 'Install .NET'
@@ -943,7 +801,7 @@ stages:
targetPath: $(Build.ArtifactStagingDirectory)
- task: ExtractFiles@1
inputs:
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
destinationFolder: '$(Build.ArtifactStagingDirectory)/bin'
displayName: Extract Package
- bash: |
@@ -965,7 +823,7 @@ stages:
- stage: Automation
displayName: Automation
dependsOn: Packages
jobs:
- job: Automation
strategy:
@@ -975,23 +833,20 @@ stages:
artifactName: 'linux-x64'
imageName: ${{ variables.linuxImage }}
pattern: 'Readarr.*.linux-core-x64.tar.gz'
failBuild: true
Mac:
osName: 'Mac'
artifactName: 'osx-x64'
imageName: ${{ variables.macImage }}
pattern: 'Readarr.*.osx-core-x64.tar.gz'
failBuild: true
Windows:
osName: 'Windows'
artifactName: 'win-x64'
imageName: ${{ variables.windowsImage }}
pattern: 'Readarr.*.windows-core-x64.zip'
failBuild: true
pool:
vmImage: $(imageName)
steps:
- task: UseDotNet@2
displayName: 'Install .net core'
@@ -1013,7 +868,7 @@ stages:
targetPath: $(Build.ArtifactStagingDirectory)
- task: ExtractFiles@1
inputs:
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
destinationFolder: '$(Build.ArtifactStagingDirectory)/bin'
displayName: Extract Package
- bash: |
@@ -1033,35 +888,20 @@ stages:
TargetFolder: '$(Build.ArtifactStagingDirectory)/screenshots'
- publish: $(Build.ArtifactStagingDirectory)/screenshots
artifact: '$(osName)AutomationScreenshots'
displayName: Publish Screenshot Bundle
condition: and(succeeded(), eq(variables['System.JobAttempt'], '1'))
displayName: Publish Screenshot Bundle
- task: PublishTestResults@2
inputs:
testResultsFormat: 'NUnit'
testResultsFiles: '**/TestResult.xml'
testRunTitle: '$(osName) Automation Tests'
failTaskOnFailedTests: $(failBuild)
failTaskOnFailedTests: true
displayName: Publish Test Results
- stage: Analyze
dependsOn:
- Setup
dependsOn: []
displayName: Analyze
jobs:
- job: Prepare
pool:
vmImage: ${{ variables.linuxImage }}
steps:
- checkout: none
- task: DownloadPipelineArtifact@2
inputs:
buildType: 'current'
artifactName: 'not_backend_update'
targetPath: '.'
- bash: echo "##vso[task.setvariable variable=backendNotUpdated;isOutput=true]$(cat not_backend_update)"
name: setVar
- job: Lint_Frontend
displayName: Lint Frontend
strategy:
@@ -1087,6 +927,7 @@ stages:
key: 'yarn | "$(osName)" | yarn.lock'
restoreKeys: |
yarn | "$(osName)"
yarn
path: $(yarnCacheFolder)
displayName: Cache Yarn packages
- bash: ./build.sh --lint
@@ -1115,16 +956,11 @@ stages:
cliProjectVersion: '$(readarrVersion)'
cliSources: './frontend'
- task: SonarCloudAnalyze@1
- job: Api_Docs
displayName: API Docs
dependsOn: Prepare
condition: |
and
(
and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/develop')),
and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0'))
)
and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/develop'))
pool:
vmImage: ${{ variables.windowsImage }}
@@ -1137,7 +973,7 @@ stages:
- checkout: self
submodules: true
persistCredentials: true
fetchDepth: 1
fetchDepth: 1
- bash: ./docs.sh Windows
displayName: Create openapi.json
- bash: |
@@ -1145,9 +981,10 @@ stages:
git config --global user.name "Servarr"
git checkout -b api-docs
git add .
if git status | grep -q modified
git status
if git status | grep modified
then
git commit -am 'Automated API Docs update'
git commit -am 'Automated API Docs update [skip ci]'
git push -f --set-upstream origin api-docs
curl -X POST -H "Authorization: token ${GITHUBTOKEN}" -H "Accept: application/vnd.github.v3+json" https://api.github.com/repos/readarr/readarr/pulls -d '{"head":"api-docs","base":"develop","title":"Update API docs"}'
else
@@ -1171,25 +1008,33 @@ stages:
- job: Analyze_Backend
displayName: Backend
dependsOn: Prepare
condition: and(succeeded(), eq(dependencies.Prepare.outputs['setVar.backendNotUpdated'], '0'))
variables:
disable.coverage.autogenerate: 'true'
EnableAnalyzers: 'false'
pool:
vmImage: ${{ variables.windowsImage }}
vmImage: ${{ variables.linuxImage }}
steps:
- task: UseDotNet@2
displayName: 'Install .net core'
displayName: 'Install .net core 2.1'
inputs:
version: 2.1.815
- task: UseDotNet@2
displayName: 'Install .net core 3.1'
inputs:
version: 3.1.413
- task: UseDotNet@2
displayName: 'Install .net core 5.0'
inputs:
version: $(dotnetVersion)
- checkout: self # Need history for Sonar analysis
submodules: true
- powershell: Set-Service SCardSvr -StartupType Manual
displayName: Enable Windows Test Service
- task: Cache@2
inputs:
key: 'nuget | "$(Agent.OS)" | $(Build.SourcesDirectory)/src/Directory.Packages.props'
path: $(nugetCacheFolder)
displayName: Cache NuGet packages
- task: SonarCloudPrepare@1
condition: eq(variables['System.PullRequest.IsFork'], 'False')
inputs:
@@ -1200,14 +1045,16 @@ stages:
projectName: 'Readarr'
projectVersion: '$(readarrVersion)'
extraProperties: |
sonar.exclusions=**/obj/**,**/*.dll,**/NzbDrone.Core.Test/Files/**/*,./frontend/**,**/ExternalModules/**,./src/Libraries/**
sonar.exclusions=**/obj/**,**/*.dll,**/NzbDrone.Core.Test/Files/**/*,./frontend/**,./src/Libraries/**
sonar.coverage.exclusions=**/Readarr.Api.V1/**/*
sonar.cs.opencover.reportsPaths=$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml
sonar.cs.nunit.reportsPaths=$(Build.SourcesDirectory)/TestResult.xml
- bash: |
./build.sh --backend -f net6.0 -r win-x64
TEST_DIR=_tests/net6.0/win-x64/publish/ ./test.sh Windows Unit Coverage
./build.sh --backend -f net6.0 -r linux-x64
TEST_DIR=_tests/net6.0/linux-x64/publish/ ./test.sh Linux Unit Coverage
displayName: Coverage Unit Tests
env:
NUGET_PACKAGES: $(nugetCacheFolder)
- task: SonarCloudAnalyze@1
condition: eq(variables['System.PullRequest.IsFork'], 'False')
displayName: Publish SonarCloud Results
@@ -1230,6 +1077,7 @@ stages:
- Unit_Test
- Integration
- Automation
- Build_Backend_Other
condition: eq(variables['system.pullrequest.isfork'], false)
displayName: Build Status Report
jobs:
@@ -1253,4 +1101,3 @@ stages:
DISCORDCHANNELID: $(discordChannelId)
DISCORDWEBHOOKKEY: $(discordWebhookKey)
DISCORDTHREADID: $(discordThreadId)

View File

@@ -23,7 +23,7 @@ UpdateVersionNumber()
echo "Updating Version Info"
sed -i'' -e "s/<AssemblyVersion>[0-9.*]\+<\/AssemblyVersion>/<AssemblyVersion>$READARRVERSION<\/AssemblyVersion>/g" src/Directory.Build.props
sed -i'' -e "s/<AssemblyConfiguration>[\$()A-Za-z-]\+<\/AssemblyConfiguration>/<AssemblyConfiguration>${BUILD_SOURCEBRANCHNAME}<\/AssemblyConfiguration>/g" src/Directory.Build.props
sed -i'' -e "s/<string>10.0.0.0<\/string>/<string>$READARRVERSION<\/string>/g" distribution/osx/Readarr.app/Contents/Info.plist
sed -i'' -e "s/<string>10.0.0.0<\/string>/<string>$READARRVERSION<\/string>/g" macOS/Readarr.app/Contents/Info.plist
fi
}
@@ -183,7 +183,7 @@ PackageMacOSApp()
rm -rf $folder
mkdir -p $folder
cp -r distribution/osx/Readarr.app $folder
cp -r macOS/Readarr.app $folder
mkdir -p $folder/Readarr.app/Contents/MacOS
echo "Copying Binaries"
@@ -245,7 +245,7 @@ BuildInstaller()
local framework="$1"
local runtime="$2"
./_inno/ISCC.exe distribution/windows/setup/readarr.iss "//DFramework=$framework" "//DRuntime=$runtime"
./_inno/ISCC.exe setup/readarr.iss "//DFramework=$framework" "//DRuntime=$runtime"
}
InstallInno()

View File

@@ -4,14 +4,14 @@ module.exports = {
plugins: [
// Stage 1
'@babel/plugin-proposal-export-default-from',
['@babel/plugin-transform-optional-chaining', { loose }],
['@babel/plugin-transform-nullish-coalescing-operator', { loose }],
['@babel/plugin-proposal-optional-chaining', { loose }],
['@babel/plugin-proposal-nullish-coalescing-operator', { loose }],
// Stage 2
'@babel/plugin-transform-export-namespace-from',
'@babel/plugin-proposal-export-namespace-from',
// Stage 3
['@babel/plugin-transform-class-properties', { loose }],
['@babel/plugin-proposal-class-properties', { loose }],
'@babel/plugin-syntax-dynamic-import'
],
env: {

View File

@@ -36,7 +36,7 @@ module.exports = (env) => {
},
entry: {
index: 'index.ts'
index: 'index.js'
},
resolve: {
@@ -98,8 +98,7 @@ module.exports = (env) => {
new HtmlWebpackPlugin({
template: 'frontend/src/index.ejs',
filename: 'index.html',
publicPath: '/',
inject: false
publicPath: '/'
}),
new FileManagerPlugin({

View File

@@ -7,13 +7,13 @@ import PageConnector from 'Components/Page/PageConnector';
import ApplyTheme from './ApplyTheme';
import AppRoutes from './AppRoutes';
function App({ store, history }) {
function App({ store, history, hasTranslationsError }) {
return (
<DocumentTitle title={window.Readarr.instanceName}>
<Provider store={store}>
<ConnectedRouter history={history}>
<ApplyTheme>
<PageConnector>
<PageConnector hasTranslationsError={hasTranslationsError}>
<AppRoutes app={App} />
</PageConnector>
</ApplyTheme>
@@ -25,7 +25,8 @@ function App({ store, history }) {
App.propTypes = {
store: PropTypes.object.isRequired,
history: PropTypes.object.isRequired
history: PropTypes.object.isRequired,
hasTranslationsError: PropTypes.bool.isRequired
};
export default App;

View File

@@ -1,7 +1,6 @@
.version {
margin: 0 3px;
font-weight: bold;
font-family: var(--defaultFontFamily);
}
.maintenance {

View File

@@ -2,7 +2,6 @@ import PropTypes from 'prop-types';
import React from 'react';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
@@ -65,12 +64,12 @@ function AppUpdatedModalContent(props) {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('AppUpdated', { appName: 'Readarr' })}
Readarr Updated
</ModalHeader>
<ModalBody>
<div>
<InlineMarkdown data={translate('AppUpdatedVersion', { appName: 'Readarr', version })} blockClassName={styles.version} />
Version <span className={styles.version}>{version}</span> of Readarr has been installed, in order to get the latest changes you'll need to reload Readarr.
</div>
{
@@ -78,14 +77,16 @@ function AppUpdatedModalContent(props) {
<div>
{
!update.changes &&
<div className={styles.maintenance}>{translate('MaintenanceRelease')}</div>
<div className={styles.maintenance}>
{translate('MaintenanceRelease')}
</div>
}
{
!!update.changes &&
<div>
<div className={styles.changes}>
{translate('WhatsNew')}
What's new?
</div>
<UpdateChanges
@@ -112,14 +113,14 @@ function AppUpdatedModalContent(props) {
<Button
onPress={onSeeChangesPress}
>
{translate('RecentChanges')}
Recent Changes
</Button>
<Button
kind={kinds.PRIMARY}
onPress={onModalClose}
>
{translate('Reload')}
Reload
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -7,7 +7,6 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ConnectionLostModal.css';
function ConnectionLostModal(props) {
@@ -23,16 +22,16 @@ function ConnectionLostModal(props) {
>
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('ConnectionLost')}
Connection Lost
</ModalHeader>
<ModalBody>
<div>
{translate('ConnectionLostToBackend', { appName: 'Readarr' })}
Readarr has lost its connection to the backend and will need to be reloaded to restore functionality.
</div>
<div className={styles.automatic}>
{translate('ConnectionLostReconnect', { appName: 'Readarr' })}
Readarr will try to connect automatically, or you can click reload below.
</div>
</ModalBody>
<ModalFooter>
@@ -40,7 +39,7 @@ function ConnectionLostModal(props) {
kind={kinds.PRIMARY}
onPress={onModalClose}
>
{translate('Reload')}
Reload
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -16,7 +16,7 @@ import AuthorIndex from './AuthorIndex';
function createMapStateToProps() {
return createSelector(
createAuthorClientSideCollectionItemsSelector('authorIndex'),
createCommandExecutingSelector(commandNames.BULK_REFRESH_AUTHOR),
createCommandExecutingSelector(commandNames.REFRESH_AUTHOR),
createCommandExecutingSelector(commandNames.RSS_SYNC),
createCommandExecutingSelector(commandNames.RENAME_AUTHOR),
createCommandExecutingSelector(commandNames.RETAG_AUTHOR),
@@ -24,17 +24,17 @@ function createMapStateToProps() {
(
author,
isRefreshingAuthor,
isRssSyncExecuting,
isOrganizingAuthor,
isRetaggingAuthor,
isRssSyncExecuting,
dimensionsState
) => {
return {
...author,
isRefreshingAuthor,
isRssSyncExecuting,
isOrganizingAuthor,
isRetaggingAuthor,
isRssSyncExecuting,
isSmallScreen: dimensionsState.isSmallScreen
};
}

View File

@@ -14,39 +14,14 @@ import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
const nameOptions = [
{
key: 'firstLast',
get value() {
return translate('NameFirstLast');
}
},
{
key: 'lastFirst',
get value() {
return translate('NameLastFirst');
}
}
{ key: 'firstLast', value: translate('NameFirstLast') },
{ key: 'lastFirst', value: translate('NameLastFirst') }
];
const posterSizeOptions = [
{
key: 'small',
get value() {
return translate('Small');
}
},
{
key: 'medium',
get value() {
return translate('Medium');
}
},
{
key: 'large',
get value() {
return translate('Large');
}
}
{ key: 'small', value: 'Small' },
{ key: 'medium', value: 'Medium' },
{ key: 'large', value: 'Large' }
];
class AuthorIndexOverviewOptionsModalContent extends Component {

View File

@@ -14,45 +14,15 @@ import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
const posterSizeOptions = [
{
key: 'small',
get value() {
return translate('Small');
}
},
{
key: 'medium',
get value() {
return translate('Medium');
}
},
{
key: 'large',
get value() {
return translate('Large');
}
}
{ key: 'small', value: 'Small' },
{ key: 'medium', value: 'Medium' },
{ key: 'large', value: 'Large' }
];
const nameOptions = [
{
key: 'no',
get value() {
return translate('NoName');
}
},
{
key: 'firstLast',
get value() {
return translate('NameFirstLast');
}
},
{
key: 'lastFirst',
get value() {
return translate('NameLastFirst');
}
}
{ key: 'no', value: translate('NoName') },
{ key: 'firstLast', value: translate('NameFirstLast') },
{ key: 'lastFirst', value: translate('NameLastFirst') }
];
class AuthorIndexPosterOptionsModalContent extends Component {

View File

@@ -7,18 +7,8 @@ import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
const nameOptions = [
{
key: 'firstLast',
get value() {
return translate('NameFirstLast');
}
},
{
key: 'lastFirst',
get value() {
return translate('NameLastFirst');
}
}
{ key: 'firstLast', value: translate('NameFirstLast') },
{ key: 'lastFirst', value: translate('NameLastFirst') }
];
class AuthorIndexTableOptions extends Component {

View File

@@ -6,5 +6,4 @@
.statusIcon {
width: 20px !important;
text-align: center;
}

View File

@@ -16,8 +16,8 @@ import BookIndex from './BookIndex';
function createMapStateToProps() {
return createSelector(
createBookClientSideCollectionItemsSelector('bookIndex'),
createCommandExecutingSelector(commandNames.BULK_REFRESH_AUTHOR),
createCommandExecutingSelector(commandNames.BULK_REFRESH_BOOK),
createCommandExecutingSelector(commandNames.REFRESH_AUTHOR),
createCommandExecutingSelector(commandNames.REFRESH_BOOK),
createCommandExecutingSelector(commandNames.RSS_SYNC),
createCommandExecutingSelector(commandNames.CUTOFF_UNMET_BOOK_SEARCH),
createCommandExecutingSelector(commandNames.MISSING_BOOK_SEARCH),

View File

@@ -143,7 +143,7 @@ class BookshelfFooter extends Component {
<div>
<div className={styles.label}>
{translate('CountAuthorsSelected', { selectedCount })}
{selectedCount} Author(s) Selected
</div>
<SpinnerButton

View File

@@ -25,10 +25,6 @@
white-space: pre-wrap;
}
.version {
margin-top: 20px;
}
@media only screen and (max-width: $breakpointMedium) {
.image {
height: 250px;

View File

@@ -6,7 +6,6 @@ interface CssExports {
'image': string;
'imageContainer': string;
'message': string;
'version': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,60 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './ErrorBoundaryError.css';
function ErrorBoundaryError(props) {
const {
className,
messageClassName,
detailsClassName,
message,
error,
info
} = props;
return (
<div className={className}>
<div className={messageClassName}>
{message}
</div>
<div className={styles.imageContainer}>
<img
className={styles.image}
src={`${window.Readarr.urlBase}/Content/Images/error.png`}
/>
</div>
<details className={detailsClassName}>
{
error &&
<div>
{error.toString()}
</div>
}
<div className={styles.info}>
{info.componentStack}
</div>
</details>
</div>
);
}
ErrorBoundaryError.propTypes = {
className: PropTypes.string.isRequired,
messageClassName: PropTypes.string.isRequired,
detailsClassName: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
error: PropTypes.object.isRequired,
info: PropTypes.object.isRequired
};
ErrorBoundaryError.defaultProps = {
className: styles.container,
messageClassName: styles.message,
detailsClassName: styles.details,
message: 'There was an error loading this content'
};
export default ErrorBoundaryError;

View File

@@ -1,77 +0,0 @@
import React, { useEffect, useState } from 'react';
import StackTrace from 'stacktrace-js';
import translate from 'Utilities/String/translate';
import styles from './ErrorBoundaryError.css';
interface ErrorBoundaryErrorProps {
className: string;
messageClassName: string;
detailsClassName: string;
message: string;
error: Error;
info: {
componentStack: string;
};
}
function ErrorBoundaryError(props: ErrorBoundaryErrorProps) {
const {
className = styles.container,
messageClassName = styles.message,
detailsClassName = styles.details,
message = translate('ErrorLoadingContent'),
error,
info,
} = props;
const [detailedError, setDetailedError] = useState<
StackTrace.StackFrame[] | null
>(null);
useEffect(() => {
if (error) {
StackTrace.fromError(error).then((de) => {
setDetailedError(de);
});
} else {
setDetailedError(null);
}
}, [error, setDetailedError]);
return (
<div className={className}>
<div className={messageClassName}>{message}</div>
<div className={styles.imageContainer}>
<img
className={styles.image}
src={`${window.Readarr.urlBase}/Content/Images/error.png`}
/>
</div>
<details className={detailsClassName}>
{error ? <div>{error.message}</div> : null}
{detailedError ? (
detailedError.map((d, index) => {
return (
<div key={index}>
{` at ${d.functionName} (${d.fileName}:${d.lineNumber}:${d.columnNumber})`}
</div>
);
})
) : (
<div>{info.componentStack}</div>
)}
{
<div className={styles.version}>
Version: {window.Readarr.version}
</div>
}
</details>
</div>
);
}
export default ErrorBoundaryError;

View File

@@ -206,11 +206,9 @@ class FilterBuilderRow extends Component {
const selectedFilterBuilderProp = this.selectedFilterBuilderProp;
const keyOptions = filterBuilderProps.map((availablePropFilter) => {
const { name, label } = availablePropFilter;
return {
key: name,
value: typeof label === 'function' ? label() : label
key: availablePropFilter.name,
value: availablePropFilter.label
};
}).sort((a, b) => a.value.localeCompare(b.value));

View File

@@ -9,10 +9,6 @@
&:hover {
background-color: var(--inputHoverBackgroundColor);
}
&.isDisabled {
cursor: not-allowed;
}
}
.optionCheck {

View File

@@ -41,7 +41,7 @@ class NumberInput extends Component {
componentDidUpdate(prevProps, prevState) {
const { value } = this.props;
if (!isNaN(value) && value !== prevProps.value && !this.state.isFocused) {
if (value !== prevProps.value && !this.state.isFocused) {
this.setState({
value: value == null ? '' : value.toString()
});

View File

@@ -61,7 +61,7 @@ class SelectInput extends Component {
value={key}
{...otherOptionProps}
>
{typeof optionValue === 'function' ? optionValue() : optionValue}
{optionValue}
</option>
);
})
@@ -75,7 +75,7 @@ SelectInput.propTypes = {
className: PropTypes.string,
disabledClassName: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.func]).isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool,
hasError: PropTypes.bool,

View File

@@ -41,7 +41,7 @@ class Icon extends PureComponent {
return (
<span
className={containerClassName}
title={typeof title === 'function' ? title() : title}
title={title}
>
{icon}
</span>
@@ -58,7 +58,7 @@ Icon.propTypes = {
name: PropTypes.object.isRequired,
kind: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
title: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
title: PropTypes.string,
isSpinning: PropTypes.bool.isRequired,
fixedWidth: PropTypes.bool.isRequired
};

View File

@@ -97,7 +97,6 @@ class SpinnerErrorButton extends Component {
render() {
const {
kind,
isSpinning,
error,
children,
@@ -113,7 +112,7 @@ class SpinnerErrorButton extends Component {
const showIcon = wasSuccessful || hasWarning || hasError;
let iconName = icons.CHECK;
let iconKind = kind === kinds.PRIMARY ? kinds.DEFAULT : kinds.SUCCESS;
let iconKind = kinds.SUCCESS;
if (hasWarning) {
iconName = icons.WARNING;
@@ -127,7 +126,6 @@ class SpinnerErrorButton extends Component {
return (
<SpinnerButton
kind={kind}
isSpinning={isSpinning}
{...otherProps}
>
@@ -156,7 +154,6 @@ class SpinnerErrorButton extends Component {
}
SpinnerErrorButton.propTypes = {
kind: PropTypes.oneOf(kinds.all),
isSpinning: PropTypes.bool.isRequired,
error: PropTypes.object,
children: PropTypes.node.isRequired

View File

@@ -10,8 +10,7 @@ class InlineMarkdown extends Component {
render() {
const {
className,
data,
blockClassName
data
} = this.props;
// For now only replace links or code blocks (not both)
@@ -48,7 +47,7 @@ class InlineMarkdown extends Component {
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
}
markdownBlocks.push(<code key={`code-${match.index}`} className={blockClassName ?? null}>{match[0].substring(1, match[0].length - 1)}</code>);
markdownBlocks.push(<code key={`code-${match.index}`}>{match[0].substring(1, match[0].length - 1)}</code>);
endIndex = match.index + match[0].length;
}
@@ -67,8 +66,7 @@ class InlineMarkdown extends Component {
InlineMarkdown.propTypes = {
className: PropTypes.string,
data: PropTypes.string,
blockClassName: PropTypes.string
data: PropTypes.string
};
export default InlineMarkdown;

View File

@@ -32,7 +32,7 @@ class FilterMenuContent extends Component {
selectedFilterKey={selectedFilterKey}
onPress={onFilterSelect}
>
{typeof filter.label === 'function' ? filter.label() : filter.label}
{filter.label}
</FilterMenuItem>
);
})

View File

@@ -7,7 +7,7 @@ function ErrorPage(props) {
const {
version,
isLocalStorageSupported,
translationsError,
hasTranslationsError,
authorError,
customFiltersError,
tagsError,
@@ -21,8 +21,8 @@ function ErrorPage(props) {
if (!isLocalStorageSupported) {
errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.';
} else if (translationsError) {
errorMessage = getErrorMessage(translationsError, 'Failed to load translations from API');
} else if (hasTranslationsError) {
errorMessage = 'Failed to load translations from API';
} else if (authorError) {
errorMessage = getErrorMessage(authorError, 'Failed to load author from API');
} else if (customFiltersError) {
@@ -55,7 +55,7 @@ function ErrorPage(props) {
ErrorPage.propTypes = {
version: PropTypes.string.isRequired,
isLocalStorageSupported: PropTypes.bool.isRequired,
translationsError: PropTypes.object,
hasTranslationsError: PropTypes.bool.isRequired,
authorError: PropTypes.object,
customFiltersError: PropTypes.object,
tagsError: PropTypes.object,

View File

@@ -3,7 +3,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { createSelector } from 'reselect';
import { fetchTranslations, saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions';
import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions';
import { fetchAuthor } from 'Store/Actions/authorActions';
import { fetchBooks } from 'Store/Actions/bookActions';
import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
@@ -52,7 +52,6 @@ const selectIsPopulated = createSelector(
(state) => state.settings.metadataProfiles.isPopulated,
(state) => state.settings.importLists.isPopulated,
(state) => state.system.status.isPopulated,
(state) => state.app.translations.isPopulated,
(
customFiltersIsPopulated,
tagsIsPopulated,
@@ -61,8 +60,7 @@ const selectIsPopulated = createSelector(
qualityProfilesIsPopulated,
metadataProfilesIsPopulated,
importListsIsPopulated,
systemStatusIsPopulated,
translationsIsPopulated
systemStatusIsPopulated
) => {
return (
customFiltersIsPopulated &&
@@ -72,8 +70,7 @@ const selectIsPopulated = createSelector(
qualityProfilesIsPopulated &&
metadataProfilesIsPopulated &&
importListsIsPopulated &&
systemStatusIsPopulated &&
translationsIsPopulated
systemStatusIsPopulated
);
}
);
@@ -87,7 +84,6 @@ const selectErrors = createSelector(
(state) => state.settings.metadataProfiles.error,
(state) => state.settings.importLists.error,
(state) => state.system.status.error,
(state) => state.app.translations.error,
(
customFiltersError,
tagsError,
@@ -96,8 +92,7 @@ const selectErrors = createSelector(
qualityProfilesError,
metadataProfilesError,
importListsError,
systemStatusError,
translationsError
systemStatusError
) => {
const hasError = !!(
customFiltersError ||
@@ -107,8 +102,7 @@ const selectErrors = createSelector(
qualityProfilesError ||
metadataProfilesError ||
importListsError ||
systemStatusError ||
translationsError
systemStatusError
);
return {
@@ -120,8 +114,7 @@ const selectErrors = createSelector(
qualityProfilesError,
metadataProfilesError,
importListsError,
systemStatusError,
translationsError
systemStatusError
};
}
);
@@ -183,9 +176,6 @@ function createMapDispatchToProps(dispatch, props) {
dispatchFetchStatus() {
dispatch(fetchStatus());
},
dispatchFetchTranslations() {
dispatch(fetchTranslations());
},
onResize(dimensions) {
dispatch(saveDimensions(dimensions));
},
@@ -220,7 +210,6 @@ class PageConnector extends Component {
this.props.dispatchFetchImportLists();
this.props.dispatchFetchUISettings();
this.props.dispatchFetchStatus();
this.props.dispatchFetchTranslations();
}
}
@@ -236,6 +225,7 @@ class PageConnector extends Component {
render() {
const {
hasTranslationsError,
isPopulated,
hasError,
dispatchFetchAuthor,
@@ -247,15 +237,15 @@ class PageConnector extends Component {
dispatchFetchImportLists,
dispatchFetchUISettings,
dispatchFetchStatus,
dispatchFetchTranslations,
...otherProps
} = this.props;
if (hasError || !this.state.isLocalStorageSupported) {
if (hasTranslationsError || hasError || !this.state.isLocalStorageSupported) {
return (
<ErrorPage
{...this.state}
{...otherProps}
hasTranslationsError={hasTranslationsError}
/>
);
}
@@ -276,6 +266,7 @@ class PageConnector extends Component {
}
PageConnector.propTypes = {
hasTranslationsError: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
hasError: PropTypes.bool.isRequired,
isSidebarVisible: PropTypes.bool.isRequired,
@@ -289,7 +280,6 @@ PageConnector.propTypes = {
dispatchFetchImportLists: PropTypes.func.isRequired,
dispatchFetchUISettings: PropTypes.func.isRequired,
dispatchFetchStatus: PropTypes.func.isRequired,
dispatchFetchTranslations: PropTypes.func.isRequired,
onSidebarVisibleChange: PropTypes.func.isRequired
};

View File

@@ -21,28 +21,28 @@ const SIDEBAR_WIDTH = parseInt(dimensions.sidebarWidth);
const links = [
{
iconName: icons.AUTHOR_CONTINUING,
title: () => translate('Library'),
title: 'Library',
to: '/',
alias: '/authors',
children: [
{
title: () => translate('Authors'),
title: 'Authors',
to: '/authors'
},
{
title: () => translate('Books'),
title: 'Books',
to: '/books'
},
{
title: () => translate('AddNew'),
title: 'Add New',
to: '/add/search'
},
{
title: () => translate('Bookshelf'),
title: 'Bookshelf',
to: '/shelf'
},
{
title: () => translate('UnmappedFiles'),
title: 'Unmapped Files',
to: '/unmapped'
}
]
@@ -50,26 +50,26 @@ const links = [
{
iconName: icons.CALENDAR,
title: () => translate('Calendar'),
title: 'Calendar',
to: '/calendar'
},
{
iconName: icons.ACTIVITY,
title: () => translate('Activity'),
title: 'Activity',
to: '/activity/queue',
children: [
{
title: () => translate('Queue'),
title: 'Queue',
to: '/activity/queue',
statusComponent: QueueStatusConnector
},
{
title: () => translate('History'),
title: 'History',
to: '/activity/history'
},
{
title: () => translate('Blocklist'),
title: 'Blocklist',
to: '/activity/blocklist'
}
]
@@ -77,15 +77,15 @@ const links = [
{
iconName: icons.WARNING,
title: () => translate('Wanted'),
title: 'Wanted',
to: '/wanted/missing',
children: [
{
title: () => translate('Missing'),
title: 'Missing',
to: '/wanted/missing'
},
{
title: () => translate('CutoffUnmet'),
title: 'Cutoff Unmet',
to: '/wanted/cutoffunmet'
}
]
@@ -93,55 +93,55 @@ const links = [
{
iconName: icons.SETTINGS,
title: () => translate('Settings'),
title: 'Settings',
to: '/settings',
children: [
{
title: () => translate('MediaManagement'),
title: 'Media Management',
to: '/settings/mediamanagement'
},
{
title: () => translate('Profiles'),
title: 'Profiles',
to: '/settings/profiles'
},
{
title: () => translate('Quality'),
title: 'Quality',
to: '/settings/quality'
},
{
title: () => translate('CustomFormats'),
title: translate('CustomFormats'),
to: '/settings/customformats'
},
{
title: () => translate('Indexers'),
title: translate('Indexers'),
to: '/settings/indexers'
},
{
title: () => translate('DownloadClients'),
title: 'Download Clients',
to: '/settings/downloadclients'
},
{
title: () => translate('ImportLists'),
title: 'Import Lists',
to: '/settings/importlists'
},
{
title: () => translate('Connect'),
title: 'Connect',
to: '/settings/connect'
},
{
title: () => translate('Metadata'),
title: 'Metadata',
to: '/settings/metadata'
},
{
title: () => translate('Tags'),
title: 'Tags',
to: '/settings/tags'
},
{
title: () => translate('General'),
title: 'General',
to: '/settings/general'
},
{
title: () => translate('Ui'),
title: 'UI',
to: '/settings/ui'
}
]
@@ -149,32 +149,32 @@ const links = [
{
iconName: icons.SYSTEM,
title: () => translate('System'),
title: 'System',
to: '/system/status',
children: [
{
title: () => translate('Status'),
title: 'Status',
to: '/system/status',
statusComponent: HealthStatusConnector
},
{
title: () => translate('Tasks'),
title: 'Tasks',
to: '/system/tasks'
},
{
title: () => translate('Backup'),
title: 'Backup',
to: '/system/backup'
},
{
title: () => translate('Updates'),
title: 'Updates',
to: '/system/updates'
},
{
title: () => translate('Events'),
title: 'Events',
to: '/system/events'
},
{
title: () => translate('LogFiles'),
title: 'Log Files',
to: '/system/logs/files'
}
]

View File

@@ -64,7 +64,7 @@ class PageSidebarItem extends Component {
}
<span className={isChildItem ? styles.noIcon : null}>
{typeof title === 'function' ? title() : title}
{title}
</span>
{
@@ -88,7 +88,7 @@ class PageSidebarItem extends Component {
PageSidebarItem.propTypes = {
iconName: PropTypes.object,
title: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
title: PropTypes.string.isRequired,
to: PropTypes.string.isRequired,
isActive: PropTypes.bool,
isActiveParent: PropTypes.bool,

View File

@@ -1,10 +1,8 @@
import React from 'react';
type PropertyFunction<T> = () => T;
interface Column {
name: string;
label: string | PropertyFunction<string> | React.ReactNode;
label: string | React.ReactNode;
columnLabel?: string;
isSortable?: boolean;
isVisible: boolean;

View File

@@ -107,7 +107,7 @@ function Table(props) {
{...getTableHeaderCellProps(otherProps)}
{...column}
>
{typeof column.label === 'function' ? column.label() : column.label}
{column.label}
</TableHeaderCell>
);
})

View File

@@ -30,7 +30,6 @@ class TableHeaderCell extends Component {
const {
className,
name,
label,
columnLabel,
isSortable,
isVisible,
@@ -54,8 +53,7 @@ class TableHeaderCell extends Component {
{...otherProps}
component="th"
className={className}
label={typeof label === 'function' ? label() : label}
title={typeof columnLabel === 'function' ? columnLabel() : columnLabel}
title={columnLabel}
onPress={this.onPress}
>
{children}
@@ -79,8 +77,7 @@ class TableHeaderCell extends Component {
TableHeaderCell.propTypes = {
className: PropTypes.string,
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func, PropTypes.node]),
columnLabel: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
columnLabel: PropTypes.string,
isSortable: PropTypes.bool,
isVisible: PropTypes.bool,
isModifiable: PropTypes.bool,

View File

@@ -35,7 +35,7 @@ function TableOptionsColumn(props) {
isDisabled={isModifiable === false}
onChange={onVisibleChange}
/>
{typeof label === 'function' ? label() : label}
{label}
</label>
{
@@ -56,7 +56,7 @@ function TableOptionsColumn(props) {
TableOptionsColumn.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
label: PropTypes.string.isRequired,
isVisible: PropTypes.bool.isRequired,
isModifiable: PropTypes.bool.isRequired,
index: PropTypes.number.isRequired,

View File

@@ -112,7 +112,7 @@ class TableOptionsColumnDragSource extends Component {
<TableOptionsColumn
name={name}
label={typeof label === 'function' ? label() : label}
label={label}
isVisible={isVisible}
isModifiable={isModifiable}
index={index}
@@ -138,7 +138,7 @@ class TableOptionsColumnDragSource extends Component {
TableOptionsColumnDragSource.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
label: PropTypes.string.isRequired,
isVisible: PropTypes.bool.isRequired,
isModifiable: PropTypes.bool.isRequired,
index: PropTypes.number.isRequired,

View File

@@ -1,6 +1,5 @@
import PropTypes from 'prop-types';
import React from 'react';
import { tooltipPositions } from 'Helpers/Props';
import Tooltip from './Tooltip';
import styles from './Popover.css';
@@ -31,13 +30,8 @@ function Popover(props) {
}
Popover.propTypes = {
className: PropTypes.string,
bodyClassName: PropTypes.string,
anchor: PropTypes.node.isRequired,
title: PropTypes.string.isRequired,
body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
position: PropTypes.oneOf(tooltipPositions.all),
canFlip: PropTypes.bool
body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired
};
export default Popover;

View File

@@ -1,8 +0,0 @@
enum TooltipPosition {
Top = 'top',
Right = 'right',
Bottom = 'bottom',
Left = 'left',
}
export default TooltipPosition;

View File

@@ -6,7 +6,6 @@ export const BOOKSHELF = 'bookshelf';
export const KEY_VALUE_LIST = 'keyValueList';
export const MONITOR_BOOKS_SELECT = 'monitorBooksSelect';
export const MONITOR_NEW_ITEMS_SELECT = 'monitorNewItemsSelect';
export const FLOAT = 'float';
export const NUMBER = 'number';
export const OAUTH = 'oauth';
export const PASSWORD = 'password';
@@ -35,7 +34,6 @@ export const all = [
KEY_VALUE_LIST,
MONITOR_BOOKS_SELECT,
MONITOR_NEW_ITEMS_SELECT,
FLOAT,
NUMBER,
OAUTH,
PASSWORD,

View File

@@ -69,7 +69,7 @@ const columns = [
name: 'customFormats',
label: React.createElement(Icon, {
name: icons.INTERACTIVE,
title: () => translate('CustomFormat')
title: translate('CustomFormat')
}),
isSortable: true,
isVisible: true
@@ -91,9 +91,9 @@ const filterExistingFilesOptions = {
};
const importModeOptions = [
{ key: 'chooseImportMode', value: () => translate('ChooseImportMethod'), disabled: true },
{ key: 'move', value: () => translate('MoveFiles') },
{ key: 'copy', value: () => translate('HardlinkCopyFiles') }
{ key: 'chooseImportMode', value: translate('ChooseImportMethod'), disabled: true },
{ key: 'move', value: translate('MoveFiles') },
{ key: 'copy', value: translate('HardlinkCopyFiles') }
];
const SELECT = 'select';

View File

@@ -1,11 +1,10 @@
import PropTypes from 'prop-types';
import React from 'react';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, kinds, sortDirections } from 'Helpers/Props';
import { icons, sortDirections } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import InteractiveSearchRow from './InteractiveSearchRow';
import styles from './InteractiveSearch.css';
@@ -57,7 +56,7 @@ const columns = [
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
title: translate('CustomFormatScore')
}),
isSortable: true,
isVisible: true
@@ -113,17 +112,17 @@ function InteractiveSearch(props) {
{
!isFetching && isPopulated && !totalReleasesCount ?
<Alert kind={kinds.INFO}>
{translate('NoResultsFound')}
</Alert> :
<div className={styles.blankpad}>
No results found
</div> :
null
}
{
!!totalReleasesCount && isPopulated && !items.length ?
<Alert kind={kinds.WARNING}>
{translate('AllResultsAreHiddenByTheAppliedFilter')}
</Alert> :
<div className={styles.blankpad}>
All results are hidden by the applied filter
</div> :
null
}
@@ -158,7 +157,7 @@ function InteractiveSearch(props) {
{
totalReleasesCount !== items.length && !!items.length ?
<div className={styles.filteredMessage}>
{translate('SomeResultsAreHiddenByTheAppliedFilter')}
Some results are hidden by the applied filter
</div> :
null
}

View File

@@ -32,18 +32,6 @@ function getDownloadIcon(isGrabbing, isGrabbed, grabError) {
return icons.DOWNLOAD;
}
function getDownloadKind(isGrabbed, grabError, downloadAllowed) {
if (isGrabbed) {
return kinds.SUCCESS;
}
if (grabError || !downloadAllowed) {
return kinds.DANGER;
}
return kinds.DEFAULT;
}
function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
if (isGrabbing) {
return '';
@@ -224,7 +212,7 @@ class InteractiveSearchRow extends Component {
{
<SpinnerIconButton
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
kind={getDownloadKind(isGrabbed, grabError, downloadAllowed)}
kind={grabError || !downloadAllowed ? kinds.DANGER : kinds.DEFAULT}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isSpinning={isGrabbing}
onPress={downloadAllowed ? this.onGrabPress : this.onConfirmGrabPress}

View File

@@ -152,7 +152,7 @@ class CustomFormat extends Component {
isOpen={this.state.isDeleteCustomFormatModalOpen}
kind={kinds.DANGER}
title={translate('DeleteCustomFormat')}
message={translate('DeleteCustomFormatMessageText', { name })}
message={translate('DeleteCustomFormatMessageText', [name])}
confirmLabel={translate('Delete')}
isSpinning={isDeleting}
onConfirm={this.onConfirmDeleteCustomFormat}

View File

@@ -115,7 +115,7 @@ class Specification extends Component {
isOpen={this.state.isDeleteSpecificationModalOpen}
kind={kinds.DANGER}
title={translate('DeleteCondition')}
message={translate('DeleteConditionMessageText', { name })}
message={translate('DeleteConditionMessageText', [name])}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteSpecification}
onCancel={this.onDeleteSpecificationModalClose}

View File

@@ -113,7 +113,7 @@ class DownloadClient extends Component {
isOpen={this.state.isDeleteDownloadClientModalOpen}
kind={kinds.DANGER}
title={translate('DeleteDownloadClient')}
message={translate('DeleteDownloadClientMessageText', { name })}
message={translate('DeleteDownloadClientMessageText', [name])}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteDownloadClient}
onCancel={this.onDeleteDownloadClientModalClose}

View File

@@ -27,25 +27,9 @@ interface ManageDownloadClientsEditModalContentProps {
const NO_CHANGE = 'noChange';
const enableOptions = [
{
key: NO_CHANGE,
get value() {
return translate('NoChange');
},
disabled: true,
},
{
key: 'enabled',
get value() {
return translate('Enabled');
},
},
{
key: 'disabled',
get value() {
return translate('Disabled');
},
},
{ key: NO_CHANGE, value: translate('NoChange'), disabled: true },
{ key: 'enabled', value: translate('Enabled') },
{ key: 'disabled', value: translate('Disabled') },
];
function ManageDownloadClientsEditModalContent(
@@ -180,7 +164,7 @@ function ManageDownloadClientsEditModalContent(
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('CountDownloadClientsSelected', { selectedCount })}
{translate('CountDownloadClientsSelected', [selectedCount])}
</div>
<div>

View File

@@ -36,37 +36,37 @@ type OnSelectedChangeCallback = React.ComponentProps<
const COLUMNS = [
{
name: 'name',
label: () => translate('Name'),
label: translate('Name'),
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: () => translate('Implementation'),
label: translate('Implementation'),
isSortable: true,
isVisible: true,
},
{
name: 'enable',
label: () => translate('Enabled'),
label: translate('Enabled'),
isSortable: true,
isVisible: true,
},
{
name: 'priority',
label: () => translate('Priority'),
label: translate('Priority'),
isSortable: true,
isVisible: true,
},
{
name: 'removeCompletedDownloads',
label: () => translate('RemoveCompleted'),
label: translate('RemoveCompleted'),
isSortable: true,
isVisible: true,
},
{
name: 'removeFailedDownloads',
label: () => translate('RemoveFailed'),
label: translate('RemoveFailed'),
isSortable: true,
isVisible: true,
},
@@ -286,9 +286,9 @@ function ManageDownloadClientsModalContent(
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteSelectedDownloadClients')}
message={translate('DeleteSelectedDownloadClientsMessageText', {
count: selectedIds.length,
})}
message={translate('DeleteSelectedDownloadClientsMessageText', [
selectedIds.length,
])}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}

View File

@@ -72,24 +72,9 @@ function TagsModalContent(props: TagsModalContentProps) {
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions = [
{
key: 'add',
get value() {
return translate('Add');
},
},
{
key: 'remove',
get value() {
return translate('Remove');
},
},
{
key: 'replace',
get value() {
return translate('Replace');
},
},
{ key: 'add', value: translate('Add') },
{ key: 'remove', value: translate('Remove') },
{ key: 'replace', value: translate('Replace') },
];
return (

View File

@@ -1,12 +1,10 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { icons, kinds } from 'Helpers/Props';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector';
import RemotePathMapping from './RemotePathMapping';
@@ -52,11 +50,6 @@ class RemotePathMappings extends Component {
errorMessage={translate('UnableToLoadRemotePathMappings')}
{...otherProps}
>
<Alert kind={kinds.INFO}>
<InlineMarkdown data={translate('RemotePathMappingsInfo', { app: 'Readarr', wikiLink: 'https://wiki.servarr.com/readarr/settings#remote-path-mappings' })} />
</Alert>
<div className={styles.remotePathMappingsHeader}>
<div className={styles.host}>
{translate('Host')}

View File

@@ -103,6 +103,7 @@ class GeneralSettings extends Component {
isResettingApiKey,
isWindows,
isWindowsService,
isDocker,
mode,
packageUpdateMechanism,
onInputChange,
@@ -170,6 +171,7 @@ class GeneralSettings extends Component {
settings={settings}
isWindows={isWindows}
packageUpdateMechanism={packageUpdateMechanism}
isDocker={isDocker}
onInputChange={onInputChange}
/>
@@ -212,6 +214,7 @@ GeneralSettings.propTypes = {
hasSettings: PropTypes.bool.isRequired,
isWindows: PropTypes.bool.isRequired,
isWindowsService: PropTypes.bool.isRequired,
isDocker: PropTypes.bool.isRequired,
mode: PropTypes.string.isRequired,
packageUpdateMechanism: PropTypes.string.isRequired,
onInputChange: PropTypes.func.isRequired,

View File

@@ -26,6 +26,7 @@ function createMapStateToProps() {
isResettingApiKey,
isWindows: systemStatus.isWindows,
isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service',
isDocker: systemStatus.isDocker,
mode: systemStatus.mode,
packageUpdateMechanism: systemStatus.packageUpdateMechanism,
...sectionSettings

View File

@@ -8,17 +8,12 @@ import { inputTypes, sizes } from 'Helpers/Props';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
const branchValues = [
'master',
'develop',
'nightly'
];
function UpdateSettings(props) {
const {
advancedSettings,
settings,
isWindows,
isDocker,
packageUpdateMechanism,
onInputChange
} = props;
@@ -49,21 +44,32 @@ function UpdateSettings(props) {
updateOptions.push({ key: 'script', value: 'Script' });
if (isDocker) {
return (
<FieldSet legend={translate('Updates')}>
<div>
{translate('UpdatingIsDisabledInsideADockerContainerUpdateTheContainerImageInstead')}
</div>
</FieldSet>
);
}
return (
<FieldSet legend={translate('Updates')}>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>{translate('Branch')}</FormLabel>
<FormLabel>
{translate('Branch')}
</FormLabel>
<FormInputGroup
type={inputTypes.AUTO_COMPLETE}
type={inputTypes.TEXT}
name="branch"
helpText={usingExternalUpdateMechanism ? translate('UsingExternalUpdateMechanismBranchUsedByExternalUpdateMechanism') : translate('UsingExternalUpdateMechanismBranchToUseToUpdateReadarr')}
helpLink="https://wiki.servarr.com/readarr/faq#how-do-I-update-my-readarr"
{...branch}
values={branchValues}
onChange={onInputChange}
readOnly={usingExternalUpdateMechanism}
/>
@@ -77,13 +83,14 @@ function UpdateSettings(props) {
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('Automatic')}</FormLabel>
<FormLabel>
{translate('Automatic')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="updateAutomatically"
helpText={translate('UpdateAutomaticallyHelpText')}
helpTextWarning={updateMechanism.value === 'docker' ? translate('AutomaticUpdatesDisabledDocker', { appName: 'Readarr' }) : undefined}
onChange={onInputChange}
{...updateAutomatically}
/>
@@ -93,7 +100,9 @@ function UpdateSettings(props) {
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>{translate('Mechanism')}</FormLabel>
<FormLabel>
{translate('Mechanism')}
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
@@ -112,7 +121,9 @@ function UpdateSettings(props) {
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>{translate('ScriptPath')}</FormLabel>
<FormLabel>
{translate('ScriptPath')}
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
@@ -133,6 +144,7 @@ UpdateSettings.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
settings: PropTypes.object.isRequired,
isWindows: PropTypes.bool.isRequired,
isDocker: PropTypes.bool.isRequired,
packageUpdateMechanism: PropTypes.string.isRequired,
onInputChange: PropTypes.func.isRequired
};

View File

@@ -107,7 +107,7 @@ class ImportList extends Component {
isOpen={this.state.isDeleteImportListModalOpen}
kind={kinds.DANGER}
title={translate('DeleteImportList')}
message={translate('DeleteImportListMessageText', { name })}
message={translate('DeleteImportListMessageText', [name])}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteImportList}
onCancel={this.onDeleteImportListModalClose}

View File

@@ -27,25 +27,9 @@ interface ManageImportListsEditModalContentProps {
const NO_CHANGE = 'noChange';
const autoAddOptions = [
{
key: NO_CHANGE,
get value() {
return translate('NoChange');
},
disabled: true,
},
{
key: 'enabled',
get value() {
return translate('Enabled');
},
},
{
key: 'disabled',
get value() {
return translate('Disabled');
},
},
{ key: NO_CHANGE, value: translate('NoChange'), disabled: true },
{ key: 'enabled', value: translate('Enabled') },
{ key: 'disabled', value: translate('Disabled') },
];
function ManageImportListsEditModalContent(
@@ -184,7 +168,7 @@ function ManageImportListsEditModalContent(
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('CountImportListsSelected', { selectedCount })}
{translate('CountImportListsSelected', [selectedCount])}
</div>
<div>

View File

@@ -36,43 +36,43 @@ type OnSelectedChangeCallback = React.ComponentProps<
const COLUMNS = [
{
name: 'name',
label: () => translate('Name'),
label: translate('Name'),
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: () => translate('Implementation'),
label: translate('Implementation'),
isSortable: true,
isVisible: true,
},
{
name: 'qualityProfileId',
label: () => translate('QualityProfile'),
label: translate('QualityProfile'),
isSortable: true,
isVisible: true,
},
{
name: 'metadataProfileId',
label: () => translate('MetadataProfile'),
label: translate('MetadataProfile'),
isSortable: true,
isVisible: true,
},
{
name: 'rootFolderPath',
label: () => translate('RootFolder'),
label: translate('RootFolder'),
isSortable: true,
isVisible: true,
},
{
name: 'enableAutomaticAdd',
label: () => translate('AutoAdd'),
label: translate('AutoAdd'),
isSortable: true,
isVisible: true,
},
{
name: 'tags',
label: () => translate('Tags'),
label: translate('Tags'),
isSortable: true,
isVisible: true,
},
@@ -283,9 +283,9 @@ function ManageImportListsModalContent(
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteSelectedImportLists')}
message={translate('DeleteSelectedImportListsMessageText', {
count: selectedIds.length,
})}
message={translate('DeleteSelectedImportListsMessageText', [
selectedIds.length,
])}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}

View File

@@ -70,24 +70,9 @@ function TagsModalContent(props: TagsModalContentProps) {
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions = [
{
key: 'add',
get value() {
return translate('Add');
},
},
{
key: 'remove',
get value() {
return translate('Remove');
},
},
{
key: 'replace',
get value() {
return translate('Replace');
},
},
{ key: 'add', value: translate('Add') },
{ key: 'remove', value: translate('Remove') },
{ key: 'replace', value: translate('Replace') },
];
return (

View File

@@ -152,7 +152,7 @@ class Indexer extends Component {
isOpen={this.state.isDeleteIndexerModalOpen}
kind={kinds.DANGER}
title={translate('DeleteIndexer')}
message={translate('DeleteIndexerMessageText', { name })}
message={translate('DeleteIndexerMessageText', [name])}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteIndexer}
onCancel={this.onDeleteIndexerModalClose}

View File

@@ -27,25 +27,9 @@ interface ManageIndexersEditModalContentProps {
const NO_CHANGE = 'noChange';
const enableOptions = [
{
key: NO_CHANGE,
get value() {
return translate('NoChange');
},
disabled: true,
},
{
key: 'enabled',
get value() {
return translate('Enabled');
},
},
{
key: 'disabled',
get value() {
return translate('Disabled');
},
},
{ key: NO_CHANGE, value: translate('NoChange'), disabled: true },
{ key: 'enabled', value: translate('Enabled') },
{ key: 'disabled', value: translate('Disabled') },
];
function ManageIndexersEditModalContent(
@@ -178,7 +162,7 @@ function ManageIndexersEditModalContent(
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('CountIndexersSelected', { selectedCount })}
{translate('CountIndexersSelected', [selectedCount])}
</div>
<div>

View File

@@ -36,43 +36,43 @@ type OnSelectedChangeCallback = React.ComponentProps<
const COLUMNS = [
{
name: 'name',
label: () => translate('Name'),
label: translate('Name'),
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: () => translate('Implementation'),
label: translate('Implementation'),
isSortable: true,
isVisible: true,
},
{
name: 'enableRss',
label: () => translate('EnableRSS'),
label: translate('EnableRSS'),
isSortable: true,
isVisible: true,
},
{
name: 'enableAutomaticSearch',
label: () => translate('EnableAutomaticSearch'),
label: translate('EnableAutomaticSearch'),
isSortable: true,
isVisible: true,
},
{
name: 'enableInteractiveSearch',
label: () => translate('EnableInteractiveSearch'),
label: translate('EnableInteractiveSearch'),
isSortable: true,
isVisible: true,
},
{
name: 'priority',
label: () => translate('Priority'),
label: translate('Priority'),
isSortable: true,
isVisible: true,
},
{
name: 'tags',
label: () => translate('Tags'),
label: translate('Tags'),
isSortable: true,
isVisible: true,
},
@@ -281,9 +281,9 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteSelectedIndexers')}
message={translate('DeleteSelectedIndexersMessageText', {
count: selectedIds.length,
})}
message={translate('DeleteSelectedIndexersMessageText', [
selectedIds.length,
])}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}

View File

@@ -70,24 +70,9 @@ function TagsModalContent(props: TagsModalContentProps) {
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions = [
{
key: 'add',
get value() {
return translate('Add');
},
},
{
key: 'remove',
get value() {
return translate('Remove');
},
},
{
key: 'replace',
get value() {
return translate('Replace');
},
},
{ key: 'add', value: translate('Add') },
{ key: 'remove', value: translate('Remove') },
{ key: 'replace', value: translate('Replace') },
];
return (

View File

@@ -95,7 +95,7 @@ class RootFolder extends Component {
isOpen={this.state.isDeleteRootFolderModalOpen}
kind={kinds.DANGER}
title={translate('DeleteRootFolder')}
message={translate('DeleteRootFolderMessageText', { name })}
message={translate('DeleteRootFolderMessageText', [name])}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteRootFolder}
onCancel={this.onDeleteRootFolderModalClose}

View File

@@ -11,51 +11,16 @@ import { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
const writeAudioTagOptions = [
{
key: 'no',
get value() {
return translate('WriteTagsNo');
}
},
{
key: 'sync',
get value() {
return translate('WriteTagsSync');
}
},
{
key: 'allFiles',
get value() {
return translate('WriteTagsAll');
}
},
{
key: 'newFiles',
get value() {
return translate('WriteTagsNew');
}
}
{ key: 'no', value: translate('WriteTagsNo') },
{ key: 'sync', value: translate('WriteTagsSync') },
{ key: 'allFiles', value: translate('WriteTagsAll') },
{ key: 'newFiles', value: translate('WriteTagsNew') }
];
const writeBookTagOptions = [
{
key: 'sync',
get value() {
return translate('WriteTagsSync');
}
},
{
key: 'allFiles',
get value() {
return translate('WriteTagsAll');
}
},
{
key: 'newFiles',
get value() {
return translate('WriteTagsNew');
}
}
{ key: 'sync', value: translate('WriteTagsSync') },
{ key: 'allFiles', value: translate('WriteTagsAll') },
{ key: 'newFiles', value: translate('WriteTagsNew') }
];
function MetadataProvider(props) {

View File

@@ -227,7 +227,7 @@ class Notification extends Component {
isOpen={this.state.isDeleteNotificationModalOpen}
kind={kinds.DANGER}
title={translate('DeleteNotification')}
message={translate('DeleteNotificationMessageText', { name })}
message={translate('DeleteNotificationMessageText', [name])}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteNotification}
onCancel={this.onDeleteNotificationModalClose}

View File

@@ -144,7 +144,7 @@ class MetadataProfile extends Component {
isOpen={this.state.isDeleteMetadataProfileModalOpen}
kind={kinds.DANGER}
title={translate('DeleteMetadataProfile')}
message={translate('DeleteMetadataProfileMessageText', { name })}
message={translate('DeleteMetadataProfileMessageText', [name])}
confirmLabel={translate('Delete')}
isSpinning={isDeleting}
onConfirm={this.onConfirmDeleteMetadataProfile}

View File

@@ -162,7 +162,7 @@ class QualityProfile extends Component {
isOpen={this.state.isDeleteQualityProfileModalOpen}
kind={kinds.DANGER}
title={translate('DeleteQualityProfile')}
message={translate('DeleteQualityProfileMessageText', { name })}
message={translate('DeleteQualityProfileMessageText', [name])}
confirmLabel={translate('Delete')}
isSpinning={isDeleting}
onConfirm={this.onConfirmDeleteQualityProfile}

View File

@@ -139,7 +139,7 @@ function Settings() {
className={styles.link}
to="/settings/ui"
>
{translate('Ui')}
{translate('UI')}
</Link>
<div className={styles.summary}>

View File

@@ -4,8 +4,6 @@ import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHand
import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
import monitorNewItemsOptions from 'Utilities/Author/monitorNewItemsOptions';
import monitorOptions from 'Utilities/Author/monitorOptions';
//
// Variables
@@ -53,10 +51,6 @@ export default {
port: 8080,
useSsl: false,
outputProfile: 'default',
defaultQualityProfileId: 0,
defaultMetadataProfileId: 0,
defaultMonitorOption: monitorOptions[0].key,
defaultNewItemMonitorOption: monitorNewItemsOptions[0].key,
defaultTags: []
},
isSaving: false,

View File

@@ -4,7 +4,6 @@ import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
import { fetchTranslations as fetchAppTranslations } from 'Utilities/String/translate';
import createHandleActions from './Creators/createHandleActions';
function getDimensions(width, height) {
@@ -42,12 +41,7 @@ export const defaultState = {
isReconnecting: false,
isDisconnected: false,
isRestarting: false,
isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen,
translations: {
isFetching: true,
isPopulated: false,
error: null
}
isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen
};
//
@@ -59,7 +53,6 @@ export const SAVE_DIMENSIONS = 'app/saveDimensions';
export const SET_VERSION = 'app/setVersion';
export const SET_APP_VALUE = 'app/setAppValue';
export const SET_IS_SIDEBAR_VISIBLE = 'app/setIsSidebarVisible';
export const FETCH_TRANSLATIONS = 'app/fetchTranslations';
export const PING_SERVER = 'app/pingServer';
@@ -73,7 +66,6 @@ export const setAppValue = createAction(SET_APP_VALUE);
export const showMessage = createAction(SHOW_MESSAGE);
export const hideMessage = createAction(HIDE_MESSAGE);
export const pingServer = createThunk(PING_SERVER);
export const fetchTranslations = createThunk(FETCH_TRANSLATIONS);
//
// Helpers
@@ -135,17 +127,6 @@ function pingServerAfterTimeout(getState, dispatch) {
export const actionHandlers = handleThunks({
[PING_SERVER]: function(getState, payload, dispatch) {
pingServerAfterTimeout(getState, dispatch);
},
[FETCH_TRANSLATIONS]: async function(getState, payload, dispatch) {
const isFetchingComplete = await fetchAppTranslations();
dispatch(setAppValue({
translations: {
isFetching: false,
isPopulated: isFetchingComplete,
error: isFetchingComplete ? null : 'Failed to load translations from API'
}
}));
}
});

View File

@@ -24,12 +24,12 @@ export const section = 'books';
export const filters = [
{
key: 'all',
label: () => translate('All'),
label: translate('All'),
filters: []
},
{
key: 'monitored',
label: () => translate('Monitored'),
label: translate('Monitored'),
filters: [
{
key: 'monitored',
@@ -40,7 +40,7 @@ export const filters = [
},
{
key: 'unmonitored',
label: () => translate('Unmonitored'),
label: translate('Unmonitored'),
filters: [
{
key: 'monitored',
@@ -51,7 +51,7 @@ export const filters = [
},
{
key: 'missing',
label: () => translate('Missing'),
label: translate('Missing'),
filters: [
{
key: 'monitored',
@@ -67,7 +67,7 @@ export const filters = [
},
{
key: 'wanted',
label: () => translate('Wanted'),
label: translate('Wanted'),
filters: [
{
key: 'monitored',

View File

@@ -60,32 +60,32 @@ export const defaultState = {
columns: [
{
name: 'status',
columnLabel: () => translate('Status'),
columnLabel: translate('Status'),
isSortable: true,
isVisible: true,
isModifiable: false
},
{
name: 'authorMetadata.sortName',
label: () => translate('Author'),
label: translate('Author'),
isSortable: true,
isVisible: true
},
{
name: 'books.title',
label: () => translate('BookTitle'),
label: translate('BookTitle'),
isSortable: true,
isVisible: true
},
{
name: 'books.releaseDate',
label: () => translate('ReleaseDate'),
label: translate('ReleaseDate'),
isSortable: true,
isVisible: false
},
{
name: 'quality',
label: () => translate('Quality'),
label: translate('Quality'),
isSortable: true,
isVisible: true
},
@@ -97,64 +97,64 @@ export const defaultState = {
},
{
name: 'customFormatScore',
columnLabel: () => translate('CustomFormatScore'),
columnLabel: translate('CustomFormatScore'),
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
title: translate('CustomFormatScore')
}),
isVisible: false
},
{
name: 'protocol',
label: () => translate('Protocol'),
label: translate('Protocol'),
isSortable: true,
isVisible: false
},
{
name: 'indexer',
label: () => translate('Indexer'),
label: translate('Indexer'),
isSortable: true,
isVisible: false
},
{
name: 'downloadClient',
label: () => translate('DownloadClient'),
label: translate('DownloadClient'),
isSortable: true,
isVisible: false
},
{
name: 'title',
label: () => translate('ReleaseTitle'),
label: translate('ReleaseTitle'),
isSortable: true,
isVisible: false
},
{
name: 'size',
label: () => translate('Size'),
label: translate('Size'),
isSortable: true,
isVisible: false
},
{
name: 'outputPath',
label: () => translate('OutputPath'),
label: translate('OutputPath'),
isSortable: false,
isVisible: false
},
{
name: 'estimatedCompletionTime',
label: () => translate('TimeLeft'),
label: translate('TimeLeft'),
isSortable: true,
isVisible: true
},
{
name: 'progress',
label: () => translate('Progress'),
label: translate('Progress'),
isSortable: true,
isVisible: true
},
{
name: 'actions',
columnLabel: () => translate('Actions'),
columnLabel: translate('Actions'),
isVisible: true,
isModifiable: false
}

View File

@@ -199,7 +199,7 @@ export const defaultState = {
},
{
name: 'customFormatScore',
label: () => translate('CustomFormatScore'),
label: translate('CustomFormatScore'),
type: filterBuilderTypes.NUMBER
},
{

View File

@@ -82,34 +82,34 @@ export const defaultState = {
columns: [
{
name: 'level',
columnLabel: () => translate('Level'),
columnLabel: translate('Level'),
isSortable: false,
isVisible: true,
isModifiable: false
},
{
name: 'time',
label: () => translate('Time'),
label: translate('Time'),
isSortable: true,
isVisible: true,
isModifiable: false
},
{
name: 'logger',
label: () => translate('Component'),
label: translate('Component'),
isSortable: false,
isVisible: true,
isModifiable: false
},
{
name: 'message',
label: () => translate('Message'),
label: translate('Message'),
isVisible: true,
isModifiable: false
},
{
name: 'actions',
columnLabel: () => translate('Actions'),
columnLabel: translate('Actions'),
isSortable: true,
isVisible: true,
isModifiable: false

View File

@@ -36,17 +36,10 @@ function mergeColumns(path, initialState, persistedState, computedState) {
const column = initialColumns.find((i) => i.name === persistedColumn.name);
if (column) {
const newColumn = {};
// We can't use a spread operator or Object.assign to clone the column
// or any accessors are lost and can break translations.
for (const prop of Object.keys(column)) {
Object.defineProperty(newColumn, prop, Object.getOwnPropertyDescriptor(column, prop));
}
newColumn.isVisible = persistedColumn.isVisible;
columns.push(newColumn);
columns.push({
...column,
isVisible: persistedColumn.isVisible
});
}
});

View File

@@ -138,7 +138,7 @@ class BackupRow extends Component {
isOpen={isConfirmDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteBackup')}
message={translate('DeleteBackupMessageText', { name })}
message={translate('DeleteBackupMessageText', [name])}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeletePress}
onCancel={this.onConfirmDeleteModalClose}

View File

@@ -21,17 +21,17 @@ const columns = [
},
{
name: 'name',
label: () => translate('Name'),
label: 'Name',
isVisible: true
},
{
name: 'size',
label: () => translate('Size'),
label: 'Size',
isVisible: true
},
{
name: 'time',
label: () => translate('Time'),
label: 'Time',
isVisible: true
},
{

View File

@@ -146,7 +146,7 @@ class RestoreBackupModalContent extends Component {
<ModalBody>
{
!!id && translate('WouldYouLikeToRestoreBackup', { name })
!!id && `Would you like to restore the backup '${name}'?`
}
{

View File

@@ -19,12 +19,12 @@ import LogFilesTableRow from './LogFilesTableRow';
const columns = [
{
name: 'filename',
label: () => translate('Filename'),
label: 'Filename',
isVisible: true
},
{
name: 'lastWriteTime',
label: () => translate('LastWriteTime'),
label: 'Last Write Time',
isVisible: true
},
{

View File

@@ -15,17 +15,17 @@ import styles from './DiskSpace.css';
const columns = [
{
name: 'path',
label: () => translate('Location'),
label: 'Location',
isVisible: true
},
{
name: 'freeSpace',
label: () => translate('FreeSpace'),
label: 'Free Space',
isVisible: true
},
{
name: 'totalSpace',
label: () => translate('TotalSpace'),
label: 'Total Space',
isVisible: true
},
{

View File

@@ -39,14 +39,6 @@ function getInternalLink(source) {
to="/settings/downloadclients"
/>
);
case 'NotificationStatusCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/settings/connect"
/>
);
case 'RootFolderCheck':
return (
<IconButton
@@ -103,12 +95,12 @@ const columns = [
},
{
name: 'message',
label: () => translate('Message'),
label: 'Message',
isVisible: true
},
{
name: 'actions',
label: () => translate('Actions'),
label: 'Actions',
isVisible: true
}
];

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
import createAjaxRequest from 'Utilities/createAjaxRequest';
function getTranslations() {
return createAjaxRequest({
global: false,
dataType: 'json',
url: '/localization'
}).request;
}
let translations = {};
export function fetchTranslations() {
return new Promise(async(resolve) => {
try {
const data = await getTranslations();
translations = data.Strings;
resolve(true);
} catch (error) {
resolve(false);
}
});
}
export default function translate(key, args = []) {
const translation = translations[key] || key;
if (args) {
return translation.replace(/\{(\d+)\}/g, (match, index) => {
return args[index];
});
}
return translation;
}

View File

@@ -1,44 +0,0 @@
import createAjaxRequest from 'Utilities/createAjaxRequest';
function getTranslations() {
return createAjaxRequest({
global: false,
dataType: 'json',
url: '/localization',
}).request;
}
let translations: Record<string, string> = {};
export async function fetchTranslations(): Promise<boolean> {
return new Promise(async (resolve) => {
try {
const data = await getTranslations();
translations = data.Strings;
resolve(true);
} catch (error) {
resolve(false);
}
});
}
export default function translate(
key: string,
tokens?: Record<string, string | number | boolean>
) {
const translation = translations[key] || key;
if (tokens) {
// Fallback to the old behaviour for translations not yet updated to use named tokens
Object.values(tokens).forEach((value, index) => {
tokens[index] = value;
});
return translation.replace(/\{([a-z0-9]+?)\}/gi, (match, tokenMatch) =>
String(tokens[tokenMatch] ?? match)
);
}
return translation;
}

View File

@@ -1,15 +0,0 @@
import { createBrowserHistory } from 'history';
import React from 'react';
import { render } from 'react-dom';
import createAppStore from 'Store/createAppStore';
import App from './App/App';
export async function bootstrap() {
const history = createBrowserHistory();
const store = createAppStore(history);
render(
<App store={store} history={history} />,
document.getElementById('root')
);
}

View File

@@ -48,15 +48,7 @@
/>
<link rel="stylesheet" type="text/css" href="/Content/Fonts/fonts.css">
<script>
window.Readarr = {
urlBase: '__URL_BASE__'
};
</script>
<% for (key in htmlWebpackPlugin.files.js) { %><script type="text/javascript" src="<%= htmlWebpackPlugin.files.js[key] %>" data-no-hash></script><% } %>
<% for (key in htmlWebpackPlugin.files.css) { %><link href="<%= htmlWebpackPlugin.files.css[key] %>" rel="stylesheet"></link><% } %>
<!-- webpack bundles head -->
<title>Readarr</title>
@@ -85,4 +77,7 @@
<div id="portal-root"></div>
<div id="root" class="root"></div>
</body>
<script src="/initialize.js" data-no-hash></script>
<!-- webpack bundles body -->
</html>

26
frontend/src/index.js Normal file
View File

@@ -0,0 +1,26 @@
import { createBrowserHistory } from 'history';
import React from 'react';
import { render } from 'react-dom';
import { fetchTranslations } from 'Utilities/String/translate';
import './preload';
import './polyfills';
import 'Styles/globals.css';
import './index.css';
const history = createBrowserHistory();
const hasTranslationsError = !await fetchTranslations();
const { default: createAppStore } = await import('Store/createAppStore');
const { default: App } = await import('./App/App');
const store = createAppStore(history);
render(
<App
store={store}
history={history}
hasTranslationsError={hasTranslationsError}
/>,
document.getElementById('root')
);

View File

@@ -1,19 +0,0 @@
import './polyfills';
import 'Styles/globals.css';
import './index.css';
const initializeUrl = `${
window.Readarr.urlBase
}/initialize.json?t=${Date.now()}`;
const response = await fetch(initializeUrl);
window.Readarr = await response.json();
/* eslint-disable no-undef, @typescript-eslint/ban-ts-comment */
// @ts-ignore 2304
__webpack_public_path__ = `${window.Readarr.urlBase}/`;
/* eslint-enable no-undef, @typescript-eslint/ban-ts-comment */
const { bootstrap } = await import('./bootstrap');
await bootstrap();

View File

@@ -1,11 +1,11 @@
{
"compilerOptions": {
"target": "esnext",
"target": "es6",
"allowJs": true,
"checkJs": false,
"baseUrl": "src",
"jsx": "react",
"module": "esnext",
"module": "commonjs",
"moduleResolution": "node",
"noEmit": true,
"esModuleInterop": true,

View File

@@ -20,7 +20,7 @@
"author": "Team Readarr",
"license": "GPL-3.0",
"readmeFilename": "readme.md",
"main": "index.ts",
"main": "index.js",
"browserslist": [
"defaults"
],
@@ -30,7 +30,7 @@
"@fortawesome/free-regular-svg-icons": "6.4.0",
"@fortawesome/free-solid-svg-icons": "6.4.0",
"@fortawesome/react-fontawesome": "0.2.0",
"@microsoft/signalr": "6.0.21",
"@microsoft/signalr": "6.0.16",
"@sentry/browser": "7.51.2",
"@sentry/integrations": "7.51.2",
"@types/node": "18.16.16",
@@ -83,27 +83,28 @@
"redux-localstorage": "0.4.1",
"redux-thunk": "2.3.0",
"reselect": "4.1.8",
"stacktrace-js": "2.0.2",
"typescript": "4.9.5"
},
"devDependencies": {
"@babel/core": "7.22.11",
"@babel/eslint-parser": "7.22.11",
"@babel/core": "7.22.9",
"@babel/eslint-parser": "7.22.9",
"@babel/plugin-proposal-class-properties": "7.18.6",
"@babel/plugin-proposal-export-default-from": "7.22.5",
"@babel/plugin-proposal-export-namespace-from": "7.18.9",
"@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6",
"@babel/plugin-proposal-optional-chaining": "7.21.0",
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/preset-env": "7.22.15",
"@babel/preset-env": "7.22.9",
"@babel/preset-react": "7.22.5",
"@babel/preset-typescript": "7.22.11",
"@types/lodash": "4.14.197",
"@types/redux-actions": "2.6.2",
"@typescript-eslint/eslint-plugin": "6.5.0",
"@typescript-eslint/parser": "6.5.0",
"@babel/preset-typescript": "7.22.5",
"@typescript-eslint/eslint-plugin": "6.0.0",
"@typescript-eslint/parser": "6.0.0",
"autoprefixer": "10.4.14",
"babel-loader": "9.1.3",
"babel-plugin-inline-classnames": "2.0.1",
"babel-plugin-transform-react-remove-prop-types": "0.4.24",
"core-js": "3.32.1",
"css-loader": "6.8.1",
"core-js": "3.31.1",
"css-loader": "6.7.3",
"css-modules-typescript-loader": "4.0.1",
"eslint": "8.44.0",
"eslint-config-prettier": "8.8.0",
@@ -117,9 +118,9 @@
"file-loader": "6.2.0",
"filemanager-webpack-plugin": "8.0.0",
"fork-ts-checker-webpack-plugin": "8.0.0",
"html-webpack-plugin": "5.5.3",
"html-webpack-plugin": "5.5.1",
"loader-utils": "^3.2.1",
"mini-css-extract-plugin": "2.7.6",
"mini-css-extract-plugin": "2.7.5",
"postcss": "8.4.23",
"postcss-color-function": "4.1.0",
"postcss-loader": "7.3.0",
@@ -132,14 +133,14 @@
"rimraf": "4.4.1",
"run-sequence": "2.2.1",
"streamqueue": "1.1.2",
"style-loader": "3.3.3",
"stylelint": "15.10.3",
"style-loader": "3.3.2",
"stylelint": "15.10.1",
"stylelint-order": "6.0.3",
"terser-webpack-plugin": "5.3.9",
"ts-loader": "9.4.4",
"ts-loader": "9.4.3",
"typescript-plugin-css-modules": "5.0.1",
"url-loader": "4.1.1",
"webpack": "5.88.2",
"webpack": "5.88.1",
"webpack-cli": "5.1.4",
"webpack-livereload-plugin": "3.0.2",
"worker-loader": "3.0.8"

View File

@@ -44,16 +44,16 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopIcon"; Description: "{cm:CreateDesktopIcon}"
Name: "windowsService"; Description: "Install Windows Service (Starts when the computer starts as the LocalService user, you will need to change the user to access network shares)"; GroupDescription: "Start automatically"; Flags: exclusive
Name: "startupShortcut"; Description: "Create shortcut in Startup folder (Starts when you log into Windows)"; GroupDescription: "Start automatically"; Flags: exclusive unchecked
Name: "windowsService"; Description: "Install Windows Service (Starts when the computer starts as the LocalService user, you will need to change the user to access network shares)"; GroupDescription: "Start automatically"; Flags: exclusive unchecked
Name: "startupShortcut"; Description: "Create shortcut in Startup folder (Starts when you log into Windows)"; GroupDescription: "Start automatically"; Flags: exclusive
Name: "none"; Description: "Do not start automatically"; GroupDescription: "Start automatically"; Flags: exclusive unchecked
[Dirs]
Name: "{app}"; Permissions: users-modify
[Files]
Source: "..\..\..\_artifacts\{#Runtime}\{#Framework}\Readarr\Readarr.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
Source: "..\..\..\_artifacts\{#Runtime}\{#Framework}\Readarr\*"; Excludes: "Readarr.Update"; DestDir: "{app}\bin"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "..\_artifacts\{#Runtime}\{#Framework}\Readarr\Readarr.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
Source: "..\_artifacts\{#Runtime}\{#Framework}\Readarr\*"; Excludes: "Readarr.Update"; DestDir: "{app}\bin"; Flags: ignoreversion recursesubdirs createallsubdirs
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Icons]
@@ -72,13 +72,12 @@ Filename: "{app}\bin\Readarr.exe"; Description: "Open Readarr Web UI"; Flags: po
Filename: "{app}\bin\Readarr.exe"; Description: "Start Readarr"; Flags: postinstall skipifsilent nowait; Tasks: startupShortcut none;
[UninstallRun]
Filename: "{app}\bin\readarr.console.exe"; Parameters: "/u"; Flags: waituntilterminated skipifdoesntexist
Filename: "{app}\bin\Readarr.Console.exe"; Parameters: "/u"; Flags: waituntilterminated skipifdoesntexist
[Code]
function PrepareToInstall(var NeedsRestart: Boolean): String;
var
ResultCode: Integer;
begin
Exec('net', 'stop readarr', '', 0, ewWaitUntilTerminated, ResultCode)
Exec('sc', 'delete readarr', '', 0, ewWaitUntilTerminated, ResultCode)
Exec(ExpandConstant('{commonappdata}\Readarr\bin\Readarr.Console.exe'), '/u', '', 0, ewWaitUntilTerminated, ResultCode)
end;

View File

@@ -4,7 +4,7 @@
<PackageVersion Include="AutoFixture" Version="4.17.0" />
<PackageVersion Include="coverlet.collector" Version="3.0.4-preview.27.ge7cb7c3b40" PrivateAssets="all" />
<PackageVersion Include="Dapper" Version="2.0.123" />
<PackageVersion Include="DryIoc.dll" Version="5.4.1" />
<PackageVersion Include="DryIoc.dll" Version="5.4.0" />
<PackageVersion Include="DryIoc.Microsoft.DependencyInjection" Version="6.2.0" />
<PackageVersion Include="Equ" Version="2.3.0" />
<PackageVersion Include="FluentAssertions" Version="5.10.3" />
@@ -16,7 +16,7 @@
<PackageVersion Include="ImpromptuInterface" Version="7.0.1" />
<PackageVersion Include="LazyCache" Version="2.4.0" />
<PackageVersion Include="Mailkit" Version="3.6.0" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="6.0.21" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="6.0.16" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="6.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
@@ -32,7 +32,7 @@
<PackageVersion Include="NLog.Extensions.Logging" Version="5.2.3" />
<PackageVersion Include="NLog" Version="5.1.4" />
<PackageVersion Include="NLog.Targets.Syslog" Version="7.0.0" />
<PackageVersion Include="Npgsql" Version="7.0.4" />
<PackageVersion Include="Npgsql" Version="6.0.9" />
<PackageVersion Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageVersion Include="NUnit" Version="3.13.3" />
<PackageVersion Include="NunitXml.TestLogger" Version="3.0.117" />
@@ -43,7 +43,7 @@
<PackageVersion Include="Selenium.WebDriver.ChromeDriver" Version="91.0.4472.10100" />
<PackageVersion Include="Sentry" Version="3.31.0" />
<PackageVersion Include="SharpZipLib" Version="1.4.2" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.0.2" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.0.1" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.1.118" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerGen" Version="6.5.0" />
<PackageVersion Include="System.Buffers" Version="4.5.1" />
@@ -57,10 +57,10 @@
<PackageVersion Include="System.Resources.Extensions" Version="6.0.0" />
<PackageVersion Include="System.Runtime.Loader" Version="4.3.0" />
<PackageVersion Include="System.Security.Principal.Windows" Version="5.0.0" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="6.0.1" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="6.0.0" />
<PackageVersion Include="System.Text.Encoding.CodePages" Version="6.0.0" />
<PackageVersion Include="System.Text.Json" Version="6.0.8" />
<PackageVersion Include="System.Text.Json" Version="6.0.7" />
<PackageVersion Include="System.ValueTuple" Version="4.5.0" />
<PackageVersion Include="TagLibSharp-Lidarr" Version="2.2.0.19" />
</ItemGroup>
</Project>
</Project>

View File

@@ -40,10 +40,6 @@ namespace NzbDrone.Automation.Test.PageModel
var element = d.FindElement(By.ClassName("followingBalls"));
return !element.Displayed;
}
catch (StaleElementReferenceException)
{
return true;
}
catch (NoSuchElementException)
{
return true;

View File

@@ -838,7 +838,7 @@ namespace NzbDrone.Common.Test.DiskTests
// Note: never returns anything.
Mocker.GetMock<IDiskProvider>()
.Setup(v => v.GetFileInfos(It.IsAny<string>(), It.IsAny<bool>()))
.Setup(v => v.GetFileInfos(It.IsAny<string>(), It.IsAny<SearchOption>()))
.Returns(new List<IFileInfo>());
Mocker.GetMock<IDiskProvider>()
@@ -878,8 +878,8 @@ namespace NzbDrone.Common.Test.DiskTests
.Returns<string>(v => fileSystem.DirectoryInfo.FromDirectoryName(v).GetDirectories().ToList());
Mocker.GetMock<IDiskProvider>()
.Setup(v => v.GetFileInfos(It.IsAny<string>(), It.IsAny<bool>()))
.Returns((string v, bool recursive) => fileSystem.DirectoryInfo.FromDirectoryName(v).GetFiles("*", new EnumerationOptions { RecurseSubdirectories = recursive }).ToList());
.Setup(v => v.GetFileInfos(It.IsAny<string>(), It.IsAny<SearchOption>()))
.Returns((string v, SearchOption option) => fileSystem.DirectoryInfo.FromDirectoryName(v).GetFiles("*", option).ToList());
Mocker.GetMock<IDiskProvider>()
.Setup(v => v.GetFileSize(It.IsAny<string>()))

View File

@@ -6,7 +6,6 @@ using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Moq;
using NLog;
@@ -115,21 +114,21 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_execute_simple_get()
public void should_execute_simple_get()
{
var request = new HttpRequest($"https://{_httpBinHost}/get");
var response = await Subject.ExecuteAsync(request);
var response = Subject.Execute(request);
response.Content.Should().NotBeNullOrWhiteSpace();
}
[Test]
public async Task should_execute_https_get()
public void should_execute_https_get()
{
var request = new HttpRequest($"https://{_httpBinHost}/get");
var response = await Subject.ExecuteAsync(request);
var response = Subject.Execute(request);
response.Content.Should().NotBeNullOrWhiteSpace();
}
@@ -141,47 +140,47 @@ namespace NzbDrone.Common.Test.Http
Mocker.GetMock<IConfigService>().SetupGet(x => x.CertificateValidation).Returns(validationType);
var request = new HttpRequest($"https://expired.badssl.com");
Assert.ThrowsAsync<HttpRequestException>(async () => await Subject.ExecuteAsync(request));
Assert.Throws<HttpRequestException>(() => Subject.Execute(request));
ExceptionVerification.ExpectedErrors(1);
}
[Test]
public async Task bad_ssl_should_pass_if_remote_validation_disabled()
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");
await Subject.ExecuteAsync(request);
Subject.Execute(request);
ExceptionVerification.ExpectedErrors(0);
}
[Test]
public async Task should_execute_typed_get()
public void should_execute_typed_get()
{
var request = new HttpRequest($"https://{_httpBinHost}/get?test=1");
var response = await Subject.GetAsync<HttpBinResource>(request);
var response = Subject.Get<HttpBinResource>(request);
response.Resource.Url.EndsWith("/get?test=1");
response.Resource.Args.Should().Contain("test", "1");
}
[Test]
public async Task should_execute_simple_post()
public void should_execute_simple_post()
{
var message = "{ my: 1 }";
var request = new HttpRequest($"https://{_httpBinHost}/post");
request.SetContent(message);
var response = await Subject.PostAsync<HttpBinResource>(request);
var response = Subject.Post<HttpBinResource>(request);
response.Resource.Data.Should().Be(message);
}
[Test]
public async Task should_execute_post_with_content_type()
public void should_execute_post_with_content_type()
{
var message = "{ my: 1 }";
@@ -189,16 +188,17 @@ namespace NzbDrone.Common.Test.Http
request.SetContent(message);
request.Headers.ContentType = "application/json";
var response = await Subject.PostAsync<HttpBinResource>(request);
var response = Subject.Post<HttpBinResource>(request);
response.Resource.Data.Should().Be(message);
}
[Test]
public async Task should_execute_get_using_gzip()
public void should_execute_get_using_gzip()
{
var request = new HttpRequest($"https://{_httpBinHost}/gzip");
var response = await Subject.GetAsync<HttpBinResource>(request);
var response = Subject.Get<HttpBinResource>(request);
response.Resource.Headers["Accept-Encoding"].ToString().Should().Contain("gzip");
@@ -208,10 +208,11 @@ namespace NzbDrone.Common.Test.Http
[Test]
[Platform(Exclude = "MacOsX", Reason = "Azure agent update prevents brotli on OSX")]
public async Task should_execute_get_using_brotli()
public void should_execute_get_using_brotli()
{
var request = new HttpRequest($"https://{_httpBinHost}/brotli");
var response = await Subject.GetAsync<HttpBinResource>(request);
var response = Subject.Get<HttpBinResource>(request);
response.Resource.Headers["Accept-Encoding"].ToString().Should().Contain("br");
@@ -229,7 +230,7 @@ namespace NzbDrone.Common.Test.Http
{
var request = new HttpRequest($"https://{_httpBinHost}/status/{statusCode}");
var exception = Assert.ThrowsAsync<HttpException>(async () => await Subject.GetAsync<HttpBinResource>(request));
var exception = Assert.Throws<HttpException>(() => Subject.Get<HttpBinResource>(request));
((int)exception.Response.StatusCode).Should().Be(statusCode);
@@ -242,7 +243,7 @@ namespace NzbDrone.Common.Test.Http
var request = new HttpRequest($"https://{_httpBinHost}/status/{HttpStatusCode.NotFound}");
request.SuppressHttpErrorStatusCodes = new[] { HttpStatusCode.NotFound };
Assert.ThrowsAsync<HttpException>(async () => await Subject.GetAsync<HttpBinResource>(request));
var exception = Assert.Throws<HttpException>(() => Subject.Get<HttpBinResource>(request));
ExceptionVerification.IgnoreWarns();
}
@@ -252,7 +253,7 @@ namespace NzbDrone.Common.Test.Http
{
var request = new HttpRequest($"https://{_httpBinHost}/status/{HttpStatusCode.NotFound}");
var exception = Assert.ThrowsAsync<HttpException>(async () => await Subject.GetAsync<HttpBinResource>(request));
var exception = Assert.Throws<HttpException>(() => Subject.Get<HttpBinResource>(request));
ExceptionVerification.ExpectedWarns(1);
}
@@ -263,28 +264,28 @@ namespace NzbDrone.Common.Test.Http
var request = new HttpRequest($"https://{_httpBinHost}/status/{HttpStatusCode.NotFound}");
request.LogHttpError = false;
Assert.ThrowsAsync<HttpException>(async () => await Subject.GetAsync<HttpBinResource>(request));
var exception = Assert.Throws<HttpException>(() => Subject.Get<HttpBinResource>(request));
ExceptionVerification.ExpectedWarns(0);
}
[Test]
public async Task should_not_follow_redirects_when_not_in_production()
public void should_not_follow_redirects_when_not_in_production()
{
var request = new HttpRequest($"https://{_httpBinHost}/redirect/1");
await Subject.GetAsync(request);
Subject.Get(request);
ExceptionVerification.ExpectedErrors(1);
}
[Test]
public async Task should_follow_redirects()
public void should_follow_redirects()
{
var request = new HttpRequest($"https://{_httpBinHost}/redirect/1");
request.AllowAutoRedirect = true;
var response = await Subject.GetAsync(request);
var response = Subject.Get(request);
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -292,12 +293,12 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_not_follow_redirects()
public void should_not_follow_redirects()
{
var request = new HttpRequest($"https://{_httpBinHost}/redirect/1");
request.AllowAutoRedirect = false;
var response = await Subject.GetAsync(request);
var response = Subject.Get(request);
response.StatusCode.Should().Be(HttpStatusCode.Found);
@@ -305,14 +306,14 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_follow_redirects_to_https()
public void should_follow_redirects_to_https()
{
var request = new HttpRequestBuilder($"https://{_httpBinHost}/redirect-to")
.AddQueryParam("url", $"https://readarr.com/")
.Build();
request.AllowAutoRedirect = true;
var response = await Subject.GetAsync(request);
var response = Subject.Get(request);
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Should().Contain("Readarr");
@@ -326,17 +327,17 @@ namespace NzbDrone.Common.Test.Http
var request = new HttpRequest($"https://{_httpBinHost}/redirect/6");
request.AllowAutoRedirect = true;
Assert.ThrowsAsync<WebException>(async () => await Subject.GetAsync(request));
Assert.Throws<WebException>(() => Subject.Get(request));
ExceptionVerification.ExpectedErrors(0);
}
[Test]
public async Task should_send_user_agent()
public void should_send_user_agent()
{
var request = new HttpRequest($"https://{_httpBinHost}/get");
var response = await Subject.GetAsync<HttpBinResource>(request);
var response = Subject.Get<HttpBinResource>(request);
response.Resource.Headers.Should().ContainKey("User-Agent");
@@ -346,24 +347,24 @@ namespace NzbDrone.Common.Test.Http
}
[TestCase("Accept", "text/xml, text/rss+xml, application/rss+xml")]
public async Task should_send_headers(string header, string value)
public void should_send_headers(string header, string value)
{
var request = new HttpRequest($"https://{_httpBinHost}/get");
request.Headers.Add(header, value);
var response = await Subject.GetAsync<HttpBinResource>(request);
var response = Subject.Get<HttpBinResource>(request);
response.Resource.Headers[header].ToString().Should().Be(value);
}
[Test]
public async Task should_download_file()
public void should_download_file()
{
var file = GetTempFilePath();
var url = "https://readarr.com/img/slider/artistdetails.png";
await Subject.DownloadFileAsync(url, file);
Subject.DownloadFile(url, file);
var fileInfo = new FileInfo(file);
fileInfo.Exists.Should().BeTrue();
@@ -371,7 +372,7 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_download_file_with_redirect()
public void should_download_file_with_redirect()
{
var file = GetTempFilePath();
@@ -379,7 +380,7 @@ namespace NzbDrone.Common.Test.Http
.AddQueryParam("url", $"https://readarr.com/img/slider/artistdetails.png")
.Build();
await Subject.DownloadFileAsync(request.Url.FullUri, file);
Subject.DownloadFile(request.Url.FullUri, file);
ExceptionVerification.ExpectedErrors(0);
@@ -393,7 +394,7 @@ namespace NzbDrone.Common.Test.Http
{
var file = GetTempFilePath();
Assert.ThrowsAsync<HttpException>(async () => await Subject.DownloadFileAsync("https://download.sonarr.tv/wrongpath", file));
Assert.Throws<HttpException>(() => Subject.DownloadFile("https://download.sonarr.tv/wrongpath", file));
File.Exists(file).Should().BeFalse();
@@ -401,7 +402,7 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_not_write_redirect_content_to_stream()
public void should_not_write_redirect_content_to_stream()
{
var file = GetTempFilePath();
@@ -411,7 +412,7 @@ namespace NzbDrone.Common.Test.Http
request.AllowAutoRedirect = false;
request.ResponseStream = fileStream;
var response = await Subject.GetAsync(request);
var response = Subject.Get(request);
response.StatusCode.Should().Be(HttpStatusCode.Moved);
}
@@ -426,12 +427,12 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_send_cookie()
public void should_send_cookie()
{
var request = new HttpRequest($"https://{_httpBinHost}/get");
request.Cookies["my"] = "cookie";
var response = await Subject.GetAsync<HttpBinResource>(request);
var response = Subject.Get<HttpBinResource>(request);
response.Resource.Headers.Should().ContainKey("Cookie");
@@ -460,13 +461,13 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_preserve_cookie_during_session()
public void should_preserve_cookie_during_session()
{
GivenOldCookie();
var request = new HttpRequest($"https://{_httpBinHost2}/get");
var response = await Subject.GetAsync<HttpBinResource>(request);
var response = Subject.Get<HttpBinResource>(request);
response.Resource.Headers.Should().ContainKey("Cookie");
@@ -476,30 +477,30 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_not_send_cookie_to_other_host()
public void should_not_send_cookie_to_other_host()
{
GivenOldCookie();
var request = new HttpRequest($"https://{_httpBinHost}/get");
var response = await Subject.GetAsync<HttpBinResource>(request);
var response = Subject.Get<HttpBinResource>(request);
response.Resource.Headers.Should().NotContainKey("Cookie");
}
[Test]
public async Task should_not_store_request_cookie()
public void should_not_store_request_cookie()
{
var requestGet = new HttpRequest($"https://{_httpBinHost}/get");
requestGet.Cookies.Add("my", "cookie");
requestGet.AllowAutoRedirect = false;
requestGet.StoreRequestCookie = false;
requestGet.StoreResponseCookie = false;
var responseGet = await Subject.GetAsync<HttpBinResource>(requestGet);
var responseGet = Subject.Get<HttpBinResource>(requestGet);
var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies");
requestCookies.AllowAutoRedirect = false;
var responseCookies = await Subject.GetAsync<HttpCookieResource>(requestCookies);
var responseCookies = Subject.Get<HttpCookieResource>(requestCookies);
responseCookies.Resource.Cookies.Should().BeEmpty();
@@ -507,18 +508,18 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_store_request_cookie()
public void should_store_request_cookie()
{
var requestGet = new HttpRequest($"https://{_httpBinHost}/get");
requestGet.Cookies.Add("my", "cookie");
requestGet.AllowAutoRedirect = false;
requestGet.StoreRequestCookie.Should().BeTrue();
requestGet.StoreResponseCookie = false;
var responseGet = await Subject.GetAsync<HttpBinResource>(requestGet);
var responseGet = Subject.Get<HttpBinResource>(requestGet);
var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies");
requestCookies.AllowAutoRedirect = false;
var responseCookies = await Subject.GetAsync<HttpCookieResource>(requestCookies);
var responseCookies = Subject.Get<HttpCookieResource>(requestCookies);
responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie");
@@ -526,7 +527,7 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_delete_request_cookie()
public void should_delete_request_cookie()
{
var requestDelete = new HttpRequest($"https://{_httpBinHost}/cookies/delete?my");
requestDelete.Cookies.Add("my", "cookie");
@@ -535,13 +536,13 @@ namespace NzbDrone.Common.Test.Http
requestDelete.StoreResponseCookie = false;
// Delete and redirect since that's the only way to check the internal temporary cookie container
var responseCookies = await Subject.GetAsync<HttpCookieResource>(requestDelete);
var responseCookies = Subject.Get<HttpCookieResource>(requestDelete);
responseCookies.Resource.Cookies.Should().BeEmpty();
}
[Test]
public async Task should_clear_request_cookie()
public void should_clear_request_cookie()
{
var requestSet = new HttpRequest($"https://{_httpBinHost}/cookies");
requestSet.Cookies.Add("my", "cookie");
@@ -549,7 +550,7 @@ namespace NzbDrone.Common.Test.Http
requestSet.StoreRequestCookie = true;
requestSet.StoreResponseCookie = false;
var responseSet = await Subject.GetAsync<HttpCookieResource>(requestSet);
var responseSet = Subject.Get<HttpCookieResource>(requestSet);
var requestClear = new HttpRequest($"https://{_httpBinHost}/cookies");
requestClear.Cookies.Add("my", null);
@@ -557,24 +558,24 @@ namespace NzbDrone.Common.Test.Http
requestClear.StoreRequestCookie = true;
requestClear.StoreResponseCookie = false;
var responseClear = await Subject.GetAsync<HttpCookieResource>(requestClear);
var responseClear = Subject.Get<HttpCookieResource>(requestClear);
responseClear.Resource.Cookies.Should().BeEmpty();
}
[Test]
public async Task should_not_store_response_cookie()
public void should_not_store_response_cookie()
{
var requestSet = new HttpRequest($"https://{_httpBinHost}/cookies/set?my=cookie");
requestSet.AllowAutoRedirect = false;
requestSet.StoreRequestCookie = false;
requestSet.StoreResponseCookie.Should().BeFalse();
var responseSet = await Subject.GetAsync(requestSet);
var responseSet = Subject.Get(requestSet);
var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies");
var responseCookies = await Subject.GetAsync<HttpCookieResource>(requestCookies);
var responseCookies = Subject.Get<HttpCookieResource>(requestCookies);
responseCookies.Resource.Cookies.Should().BeEmpty();
@@ -582,18 +583,18 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_store_response_cookie()
public void should_store_response_cookie()
{
var requestSet = new HttpRequest($"https://{_httpBinHost}/cookies/set?my=cookie");
requestSet.AllowAutoRedirect = false;
requestSet.StoreRequestCookie = false;
requestSet.StoreResponseCookie = true;
var responseSet = await Subject.GetAsync(requestSet);
var responseSet = Subject.Get(requestSet);
var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies");
var responseCookies = await Subject.GetAsync<HttpCookieResource>(requestCookies);
var responseCookies = Subject.Get<HttpCookieResource>(requestCookies);
responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie");
@@ -601,13 +602,13 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_temp_store_response_cookie()
public void should_temp_store_response_cookie()
{
var requestSet = new HttpRequest($"https://{_httpBinHost}/cookies/set?my=cookie");
requestSet.AllowAutoRedirect = true;
requestSet.StoreRequestCookie = false;
requestSet.StoreResponseCookie.Should().BeFalse();
var responseSet = await Subject.GetAsync<HttpCookieResource>(requestSet);
var responseSet = Subject.Get<HttpCookieResource>(requestSet);
// Set and redirect since that's the only way to check the internal temporary cookie container
responseSet.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie");
@@ -616,7 +617,7 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_overwrite_response_cookie()
public void should_overwrite_response_cookie()
{
var requestSet = new HttpRequest($"https://{_httpBinHost}/cookies/set?my=cookie");
requestSet.Cookies.Add("my", "oldcookie");
@@ -624,11 +625,11 @@ namespace NzbDrone.Common.Test.Http
requestSet.StoreRequestCookie = false;
requestSet.StoreResponseCookie = true;
var responseSet = await Subject.GetAsync(requestSet);
var responseSet = Subject.Get(requestSet);
var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies");
var responseCookies = await Subject.GetAsync<HttpCookieResource>(requestCookies);
var responseCookies = Subject.Get<HttpCookieResource>(requestCookies);
responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie");
@@ -636,7 +637,7 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_overwrite_temp_response_cookie()
public void should_overwrite_temp_response_cookie()
{
var requestSet = new HttpRequest($"https://{_httpBinHost}/cookies/set?my=cookie");
requestSet.Cookies.Add("my", "oldcookie");
@@ -644,13 +645,13 @@ namespace NzbDrone.Common.Test.Http
requestSet.StoreRequestCookie = true;
requestSet.StoreResponseCookie = false;
var responseSet = await Subject.GetAsync<HttpCookieResource>(requestSet);
var responseSet = Subject.Get<HttpCookieResource>(requestSet);
responseSet.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie");
var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies");
var responseCookies = await Subject.GetAsync<HttpCookieResource>(requestCookies);
var responseCookies = Subject.Get<HttpCookieResource>(requestCookies);
responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "oldcookie");
@@ -658,14 +659,14 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_not_delete_response_cookie()
public void should_not_delete_response_cookie()
{
var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies");
requestCookies.Cookies.Add("my", "cookie");
requestCookies.AllowAutoRedirect = false;
requestCookies.StoreRequestCookie = true;
requestCookies.StoreResponseCookie = false;
var responseCookies = await Subject.GetAsync<HttpCookieResource>(requestCookies);
var responseCookies = Subject.Get<HttpCookieResource>(requestCookies);
responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie");
@@ -674,13 +675,13 @@ namespace NzbDrone.Common.Test.Http
requestDelete.StoreRequestCookie = false;
requestDelete.StoreResponseCookie = false;
var responseDelete = await Subject.GetAsync(requestDelete);
var responseDelete = Subject.Get(requestDelete);
requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies");
requestCookies.StoreRequestCookie = false;
requestCookies.StoreResponseCookie = false;
responseCookies = await Subject.GetAsync<HttpCookieResource>(requestCookies);
responseCookies = Subject.Get<HttpCookieResource>(requestCookies);
responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie");
@@ -688,14 +689,14 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_delete_response_cookie()
public void should_delete_response_cookie()
{
var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies");
requestCookies.Cookies.Add("my", "cookie");
requestCookies.AllowAutoRedirect = false;
requestCookies.StoreRequestCookie = true;
requestCookies.StoreResponseCookie = false;
var responseCookies = await Subject.GetAsync<HttpCookieResource>(requestCookies);
var responseCookies = Subject.Get<HttpCookieResource>(requestCookies);
responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie");
@@ -704,13 +705,13 @@ namespace NzbDrone.Common.Test.Http
requestDelete.StoreRequestCookie = false;
requestDelete.StoreResponseCookie = true;
var responseDelete = await Subject.GetAsync(requestDelete);
var responseDelete = Subject.Get(requestDelete);
requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies");
requestCookies.StoreRequestCookie = false;
requestCookies.StoreResponseCookie = false;
responseCookies = await Subject.GetAsync<HttpCookieResource>(requestCookies);
responseCookies = Subject.Get<HttpCookieResource>(requestCookies);
responseCookies.Resource.Cookies.Should().BeEmpty();
@@ -718,14 +719,14 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_delete_temp_response_cookie()
public void should_delete_temp_response_cookie()
{
var requestCookies = new HttpRequest($"https://{_httpBinHost}/cookies");
requestCookies.Cookies.Add("my", "cookie");
requestCookies.AllowAutoRedirect = false;
requestCookies.StoreRequestCookie = true;
requestCookies.StoreResponseCookie = false;
var responseCookies = await Subject.GetAsync<HttpCookieResource>(requestCookies);
var responseCookies = Subject.Get<HttpCookieResource>(requestCookies);
responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie");
@@ -733,7 +734,7 @@ namespace NzbDrone.Common.Test.Http
requestDelete.AllowAutoRedirect = true;
requestDelete.StoreRequestCookie = false;
requestDelete.StoreResponseCookie = false;
var responseDelete = await Subject.GetAsync<HttpCookieResource>(requestDelete);
var responseDelete = Subject.Get<HttpCookieResource>(requestDelete);
responseDelete.Resource.Cookies.Should().BeEmpty();
@@ -751,13 +752,13 @@ namespace NzbDrone.Common.Test.Http
{
var request = new HttpRequest($"https://{_httpBinHost}/status/429");
Assert.ThrowsAsync<TooManyRequestsException>(async () => await Subject.GetAsync(request));
Assert.Throws<TooManyRequestsException>(() => Subject.Get(request));
ExceptionVerification.IgnoreWarns();
}
[Test]
public async Task should_call_interceptor()
public void should_call_interceptor()
{
Mocker.SetConstant<IEnumerable<IHttpRequestInterceptor>>(new[] { Mocker.GetMock<IHttpRequestInterceptor>().Object });
@@ -771,7 +772,7 @@ namespace NzbDrone.Common.Test.Http
var request = new HttpRequest($"https://{_httpBinHost}/get");
await Subject.GetAsync(request);
Subject.Get(request);
Mocker.GetMock<IHttpRequestInterceptor>()
.Verify(v => v.PreRequest(It.IsAny<HttpRequest>()), Times.Once());
@@ -782,7 +783,7 @@ namespace NzbDrone.Common.Test.Http
[TestCase("en-US")]
[TestCase("es-ES")]
public async Task should_parse_malformed_cloudflare_cookie(string culture)
public void should_parse_malformed_cloudflare_cookie(string culture)
{
var origCulture = Thread.CurrentThread.CurrentCulture;
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo(culture);
@@ -798,11 +799,11 @@ namespace NzbDrone.Common.Test.Http
requestSet.AllowAutoRedirect = false;
requestSet.StoreResponseCookie = true;
var responseSet = await Subject.GetAsync(requestSet);
var responseSet = Subject.Get(requestSet);
var request = new HttpRequest($"https://{_httpBinHost}/get");
var response = await Subject.GetAsync<HttpBinResource>(request);
var response = Subject.Get<HttpBinResource>(request);
response.Resource.Headers.Should().ContainKey("Cookie");
@@ -820,7 +821,7 @@ namespace NzbDrone.Common.Test.Http
}
[TestCase("lang_code=en; expires=Wed, 23-Dec-2026 18:09:14 GMT; Max-Age=31536000; path=/; domain=.abc.com")]
public async Task should_reject_malformed_domain_cookie(string malformedCookie)
public void should_reject_malformed_domain_cookie(string malformedCookie)
{
try
{
@@ -830,11 +831,11 @@ namespace NzbDrone.Common.Test.Http
requestSet.AllowAutoRedirect = false;
requestSet.StoreResponseCookie = true;
var responseSet = await Subject.GetAsync(requestSet);
var responseSet = Subject.Get(requestSet);
var request = new HttpRequest($"https://{_httpBinHost}/get");
var response = await Subject.GetAsync<HttpBinResource>(request);
var response = Subject.Get<HttpBinResource>(request);
response.Resource.Headers.Should().NotContainKey("Cookie");
@@ -846,12 +847,12 @@ namespace NzbDrone.Common.Test.Http
}
[Test]
public async Task should_correctly_use_basic_auth()
public void should_correctly_use_basic_auth_with_basic_network_credential()
{
var request = new HttpRequest($"https://{_httpBinHost}/basic-auth/username/password");
request.Credentials = new BasicNetworkCredential("username", "password");
var response = await Subject.ExecuteAsync(request);
var response = Subject.Execute(request);
response.StatusCode.Should().Be(HttpStatusCode.OK);
}

View File

@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.IO;
using ICSharpCode.SharpZipLib.Core;
using ICSharpCode.SharpZipLib.GZip;
@@ -12,7 +11,7 @@ namespace NzbDrone.Common
public interface IArchiveService
{
void Extract(string compressedFile, string destination);
void CreateZip(string path, IEnumerable<string> files);
void CreateZip(string path, params string[] files);
}
public class ArchiveService : IArchiveService
@@ -40,7 +39,7 @@ namespace NzbDrone.Common
_logger.Debug("Extraction complete.");
}
public void CreateZip(string path, IEnumerable<string> files)
public void CreateZip(string path, params string[] files)
{
using (var zipFile = ZipFile.Create(path))
{

View File

@@ -53,7 +53,7 @@ namespace NzbDrone.Common.Disk
{
CheckFolderExists(path);
var dirFiles = GetFiles(path, true).ToList();
var dirFiles = GetFiles(path, SearchOption.AllDirectories).ToList();
if (!dirFiles.Any())
{
@@ -156,11 +156,11 @@ namespace NzbDrone.Common.Disk
return _fileSystem.Directory.EnumerateFileSystemEntries(path).Empty();
}
public IEnumerable<string> GetDirectories(string path)
public string[] GetDirectories(string path)
{
Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs);
return _fileSystem.Directory.EnumerateDirectories(path);
return _fileSystem.Directory.GetDirectories(path);
}
public string[] GetDirectories(string path, SearchOption searchOption)
@@ -170,22 +170,18 @@ namespace NzbDrone.Common.Disk
return _fileSystem.Directory.GetDirectories(path, "*", searchOption);
}
public IEnumerable<string> GetFiles(string path, bool recursive)
public string[] GetFiles(string path, SearchOption searchOption)
{
Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs);
return _fileSystem.Directory.EnumerateFiles(path, "*", new EnumerationOptions
{
RecurseSubdirectories = recursive,
IgnoreInaccessible = true
});
return _fileSystem.Directory.GetFiles(path, "*.*", searchOption);
}
public long GetFolderSize(string path)
{
Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs);
return GetFiles(path, true).Sum(e => _fileSystem.FileInfo.FromFileName(e).Length);
return GetFiles(path, SearchOption.AllDirectories).Sum(e => _fileSystem.FileInfo.FromFileName(e).Length);
}
public long GetFileSize(string path)
@@ -306,9 +302,8 @@ namespace NzbDrone.Common.Disk
{
Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs);
var files = GetFiles(path, recursive);
files.ToList().ForEach(RemoveReadOnly);
var files = _fileSystem.Directory.GetFiles(path, "*.*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly);
Array.ForEach(files, RemoveReadOnly);
_fileSystem.Directory.Delete(path, recursive);
}
@@ -409,7 +404,7 @@ namespace NzbDrone.Common.Disk
{
Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs);
foreach (var file in GetFiles(path, false))
foreach (var file in GetFiles(path, SearchOption.TopDirectoryOnly))
{
DeleteFile(file);
}
@@ -509,17 +504,13 @@ namespace NzbDrone.Common.Disk
return _fileSystem.DirectoryInfo.FromDirectoryName(path);
}
public List<IFileInfo> GetFileInfos(string path, bool recursive = false)
public List<IFileInfo> GetFileInfos(string path, SearchOption searchOption = SearchOption.TopDirectoryOnly)
{
Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs);
var di = _fileSystem.DirectoryInfo.FromDirectoryName(path);
return di.EnumerateFiles("*", new EnumerationOptions
{
RecurseSubdirectories = recursive,
IgnoreInaccessible = true
}).ToList();
return di.GetFiles("*", searchOption).ToList();
}
public IFileInfo GetFileInfo(string path)

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