Compare commits

..

No commits in common. "develop" and "v2.2.1.4073" have entirely different histories.

699 changed files with 8471 additions and 20602 deletions

View file

@ -1,13 +0,0 @@
// This file is used to open the backend and frontend in the same workspace, which is necessary as
// the frontend has vscode settings that are distinct from the backend
{
"folders": [
{
"path": ".."
},
{
"path": "../frontend"
}
],
"settings": {}
}

View file

@ -1,19 +0,0 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet
{
"name": "Lidarr",
"image": "mcr.microsoft.com/devcontainers/dotnet:1-6.0",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"nodeGypDependencies": true,
"version": "20",
"nvmVersion": "latest"
}
},
"forwardPorts": [8686],
"customizations": {
"vscode": {
"extensions": ["esbenp.prettier-vscode"]
}
}
}

View file

@ -60,7 +60,6 @@ body:
- Master - Master
- Develop - Develop
- Nightly - Nightly
- Plugins (experimental)
- Other (This issue will be closed) - Other (This issue will be closed)
validations: validations:
required: true required: true

View file

@ -1,12 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for more information:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
# https://containers.dev/guide/dependabot
version: 2
updates:
- package-ecosystem: "devcontainers"
directory: "/"
schedule:
interval: weekly

View file

@ -12,6 +12,6 @@ jobs:
action: action:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: dessant/label-actions@v4 - uses: dessant/label-actions@v3
with: with:
process-only: 'issues' process-only: 'issues'

View file

@ -9,7 +9,7 @@ jobs:
lock: lock:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: dessant/lock-threads@v5 - uses: dessant/lock-threads@v4
with: with:
github-token: ${{ github.token }} github-token: ${{ github.token }}
issue-inactive-days: '90' issue-inactive-days: '90'

36
.gitignore vendored
View file

@ -121,13 +121,11 @@ _artifacts
_rawPackage/ _rawPackage/
_dotTrace* _dotTrace*
_tests/ _tests/
_temp*
*.Result.xml *.Result.xml
coverage*.xml coverage*.xml
coverage*.json coverage*.json
setup/Output/ setup/Output/
*.~is *.~is
.mono
# VS outout folders # VS outout folders
bin bin
@ -140,6 +138,12 @@ project.fragment.lock.json
artifacts/ artifacts/
**/Properties/launchSettings.json **/Properties/launchSettings.json
#VS outout folders
bin
obj
output/*
# macOS metadata files # macOS metadata files
._* ._*
.DS_Store .DS_Store
@ -158,12 +162,34 @@ Thumbs.db
/tools/Addins/* /tools/Addins/*
packages.config.md5sum packages.config.md5sum
# Common IntelliJ Platform excludes
# User specific
**/.idea/**/workspace.xml
**/.idea/**/tasks.xml
**/.idea/shelf/*
**/.idea/dictionaries
**/.idea/.idea.Radarr.Posix
**/.idea/.idea.Radarr.Windows
# Sensitive or high-churn files
**/.idea/**/dataSources/
**/.idea/**/dataSources.ids
**/.idea/**/dataSources.xml
**/.idea/**/dataSources.local.xml
**/.idea/**/sqlDataSources.xml
**/.idea/**/dynamic.xml
# Rider
# Rider auto-generates .iml files, and contentModel.xml
**/.idea/**/*.iml
**/.idea/**/contentModel.xml
**/.idea/**/modules.xml
# ignore node_modules symlink # ignore node_modules symlink
node_modules node_modules
node_modules.nosync node_modules.nosync
# API doc generation # API doc generation
.config/ .config/
# Ignore Jetbrains IntelliJ Workspace Directories
.idea/

View file

@ -1,7 +0,0 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"ms-dotnettools.csdevkit",
"ms-vscode-remote.remote-containers"
]
}

26
.vscode/launch.json vendored
View file

@ -1,26 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
// For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md
"name": "Run Lidarr",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build dotnet",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/_output/net6.0/Lidarr",
"args": [],
"cwd": "${workspaceFolder}",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
"console": "integratedTerminal",
"stopAtEntry": false
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}

44
.vscode/tasks.json vendored
View file

@ -1,44 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build dotnet",
"command": "dotnet",
"type": "process",
"args": [
"msbuild",
"-restore",
"${workspaceFolder}/src/Lidarr.sln",
"-p:GenerateFullPaths=true",
"-p:Configuration=Debug",
"-p:Platform=Posix",
"-consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/src/Lidarr.sln",
"-property:GenerateFullPaths=true",
"-consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/src/Lidarr.sln"
],
"problemMatcher": "$msCompile"
}
]
}

View file

@ -1,7 +1,6 @@
# Lidarr # Lidarr
[![Build Status](https://dev.azure.com/Lidarr/Lidarr/_apis/build/status/lidarr.Lidarr?branchName=develop)](https://dev.azure.com/Lidarr/Lidarr/_build/latest?definitionId=1&branchName=develop) [![Build Status](https://dev.azure.com/Lidarr/Lidarr/_apis/build/status/lidarr.Lidarr?branchName=develop)](https://dev.azure.com/Lidarr/Lidarr/_build/latest?definitionId=1&branchName=develop)
[![Translation status](https://translate.servarr.com/widget/servarr/lidarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/?utm_source=widget)
[![Docker Pulls](https://img.shields.io/docker/pulls/linuxserver/lidarr.svg)](https://wiki.servarr.com/lidarr/installation#docker) [![Docker Pulls](https://img.shields.io/docker/pulls/linuxserver/lidarr.svg)](https://wiki.servarr.com/lidarr/installation#docker)
![Github Downloads](https://img.shields.io/github/downloads/lidarr/lidarr/total.svg) ![Github Downloads](https://img.shields.io/github/downloads/lidarr/lidarr/total.svg)
[![Backers on Open Collective](https://opencollective.com/lidarr/backers/badge.svg)](#backers) [![Backers on Open Collective](https://opencollective.com/lidarr/backers/badge.svg)](#backers)
@ -9,9 +8,6 @@
Lidarr is a music collection manager for Usenet and BitTorrent users. It can monitor multiple RSS feeds for new tracks from your favorite artists and will grab, sort and rename them. It can also be configured to automatically upgrade the quality of files already downloaded when a better quality format becomes available. Lidarr is a music collection manager for Usenet and BitTorrent users. It can monitor multiple RSS feeds for new tracks from your favorite artists and will grab, sort and rename them. It can also be configured to automatically upgrade the quality of files already downloaded when a better quality format becomes available.
> [!WARNING]
> NOTICE - The Lidarr Metadata Server is currently down impacting adding artists, etc. Please follow [GHI 5498](https://github.com/Lidarr/Lidarr/issues/5498) or see Discord for detaila.
## Major Features Include: ## Major Features Include:
* Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc. * Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc.

View file

@ -9,18 +9,18 @@ variables:
testsFolder: './_tests' testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '2.13.3' majorVersion: '2.2.1'
minorVersion: $[counter('minorVersion', 1076)] minorVersion: $[counter('minorVersion', 1076)]
lidarrVersion: '$(majorVersion).$(minorVersion)' lidarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(lidarrVersion)' buildName: '$(Build.SourceBranchName).$(lidarrVersion)'
sentryOrg: 'servarr' sentryOrg: 'servarr'
sentryUrl: 'https://sentry.servarr.com' sentryUrl: 'https://sentry.servarr.com'
dotnetVersion: '6.0.427' dotnetVersion: '6.0.417'
nodeVersion: '20.X' nodeVersion: '20.X'
innoVersion: '6.2.0' innoVersion: '6.2.0'
windowsImage: 'windows-2022' windowsImage: 'windows-2022'
linuxImage: 'ubuntu-22.04' linuxImage: 'ubuntu-20.04'
macImage: 'macOS-13' macImage: 'macOS-11'
trigger: trigger:
branches: branches:
@ -166,10 +166,10 @@ stages:
pool: pool:
vmImage: $(imageName) vmImage: $(imageName)
steps: steps:
- task: UseNode@1 - task: NodeTool@0
displayName: Set Node.js version displayName: Set Node.js version
inputs: inputs:
version: $(nodeVersion) versionSpec: $(nodeVersion)
- checkout: self - checkout: self
submodules: true submodules: true
fetchDepth: 1 fetchDepth: 1
@ -1093,10 +1093,10 @@ stages:
pool: pool:
vmImage: $(imageName) vmImage: $(imageName)
steps: steps:
- task: UseNode@1 - task: NodeTool@0
displayName: Set Node.js version displayName: Set Node.js version
inputs: inputs:
version: $(nodeVersion) versionSpec: $(nodeVersion)
- checkout: self - checkout: self
submodules: true submodules: true
fetchDepth: 1 fetchDepth: 1
@ -1120,19 +1120,19 @@ stages:
vmImage: ${{ variables.windowsImage }} vmImage: ${{ variables.windowsImage }}
steps: steps:
- checkout: self # Need history for Sonar analysis - checkout: self # Need history for Sonar analysis
- task: SonarCloudPrepare@3 - task: SonarCloudPrepare@1
env: env:
SONAR_SCANNER_OPTS: '' SONAR_SCANNER_OPTS: ''
inputs: inputs:
SonarCloud: 'SonarCloud' SonarCloud: 'SonarCloud'
organization: 'lidarr' organization: 'lidarr'
scannerMode: 'cli' scannerMode: 'CLI'
configMode: 'manual' configMode: 'manual'
cliProjectKey: 'lidarr_Lidarr.UI' cliProjectKey: 'lidarr_Lidarr.UI'
cliProjectName: 'LidarrUI' cliProjectName: 'LidarrUI'
cliProjectVersion: '$(lidarrVersion)' cliProjectVersion: '$(lidarrVersion)'
cliSources: './frontend' cliSources: './frontend'
- task: SonarCloudAnalyze@3 - task: SonarCloudAnalyze@1
- job: Api_Docs - job: Api_Docs
displayName: API Docs displayName: API Docs
@ -1208,12 +1208,12 @@ stages:
submodules: true submodules: true
- powershell: Set-Service SCardSvr -StartupType Manual - powershell: Set-Service SCardSvr -StartupType Manual
displayName: Enable Windows Test Service displayName: Enable Windows Test Service
- task: SonarCloudPrepare@3 - task: SonarCloudPrepare@1
condition: eq(variables['System.PullRequest.IsFork'], 'False') condition: eq(variables['System.PullRequest.IsFork'], 'False')
inputs: inputs:
SonarCloud: 'SonarCloud' SonarCloud: 'SonarCloud'
organization: 'lidarr' organization: 'lidarr'
scannerMode: 'dotnet' scannerMode: 'MSBuild'
projectKey: 'lidarr_Lidarr' projectKey: 'lidarr_Lidarr'
projectName: 'Lidarr' projectName: 'Lidarr'
projectVersion: '$(lidarrVersion)' projectVersion: '$(lidarrVersion)'
@ -1226,16 +1226,21 @@ stages:
./build.sh --backend -f net6.0 -r win-x64 ./build.sh --backend -f net6.0 -r win-x64
TEST_DIR=_tests/net6.0/win-x64/publish/ ./test.sh Windows Unit Coverage TEST_DIR=_tests/net6.0/win-x64/publish/ ./test.sh Windows Unit Coverage
displayName: Coverage Unit Tests displayName: Coverage Unit Tests
- task: SonarCloudAnalyze@3 - task: SonarCloudAnalyze@1
condition: eq(variables['System.PullRequest.IsFork'], 'False') condition: eq(variables['System.PullRequest.IsFork'], 'False')
displayName: Publish SonarCloud Results displayName: Publish SonarCloud Results
- task: reportgenerator@5.3.11 - task: reportgenerator@4
displayName: Generate Coverage Report displayName: Generate Coverage Report
inputs: inputs:
reports: '$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml' reports: '$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml'
targetdir: '$(Build.SourcesDirectory)/CoverageResults/combined' targetdir: '$(Build.SourcesDirectory)/CoverageResults/combined'
reporttypes: 'HtmlInline_AzurePipelines;Cobertura;Badges' reporttypes: 'HtmlInline_AzurePipelines;Cobertura;Badges'
publishCodeCoverageResults: true - task: PublishCodeCoverageResults@1
displayName: Publish Coverage Report
inputs:
codeCoverageTool: 'cobertura'
summaryFileLocation: './CoverageResults/combined/Cobertura.xml'
reportDirectory: './CoverageResults/combined/'
- stage: Report_Out - stage: Report_Out
dependsOn: dependsOn:

View file

@ -59,7 +59,7 @@ app_guid=$(echo "$app_guid" | tr -d ' ')
app_guid=${app_guid:-media} app_guid=${app_guid:-media}
echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory" echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory"
echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that the selected user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories" echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that that user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories"
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
# Create User / Group as needed # Create User / Group as needed
@ -114,7 +114,7 @@ case "$ARCH" in
esac esac
echo "" echo ""
echo "Removing previous tarballs" echo "Removing previous tarballs"
# -f to Force so we fail if it doesn't exist # -f to Force so we fail if it doesnt exist
rm -f "${app^}".*.tar.gz rm -f "${app^}".*.tar.gz
echo "" echo ""
echo "Downloading..." echo "Downloading..."

23
docs.sh
View file

@ -1,18 +1,13 @@
#!/bin/bash
set -e
FRAMEWORK="net6.0"
PLATFORM=$1 PLATFORM=$1
ARCHITECTURE="${2:-x64}"
if [ "$PLATFORM" = "Windows" ]; then if [ "$PLATFORM" = "Windows" ]; then
RUNTIME="win-$ARCHITECTURE" RUNTIME="win-x64"
elif [ "$PLATFORM" = "Linux" ]; then elif [ "$PLATFORM" = "Linux" ]; then
RUNTIME="linux-$ARCHITECTURE" RUNTIME="linux-x64"
elif [ "$PLATFORM" = "Mac" ]; then elif [ "$PLATFORM" = "Mac" ]; then
RUNTIME="osx-$ARCHITECTURE" RUNTIME="osx-x64"
else else
echo "Platform must be provided as first argument: Windows, Linux or Mac" echo "Platform must be provided as first arguement: Windows, Linux or Mac"
exit 1 exit 1
fi fi
@ -26,21 +21,15 @@ slnFile=src/Lidarr.sln
platform=Posix platform=Posix
if [ "$PLATFORM" = "Windows" ]; then
application=Lidarr.Console.dll
else
application=Lidarr.dll
fi
dotnet clean $slnFile -c Debug dotnet clean $slnFile -c Debug
dotnet clean $slnFile -c Release dotnet clean $slnFile -c Release
dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids
dotnet new tool-manifest dotnet new tool-manifest
dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli dotnet tool install --version 6.5.0 Swashbuckle.AspNetCore.Cli
dotnet tool run swagger tofile --output ./src/Lidarr.Api.V1/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v1 & dotnet tool run swagger tofile --output ./src/Lidarr.Api.V1/openapi.json "$outputFolder/net6.0/$RUNTIME/lidarr.console.dll" v1 &
sleep 45 sleep 45

View file

@ -28,8 +28,7 @@ module.exports = {
globals: { globals: {
expect: false, expect: false,
chai: false, chai: false,
sinon: false, sinon: false
JSX: true
}, },
parserOptions: { parserOptions: {

View file

@ -9,7 +9,7 @@
"editor.formatOnSave": false, "editor.formatOnSave": false,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll": "explicit" "source.fixAll": true
}, },
"typescript.preferences.quoteStyle": "single", "typescript.preferences.quoteStyle": "single",

View file

@ -26,7 +26,6 @@ module.exports = (env) => {
const config = { const config = {
mode: isProduction ? 'production' : 'development', mode: isProduction ? 'production' : 'development',
devtool: isProduction ? 'source-map' : 'eval-source-map', devtool: isProduction ? 'source-map' : 'eval-source-map',
target: 'web',
stats: { stats: {
children: false children: false
@ -68,7 +67,7 @@ module.exports = (env) => {
output: { output: {
path: distFolder, path: distFolder,
publicPath: '/', publicPath: '/',
filename: isProduction ? '[name]-[contenthash].js' : '[name].js', filename: '[name]-[contenthash].js',
sourceMapFilename: '[file].map' sourceMapFilename: '[file].map'
}, },
@ -93,7 +92,7 @@ module.exports = (env) => {
new MiniCssExtractPlugin({ new MiniCssExtractPlugin({
filename: 'Content/styles.css', filename: 'Content/styles.css',
chunkFilename: isProduction ? 'Content/[id]-[chunkhash].css' : 'Content/[id].css' chunkFilename: 'Content/[id]-[chunkhash].css'
}), }),
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
@ -135,12 +134,6 @@ module.exports = (env) => {
{ {
source: 'frontend/src/Content/robots.txt', source: 'frontend/src/Content/robots.txt',
destination: path.join(distFolder, 'Content/robots.txt') destination: path.join(distFolder, 'Content/robots.txt')
},
// manifest.json and browserconfig.xml
{
source: 'frontend/src/Content/*.(json|xml)',
destination: path.join(distFolder, 'Content')
} }
] ]
} }
@ -188,7 +181,7 @@ module.exports = (env) => {
loose: true, loose: true,
debug: false, debug: false,
useBuiltIns: 'entry', useBuiltIns: 'entry',
corejs: '3.41' corejs: 3
} }
] ]
] ]
@ -209,7 +202,7 @@ module.exports = (env) => {
options: { options: {
importLoaders: 1, importLoaders: 1,
modules: { modules: {
localIdentName: isProduction ? '[name]/[local]/[hash:base64:5]' : '[name]/[local]' localIdentName: '[name]/[local]/[hash:base64:5]'
} }
} }
}, },

View file

@ -16,7 +16,6 @@ const mixinsFiles = [
module.exports = { module.exports = {
plugins: [ plugins: [
'autoprefixer',
['postcss-mixins', { ['postcss-mixins', {
mixinsFiles mixinsFiles
}], }],

View file

@ -172,8 +172,7 @@ function HistoryDetails(props) {
if (eventType === 'downloadFailed') { if (eventType === 'downloadFailed') {
const { const {
message, message
indexer
} = data; } = data;
return ( return (
@ -193,14 +192,6 @@ function HistoryDetails(props) {
null null
} }
{
indexer ? (
<DescriptionListItem
title={translate('Indexer')}
data={indexer}
/>
) : null}
{ {
message ? message ?
<DescriptionListItem <DescriptionListItem

View file

@ -26,5 +26,4 @@
composes: cell from '~Components/Table/Cells/TableRowCell.css'; composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 90px; width: 90px;
text-align: right;
} }

View file

@ -57,40 +57,30 @@ function QueueStatusCell(props) {
if (status === 'paused') { if (status === 'paused') {
iconName = icons.PAUSED; iconName = icons.PAUSED;
title = translate('Paused'); title = 'Paused';
} }
if (status === 'queued') { if (status === 'queued') {
iconName = icons.QUEUED; iconName = icons.QUEUED;
title = translate('Queued'); title = 'Queued';
} }
if (status === 'completed') { if (status === 'completed') {
iconName = icons.DOWNLOADED; iconName = icons.DOWNLOADED;
title = translate('Downloaded'); title = 'Downloaded';
if (trackedDownloadState === 'importBlocked') {
title += ` - ${translate('UnableToImportAutomatically')}`;
iconKind = kinds.WARNING;
}
if (trackedDownloadState === 'importFailed') {
title += ` - ${translate('ImportFailed', { sourceTitle })}`;
iconKind = kinds.WARNING;
}
if (trackedDownloadState === 'importPending') { if (trackedDownloadState === 'importPending') {
title += ` - ${translate('WaitingToImport')}`; title += ' - Waiting to Import';
iconKind = kinds.PURPLE; iconKind = kinds.PURPLE;
} }
if (trackedDownloadState === 'importing') { if (trackedDownloadState === 'importing') {
title += ` - ${translate('Importing')}`; title += ' - Importing';
iconKind = kinds.PURPLE; iconKind = kinds.PURPLE;
} }
if (trackedDownloadState === 'failedPending') { if (trackedDownloadState === 'failedPending') {
title += ` - ${translate('WaitingToProcess')}`; title += ' - Waiting to Process';
iconKind = kinds.DANGER; iconKind = kinds.DANGER;
} }
} }
@ -101,38 +91,36 @@ function QueueStatusCell(props) {
if (status === 'delay') { if (status === 'delay') {
iconName = icons.PENDING; iconName = icons.PENDING;
title = translate('Pending'); title = 'Pending';
} }
if (status === 'downloadClientUnavailable') { if (status === 'downloadClientUnavailable') {
iconName = icons.PENDING; iconName = icons.PENDING;
iconKind = kinds.WARNING; iconKind = kinds.WARNING;
title = translate('PendingDownloadClientUnavailable'); title = 'Pending - Download client is unavailable';
} }
if (status === 'failed') { if (status === 'failed') {
iconName = icons.DOWNLOADING; iconName = icons.DOWNLOADING;
iconKind = kinds.DANGER; iconKind = kinds.DANGER;
title = translate('DownloadFailed'); title = 'Download failed';
} }
if (status === 'warning') { if (status === 'warning') {
iconName = icons.DOWNLOADING; iconName = icons.DOWNLOADING;
iconKind = kinds.WARNING; iconKind = kinds.WARNING;
const warningMessage = title = `Download warning: ${errorMessage || 'check download client for more details'}`;
errorMessage || translate('CheckDownloadClientForDetails');
title = translate('DownloadWarning', { warningMessage });
} }
if (hasError) { if (hasError) {
if (status === 'completed') { if (status === 'completed') {
iconName = icons.DOWNLOAD; iconName = icons.DOWNLOAD;
iconKind = kinds.DANGER; iconKind = kinds.DANGER;
title = translate('ImportFailed', { sourceTitle }); title = `Import failed: ${sourceTitle}`;
} else { } else {
iconName = icons.DOWNLOADING; iconName = icons.DOWNLOADING;
iconKind = kinds.DANGER; iconKind = kinds.DANGER;
title = translate('DownloadFailed'); title = 'Download failed';
} }
} }

View file

@ -118,7 +118,6 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
{ {
key: 'blocklistAndSearch', key: 'blocklistAndSearch',
value: translate('BlocklistAndSearch'), value: translate('BlocklistAndSearch'),
isDisabled: isPending,
hint: multipleSelected hint: multipleSelected
? translate('BlocklistAndSearchMultipleHint') ? translate('BlocklistAndSearchMultipleHint')
: translate('BlocklistAndSearchHint'), : translate('BlocklistAndSearchHint'),
@ -131,7 +130,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
: translate('BlocklistOnlyHint'), : translate('BlocklistOnlyHint'),
}, },
]; ];
}, [isPending, multipleSelected]); }, [multipleSelected]);
const handleRemovalMethodChange = useCallback( const handleRemovalMethodChange = useCallback(
({ value }: { value: RemovalMethod }) => { ({ value }: { value: RemovalMethod }) => {

View file

@ -8,17 +8,17 @@ function ArtistMonitorNewItemsOptionsPopoverContent() {
<DescriptionList> <DescriptionList>
<DescriptionListItem <DescriptionListItem
title={translate('AllAlbums')} title={translate('AllAlbums')}
data={translate('MonitorAllAlbums')} data="Monitor all new albums"
/> />
<DescriptionListItem <DescriptionListItem
title={translate('NewAlbums')} title={translate('NewAlbums')}
data={translate('MonitorNewAlbumsData')} data="Monitor new albums released after the newest existing album"
/> />
<DescriptionListItem <DescriptionListItem
title={translate('None')} title={translate('None')}
data={translate('MonitorNoAlbumsData')} data="Don't monitor any new albums"
/> />
</DescriptionList> </DescriptionList>
); );

View file

@ -10,7 +10,6 @@ export interface Statistics {
} }
interface Album extends ModelBase { interface Album extends ModelBase {
artistId: number;
artist: Artist; artist: Artist;
foreignAlbumId: string; foreignAlbumId: string;
title: string; title: string;
@ -20,7 +19,6 @@ interface Album extends ModelBase {
monitored: boolean; monitored: boolean;
releaseDate: string; releaseDate: string;
statistics: Statistics; statistics: Statistics;
lastSearchTime?: string;
isSaving?: boolean; isSaving?: boolean;
} }

View file

@ -4,11 +4,10 @@ import Link from 'Components/Link/Link';
function AlbumTitleLink({ foreignAlbumId, title, disambiguation }) { function AlbumTitleLink({ foreignAlbumId, title, disambiguation }) {
const link = `/album/${foreignAlbumId}`; const link = `/album/${foreignAlbumId}`;
const albumTitle = `${title}${disambiguation ? ` (${disambiguation})` : ''}`;
return ( return (
<Link to={link} title={albumTitle}> <Link to={link}>
{albumTitle} {title}{disambiguation ? ` (${disambiguation})` : ''}
</Link> </Link>
); );
} }

View file

@ -53,7 +53,7 @@ class DeleteAlbumModalContent extends Component {
render() { render() {
const { const {
title, title,
statistics = {}, statistics,
onModalClose onModalClose
} = this.props; } = this.props;

View file

@ -121,8 +121,6 @@
.releaseDate, .releaseDate,
.sizeOnDisk, .sizeOnDisk,
.albumType,
.secondaryTypes,
.qualityProfileName, .qualityProfileName,
.links, .links,
.tags { .tags {

View file

@ -3,7 +3,6 @@
interface CssExports { interface CssExports {
'albumNavigationButton': string; 'albumNavigationButton': string;
'albumNavigationButtons': string; 'albumNavigationButtons': string;
'albumType': string;
'alternateTitlesIconContainer': string; 'alternateTitlesIconContainer': string;
'backdrop': string; 'backdrop': string;
'backdropOverlay': string; 'backdropOverlay': string;
@ -21,7 +20,6 @@ interface CssExports {
'overview': string; 'overview': string;
'qualityProfileName': string; 'qualityProfileName': string;
'releaseDate': string; 'releaseDate': string;
'secondaryTypes': string;
'sizeOnDisk': string; 'sizeOnDisk': string;
'tags': string; 'tags': string;
'title': string; 'title': string;

View file

@ -192,7 +192,6 @@ class AlbumDetails extends Component {
duration, duration,
overview, overview,
albumType, albumType,
secondaryTypes,
statistics = {}, statistics = {},
monitored, monitored,
releaseDate, releaseDate,
@ -205,7 +204,6 @@ class AlbumDetails extends Component {
isFetching, isFetching,
isPopulated, isPopulated,
albumsError, albumsError,
tracksError,
trackFilesError, trackFilesError,
hasTrackFiles, hasTrackFiles,
shortDateFormat, shortDateFormat,
@ -398,11 +396,10 @@ class AlbumDetails extends Component {
<div className={styles.details}> <div className={styles.details}>
<div> <div>
{ {
duration ? !!duration &&
<span className={styles.duration}> <span className={styles.duration}>
{formatDuration(duration)} {formatDuration(duration)}
</span> : </span>
null
} }
<HeartRating <HeartRating
@ -421,15 +418,14 @@ class AlbumDetails extends Component {
title={translate('ReleaseDate')} title={translate('ReleaseDate')}
size={sizes.LARGE} size={sizes.LARGE}
> >
<div> <Icon
<Icon name={icons.CALENDAR}
name={icons.CALENDAR} size={17}
size={17} />
/>
<span className={styles.releaseDate}> <span className={styles.releaseDate}>
{moment(releaseDate).format(shortDateFormat)} {moment(releaseDate).format(shortDateFormat)}
</span> </span>
</div>
</Label> </Label>
<Tooltip <Tooltip
@ -438,15 +434,16 @@ class AlbumDetails extends Component {
className={styles.detailsLabel} className={styles.detailsLabel}
size={sizes.LARGE} size={sizes.LARGE}
> >
<div> <Icon
<Icon name={icons.DRIVE}
name={icons.DRIVE} size={17}
size={17} />
/>
<span className={styles.sizeOnDisk}> <span className={styles.sizeOnDisk}>
{formatBytes(sizeOnDisk)} {
</span> formatBytes(sizeOnDisk || 0)
</div> }
</span>
</Label> </Label>
} }
tooltip={ tooltip={
@ -462,55 +459,32 @@ class AlbumDetails extends Component {
className={styles.detailsLabel} className={styles.detailsLabel}
size={sizes.LARGE} size={sizes.LARGE}
> >
<div> <Icon
<Icon name={monitored ? icons.MONITORED : icons.UNMONITORED}
name={monitored ? icons.MONITORED : icons.UNMONITORED} size={17}
size={17} />
/>
<span className={styles.qualityProfileName}> <span className={styles.qualityProfileName}>
{monitored ? translate('Monitored') : translate('Unmonitored')} {monitored ? translate('Monitored') : translate('Unmonitored')}
</span> </span>
</div>
</Label> </Label>
{ {
albumType ? !!albumType &&
<Label <Label
className={styles.detailsLabel} className={styles.detailsLabel}
title={translate('Type')} title={translate('Type')}
size={sizes.LARGE} size={sizes.LARGE}
> >
<div> <Icon
<Icon name={icons.INFO}
name={icons.INFO} size={17}
size={17} />
/>
<span className={styles.albumType}>
{albumType}
</span>
</div>
</Label> :
null
}
{ <span className={styles.qualityProfileName}>
secondaryTypes.length ? {albumType}
<Label </span>
className={styles.detailsLabel} </Label>
title={translate('SecondaryTypes')}
size={sizes.LARGE}
>
<div>
<Icon
name={icons.INFO}
size={17}
/>
<span className={styles.secondaryTypes}>
{secondaryTypes.join(', ')}
</span>
</div>
</Label> :
null
} }
<Tooltip <Tooltip
@ -519,15 +493,14 @@ class AlbumDetails extends Component {
className={styles.detailsLabel} className={styles.detailsLabel}
size={sizes.LARGE} size={sizes.LARGE}
> >
<div> <Icon
<Icon name={icons.EXTERNAL_LINK}
name={icons.EXTERNAL_LINK} size={17}
size={17} />
/>
<span className={styles.links}> <span className={styles.links}>
{translate('Links')} {translate('Links')}
</span> </span>
</div>
</Label> </Label>
} }
tooltip={ tooltip={
@ -553,9 +526,8 @@ class AlbumDetails extends Component {
<div className={styles.contentContainer}> <div className={styles.contentContainer}>
{ {
!isPopulated && !albumsError && !tracksError && !trackFilesError ? !isPopulated && !albumsError && !trackFilesError &&
<LoadingIndicator /> : <LoadingIndicator />
null
} }
{ {
@ -566,14 +538,6 @@ class AlbumDetails extends Component {
null null
} }
{
!isFetching && tracksError ?
<Alert kind={kinds.DANGER}>
{translate('TracksLoadError')}
</Alert> :
null
}
{ {
!isFetching && trackFilesError ? !isFetching && trackFilesError ?
<Alert kind={kinds.DANGER}> <Alert kind={kinds.DANGER}>
@ -602,14 +566,6 @@ class AlbumDetails extends Component {
</div> </div>
} }
{
isPopulated && !media.length ?
<Alert kind={kinds.WARNING}>
{translate('NoMediumInformation')}
</Alert> :
null
}
</div> </div>
<OrganizePreviewModalConnector <OrganizePreviewModalConnector
@ -676,7 +632,6 @@ AlbumDetails.propTypes = {
duration: PropTypes.number, duration: PropTypes.number,
overview: PropTypes.string, overview: PropTypes.string,
albumType: PropTypes.string.isRequired, albumType: PropTypes.string.isRequired,
secondaryTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
statistics: PropTypes.object.isRequired, statistics: PropTypes.object.isRequired,
releaseDate: PropTypes.string.isRequired, releaseDate: PropTypes.string.isRequired,
ratings: PropTypes.object.isRequired, ratings: PropTypes.object.isRequired,
@ -703,8 +658,6 @@ AlbumDetails.propTypes = {
}; };
AlbumDetails.defaultProps = { AlbumDetails.defaultProps = {
secondaryTypes: [],
statistics: {},
isSaving: false isSaving: false
}; };

View file

@ -12,13 +12,16 @@ import TrackRowConnector from './TrackRowConnector';
import styles from './AlbumDetailsMedium.css'; import styles from './AlbumDetailsMedium.css';
function getMediumStatistics(tracks) { function getMediumStatistics(tracks) {
const trackCount = tracks.length; let trackCount = 0;
let trackFileCount = 0; let trackFileCount = 0;
let totalTrackCount = 0; let totalTrackCount = 0;
tracks.forEach((track) => { tracks.forEach((track) => {
if (track.trackFileId) { if (track.trackFileId) {
trackCount++;
trackFileCount++; trackFileCount++;
} else {
trackCount++;
} }
totalTrackCount++; totalTrackCount++;

View file

@ -35,9 +35,3 @@
width: 55px; width: 55px;
} }
.indexerFlags {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 50px;
}

View file

@ -4,7 +4,6 @@ interface CssExports {
'audio': string; 'audio': string;
'customFormatScore': string; 'customFormatScore': string;
'duration': string; 'duration': string;
'indexerFlags': string;
'monitored': string; 'monitored': string;
'size': string; 'size': string;
'status': string; 'status': string;

View file

@ -2,19 +2,15 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import AlbumFormats from 'Album/AlbumFormats'; import AlbumFormats from 'Album/AlbumFormats';
import EpisodeStatusConnector from 'Album/EpisodeStatusConnector'; import EpisodeStatusConnector from 'Album/EpisodeStatusConnector';
import IndexerFlags from 'Album/IndexerFlags';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip'; import Tooltip from 'Components/Tooltip/Tooltip';
import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import { tooltipPositions } from 'Helpers/Props';
import MediaInfoConnector from 'TrackFile/MediaInfoConnector'; import MediaInfoConnector from 'TrackFile/MediaInfoConnector';
import * as mediaInfoTypes from 'TrackFile/mediaInfoTypes'; import * as mediaInfoTypes from 'TrackFile/mediaInfoTypes';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import formatBytes from 'Utilities/Number/formatBytes'; import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import TrackActionsCell from './TrackActionsCell'; import TrackActionsCell from './TrackActionsCell';
import styles from './TrackRow.css'; import styles from './TrackRow.css';
@ -36,7 +32,6 @@ class TrackRow extends Component {
trackFileSize, trackFileSize,
customFormats, customFormats,
customFormatScore, customFormatScore,
indexerFlags,
columns, columns,
deleteTrackFile deleteTrackFile
} = this.props; } = this.props;
@ -146,30 +141,12 @@ class TrackRow extends Component {
customFormats.length customFormats.length
)} )}
tooltip={<AlbumFormats formats={customFormats} />} tooltip={<AlbumFormats formats={customFormats} />}
position={tooltipPositions.LEFT} position={tooltipPositions.BOTTOM}
/> />
</TableRowCell> </TableRowCell>
); );
} }
if (name === 'indexerFlags') {
return (
<TableRowCell
key={name}
className={styles.indexerFlags}
>
{indexerFlags ? (
<Popover
anchor={<Icon name={icons.FLAG} kind={kinds.PRIMARY} />}
title={translate('IndexerFlags')}
body={<IndexerFlags indexerFlags={indexerFlags} />}
position={tooltipPositions.LEFT}
/>
) : null}
</TableRowCell>
);
}
if (name === 'size') { if (name === 'size') {
return ( return (
<TableRowCell <TableRowCell
@ -231,14 +208,12 @@ TrackRow.propTypes = {
trackFileSize: PropTypes.number, trackFileSize: PropTypes.number,
customFormats: PropTypes.arrayOf(PropTypes.object), customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired, customFormatScore: PropTypes.number.isRequired,
indexerFlags: PropTypes.number.isRequired,
mediaInfo: PropTypes.object, mediaInfo: PropTypes.object,
columns: PropTypes.arrayOf(PropTypes.object).isRequired columns: PropTypes.arrayOf(PropTypes.object).isRequired
}; };
TrackRow.defaultProps = { TrackRow.defaultProps = {
customFormats: [], customFormats: []
indexerFlags: 0
}; };
export default TrackRow; export default TrackRow;

View file

@ -13,8 +13,7 @@ function createMapStateToProps() {
trackFilePath: trackFile ? trackFile.path : null, trackFilePath: trackFile ? trackFile.path : null,
trackFileSize: trackFile ? trackFile.size : null, trackFileSize: trackFile ? trackFile.size : null,
customFormats: trackFile ? trackFile.customFormats : [], customFormats: trackFile ? trackFile.customFormats : [],
customFormatScore: trackFile ? trackFile.customFormatScore : 0, customFormatScore: trackFile ? trackFile.customFormatScore : 0
indexerFlags: trackFile ? trackFile.indexerFlags : 0
}; };
} }
); );

View file

@ -35,7 +35,7 @@ class EditAlbumModalContent extends Component {
title, title,
artistName, artistName,
albumType, albumType,
statistics = {}, statistics,
item, item,
isSaving, isSaving,
onInputChange, onInputChange,

View file

@ -1,26 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import createIndexerFlagsSelector from 'Store/Selectors/createIndexerFlagsSelector';
interface IndexerFlagsProps {
indexerFlags: number;
}
function IndexerFlags({ indexerFlags = 0 }: IndexerFlagsProps) {
const allIndexerFlags = useSelector(createIndexerFlagsSelector);
const flags = allIndexerFlags.items.filter(
// eslint-disable-next-line no-bitwise
(item) => (indexerFlags & item.id) === item.id
);
return flags.length ? (
<ul>
{flags.map((flag, index) => {
return <li key={index}>{flag.name}</li>;
})}
</ul>
) : null;
}
export default IndexerFlags;

View file

@ -15,7 +15,7 @@ function AlbumInteractiveSearchModal(props) {
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
size={sizes.EXTRA_EXTRA_LARGE} size={sizes.EXTRA_LARGE}
closeOnBackgroundClick={false} closeOnBackgroundClick={false}
onModalClose={onModalClose} onModalClose={onModalClose}
> >

View file

@ -7,7 +7,6 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import { scrollDirections } from 'Helpers/Props'; import { scrollDirections } from 'Helpers/Props';
import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector'; import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector';
import translate from 'Utilities/String/translate';
function AlbumInteractiveSearchModalContent(props) { function AlbumInteractiveSearchModalContent(props) {
const { const {
@ -19,10 +18,7 @@ function AlbumInteractiveSearchModalContent(props) {
return ( return (
<ModalContent onModalClose={onModalClose}> <ModalContent onModalClose={onModalClose}>
<ModalHeader> <ModalHeader>
{albumTitle === undefined ? Interactive Search {albumId != null && `- ${albumTitle}`}
translate('InteractiveSearchModalHeader') :
translate('InteractiveSearchModalHeaderTitle', { title: albumTitle })
}
</ModalHeader> </ModalHeader>
<ModalBody scrollDirection={scrollDirections.BOTH}> <ModalBody scrollDirection={scrollDirections.BOTH}>
@ -36,7 +32,7 @@ function AlbumInteractiveSearchModalContent(props) {
<ModalFooter> <ModalFooter>
<Button onPress={onModalClose}> <Button onPress={onModalClose}>
{translate('Close')} Close
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>

View file

@ -12,10 +12,11 @@ function App({ store, history }) {
<DocumentTitle title={window.Lidarr.instanceName}> <DocumentTitle title={window.Lidarr.instanceName}>
<Provider store={store}> <Provider store={store}>
<ConnectedRouter history={history}> <ConnectedRouter history={history}>
<ApplyTheme /> <ApplyTheme>
<PageConnector> <PageConnector>
<AppRoutes app={App} /> <AppRoutes app={App} />
</PageConnector> </PageConnector>
</ApplyTheme>
</ConnectedRouter> </ConnectedRouter>
</Provider> </Provider>
</DocumentTitle> </DocumentTitle>

View file

@ -11,7 +11,7 @@ import CalendarPageConnector from 'Calendar/CalendarPageConnector';
import NotFound from 'Components/NotFound'; import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch'; import Switch from 'Components/Router/Switch';
import AddNewItemConnector from 'Search/AddNewItemConnector'; import AddNewItemConnector from 'Search/AddNewItemConnector';
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage'; import CustomFormatSettingsConnector from 'Settings/CustomFormats/CustomFormatSettingsConnector';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector'; import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
@ -29,7 +29,7 @@ import LogsTableConnector from 'System/Events/LogsTableConnector';
import Logs from 'System/Logs/Logs'; import Logs from 'System/Logs/Logs';
import Status from 'System/Status/Status'; import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks'; import Tasks from 'System/Tasks/Tasks';
import Updates from 'System/Updates/Updates'; import UpdatesConnector from 'System/Updates/UpdatesConnector';
import UnmappedFilesTableConnector from 'UnmappedFiles/UnmappedFilesTableConnector'; import UnmappedFilesTableConnector from 'UnmappedFiles/UnmappedFilesTableConnector';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector'; import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
@ -184,7 +184,7 @@ function AppRoutes(props) {
<Route <Route
path="/settings/customformats" path="/settings/customformats"
component={CustomFormatSettingsPage} component={CustomFormatSettingsConnector}
/> />
<Route <Route
@ -248,7 +248,7 @@ function AppRoutes(props) {
<Route <Route
path="/system/updates" path="/system/updates"
component={Updates} component={UpdatesConnector}
/> />
<Route <Route

View file

@ -0,0 +1,50 @@
import PropTypes from 'prop-types';
import React, { Fragment, useCallback, useEffect } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import themes from 'Styles/Themes';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.ui.item.theme || window.Lidarr.theme,
(
theme
) => {
return {
theme
};
}
);
}
function ApplyTheme({ theme, children }) {
// Update the CSS Variables
const updateCSSVariables = useCallback(() => {
const arrayOfVariableKeys = Object.keys(themes[theme]);
const arrayOfVariableValues = Object.values(themes[theme]);
// Loop through each array key and set the CSS Variables
arrayOfVariableKeys.forEach((cssVariableKey, index) => {
// Based on our snippet from MDN
document.documentElement.style.setProperty(
`--${cssVariableKey}`,
arrayOfVariableValues[index]
);
});
}, [theme]);
// On Component Mount and Component Update
useEffect(() => {
updateCSSVariables(theme);
}, [updateCSSVariables, theme]);
return <Fragment>{children}</Fragment>;
}
ApplyTheme.propTypes = {
theme: PropTypes.string.isRequired,
children: PropTypes.object.isRequired
};
export default connect(createMapStateToProps)(ApplyTheme);

View file

@ -1,37 +0,0 @@
import React, { Fragment, ReactNode, useCallback, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import themes from 'Styles/Themes';
import AppState from './State/AppState';
interface ApplyThemeProps {
children: ReactNode;
}
function createThemeSelector() {
return createSelector(
(state: AppState) => state.settings.ui.item.theme || window.Lidarr.theme,
(theme) => {
return theme;
}
);
}
function ApplyTheme({ children }: ApplyThemeProps) {
const theme = useSelector(createThemeSelector());
const updateCSSVariables = useCallback(() => {
Object.entries(themes[theme]).forEach(([key, value]) => {
document.documentElement.style.setProperty(`--${key}`, value);
});
}, [theme]);
// On Component Mount and Component Update
useEffect(() => {
updateCSSVariables();
}, [updateCSSVariables, theme]);
return <Fragment>{children}</Fragment>;
}
export default ApplyTheme;

View file

@ -1,12 +1,9 @@
import ParseAppState from 'App/State/ParseAppState';
import AlbumAppState from './AlbumAppState'; import AlbumAppState from './AlbumAppState';
import ArtistAppState, { ArtistIndexAppState } from './ArtistAppState'; import ArtistAppState, { ArtistIndexAppState } from './ArtistAppState';
import CalendarAppState from './CalendarAppState'; import CalendarAppState from './CalendarAppState';
import CommandAppState from './CommandAppState';
import HistoryAppState from './HistoryAppState'; import HistoryAppState from './HistoryAppState';
import QueueAppState from './QueueAppState'; import QueueAppState from './QueueAppState';
import SettingsAppState from './SettingsAppState'; import SettingsAppState from './SettingsAppState';
import SystemAppState from './SystemAppState';
import TagsAppState from './TagsAppState'; import TagsAppState from './TagsAppState';
import TrackFilesAppState from './TrackFilesAppState'; import TrackFilesAppState from './TrackFilesAppState';
import TracksAppState from './TracksAppState'; import TracksAppState from './TracksAppState';
@ -44,7 +41,6 @@ export interface CustomFilter {
} }
export interface AppSectionState { export interface AppSectionState {
version: string;
dimensions: { dimensions: {
isSmallScreen: boolean; isSmallScreen: boolean;
width: number; width: number;
@ -58,15 +54,12 @@ interface AppState {
artist: ArtistAppState; artist: ArtistAppState;
artistIndex: ArtistIndexAppState; artistIndex: ArtistIndexAppState;
calendar: CalendarAppState; calendar: CalendarAppState;
commands: CommandAppState;
history: HistoryAppState; history: HistoryAppState;
parse: ParseAppState;
queue: QueueAppState; queue: QueueAppState;
settings: SettingsAppState; settings: SettingsAppState;
tags: TagsAppState; tags: TagsAppState;
trackFiles: TrackFilesAppState; trackFiles: TrackFilesAppState;
tracksSelection: TracksAppState; tracksSelection: TracksAppState;
system: SystemAppState;
} }
export default AppState; export default AppState;

View file

@ -1,6 +0,0 @@
import AppSectionState from 'App/State/AppSectionState';
import Command from 'Commands/Command';
export type CommandAppState = AppSectionState<Command>;
export default CommandAppState;

View file

@ -1,35 +0,0 @@
import Album from 'Album/Album';
import ModelBase from 'App/ModelBase';
import { AppSectionItemState } from 'App/State/AppSectionState';
import Artist from 'Artist/Artist';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
export interface ArtistTitleInfo {
title: string;
}
export interface ParsedAlbumInfo {
albumTitle: string;
artistName: string;
artistTitleInfo: ArtistTitleInfo;
discography: boolean;
quality: QualityModel;
releaseGroup?: string;
releaseHash: string;
releaseTitle: string;
releaseTokens: string;
}
export interface ParseModel extends ModelBase {
title: string;
parsedAlbumInfo: ParsedAlbumInfo;
artist?: Artist;
albums: Album[];
customFormats?: CustomFormat[];
customFormatScore?: number;
}
type ParseAppState = AppSectionItemState<ParseModel>;
export default ParseAppState;

View file

@ -1,28 +1,22 @@
import AppSectionState, { import AppSectionState, {
AppSectionDeleteState, AppSectionDeleteState,
AppSectionItemState,
AppSectionSaveState, AppSectionSaveState,
AppSectionSchemaState, AppSectionSchemaState,
} from 'App/State/AppSectionState'; } from 'App/State/AppSectionState';
import CustomFormat from 'typings/CustomFormat';
import DownloadClient from 'typings/DownloadClient'; import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList'; import ImportList from 'typings/ImportList';
import Indexer from 'typings/Indexer'; import Indexer from 'typings/Indexer';
import IndexerFlag from 'typings/IndexerFlag';
import MetadataProfile from 'typings/MetadataProfile'; import MetadataProfile from 'typings/MetadataProfile';
import Notification from 'typings/Notification'; import Notification from 'typings/Notification';
import QualityProfile from 'typings/QualityProfile'; import QualityProfile from 'typings/QualityProfile';
import RootFolder from 'typings/RootFolder'; import RootFolder from 'typings/RootFolder';
import General from 'typings/Settings/General'; import { UiSettings } from 'typings/UiSettings';
import UiSettings from 'typings/Settings/UiSettings';
export interface DownloadClientAppState export interface DownloadClientAppState
extends AppSectionState<DownloadClient>, extends AppSectionState<DownloadClient>,
AppSectionDeleteState, AppSectionDeleteState,
AppSectionSaveState {} AppSectionSaveState {}
export type GeneralAppState = AppSectionItemState<General>;
export interface ImportListAppState export interface ImportListAppState
extends AppSectionState<ImportList>, extends AppSectionState<ImportList>,
AppSectionDeleteState, AppSectionDeleteState,
@ -45,32 +39,22 @@ export interface MetadataProfilesAppState
extends AppSectionState<MetadataProfile>, extends AppSectionState<MetadataProfile>,
AppSectionSchemaState<MetadataProfile> {} AppSectionSchemaState<MetadataProfile> {}
export interface CustomFormatAppState
extends AppSectionState<CustomFormat>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface RootFolderAppState export interface RootFolderAppState
extends AppSectionState<RootFolder>, extends AppSectionState<RootFolder>,
AppSectionDeleteState, AppSectionDeleteState,
AppSectionSaveState {} AppSectionSaveState {}
export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>; export type UiSettingsAppState = AppSectionState<UiSettings>;
export type UiSettingsAppState = AppSectionItemState<UiSettings>;
interface SettingsAppState { interface SettingsAppState {
advancedSettings: boolean;
customFormats: CustomFormatAppState;
downloadClients: DownloadClientAppState; downloadClients: DownloadClientAppState;
general: GeneralAppState;
importLists: ImportListAppState; importLists: ImportListAppState;
indexerFlags: IndexerFlagSettingsAppState;
indexers: IndexerAppState; indexers: IndexerAppState;
metadataProfiles: MetadataProfilesAppState; metadataProfiles: MetadataProfilesAppState;
notifications: NotificationAppState; notifications: NotificationAppState;
qualityProfiles: QualityProfilesAppState; qualityProfiles: QualityProfilesAppState;
rootFolders: RootFolderAppState; rootFolders: RootFolderAppState;
ui: UiSettingsAppState; uiSettings: UiSettingsAppState;
} }
export default SettingsAppState; export default SettingsAppState;

View file

@ -1,13 +0,0 @@
import SystemStatus from 'typings/SystemStatus';
import Update from 'typings/Update';
import AppSectionState, { AppSectionItemState } from './AppSectionState';
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
export type UpdateAppState = AppSectionState<Update>;
interface SystemAppState {
updates: UpdateAppState;
status: SystemStatusAppState;
}
export default SystemAppState;

View file

@ -1,32 +1,12 @@
import ModelBase from 'App/ModelBase'; import ModelBase from 'App/ModelBase';
import AppSectionState, { import AppSectionState, {
AppSectionDeleteState, AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState'; } from 'App/State/AppSectionState';
export interface Tag extends ModelBase { export interface Tag extends ModelBase {
label: string; label: string;
} }
export interface TagDetail extends ModelBase { interface TagsAppState extends AppSectionState<Tag>, AppSectionDeleteState {}
label: string;
autoTagIds: number[];
delayProfileIds: number[];
downloadClientIds: [];
importListIds: number[];
indexerIds: number[];
notificationIds: number[];
restrictionIds: number[];
artistIds: number[];
}
export interface TagDetailAppState
extends AppSectionState<TagDetail>,
AppSectionDeleteState,
AppSectionSaveState {}
interface TagsAppState extends AppSectionState<Tag>, AppSectionDeleteState {
details: TagDetailAppState;
}
export default TagsAppState; export default TagsAppState;

View file

@ -23,6 +23,7 @@ export interface Ratings {
interface Artist extends ModelBase { interface Artist extends ModelBase {
added: string; added: string;
artistMetadataId: string;
foreignArtistId: string; foreignArtistId: string;
cleanName: string; cleanName: string;
ended: boolean; ended: boolean;

View file

@ -135,14 +135,14 @@ class DeleteArtistModalContent extends Component {
<ModalFooter> <ModalFooter>
<Button onPress={onModalClose}> <Button onPress={onModalClose}>
{translate('Close')} Close
</Button> </Button>
<Button <Button
kind={kinds.DANGER} kind={kinds.DANGER}
onPress={this.onDeleteArtistConfirmed} onPress={this.onDeleteArtistConfirmed}
> >
{translate('Delete')} Delete
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
@ -161,7 +161,9 @@ DeleteArtistModalContent.propTypes = {
}; };
DeleteArtistModalContent.defaultProps = { DeleteArtistModalContent.defaultProps = {
statistics: {} statistics: {
trackFileCount: 0
}
}; };
export default DeleteArtistModalContent; export default DeleteArtistModalContent;

View file

@ -10,7 +10,6 @@ function AlbumGroupInfo(props) {
const { const {
totalAlbumCount, totalAlbumCount,
monitoredAlbumCount, monitoredAlbumCount,
albumFileCount,
trackFileCount, trackFileCount,
sizeOnDisk sizeOnDisk
} = props; } = props;
@ -31,13 +30,6 @@ function AlbumGroupInfo(props) {
data={monitoredAlbumCount} data={monitoredAlbumCount}
/> />
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={translate('WithFiles')}
data={albumFileCount}
/>
<DescriptionListItem <DescriptionListItem
titleClassName={styles.title} titleClassName={styles.title}
descriptionClassName={styles.description} descriptionClassName={styles.description}
@ -58,7 +50,6 @@ function AlbumGroupInfo(props) {
AlbumGroupInfo.propTypes = { AlbumGroupInfo.propTypes = {
totalAlbumCount: PropTypes.number.isRequired, totalAlbumCount: PropTypes.number.isRequired,
monitoredAlbumCount: PropTypes.number.isRequired, monitoredAlbumCount: PropTypes.number.isRequired,
albumFileCount: PropTypes.number.isRequired,
trackFileCount: PropTypes.number.isRequired, trackFileCount: PropTypes.number.isRequired,
sizeOnDisk: PropTypes.number.isRequired sizeOnDisk: PropTypes.number.isRequired
}; };

View file

@ -149,7 +149,9 @@ class AlbumRow extends Component {
if (name === 'secondaryTypes') { if (name === 'secondaryTypes') {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
{secondaryTypes.join(', ')} {
secondaryTypes
}
</TableRowCell> </TableRowCell>
); );
} }
@ -158,7 +160,7 @@ class AlbumRow extends Component {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
{ {
totalTrackCount statistics.totalTrackCount
} }
</TableRowCell> </TableRowCell>
); );

View file

@ -192,7 +192,7 @@ class ArtistDetails extends Component {
artistName, artistName,
ratings, ratings,
path, path,
statistics = {}, statistics,
qualityProfileId, qualityProfileId,
monitored, monitored,
genres, genres,

View file

@ -22,43 +22,32 @@ import styles from './ArtistDetailsSeason.css';
function getAlbumStatistics(albums) { function getAlbumStatistics(albums) {
let albumCount = 0; let albumCount = 0;
let albumFileCount = 0;
let trackFileCount = 0; let trackFileCount = 0;
let totalAlbumCount = 0; let totalAlbumCount = 0;
let monitoredAlbumCount = 0; let monitoredAlbumCount = 0;
let hasMonitoredAlbums = false; let hasMonitoredAlbums = false;
let sizeOnDisk = 0; let sizeOnDisk = 0;
albums.forEach(({ monitored, releaseDate, statistics = {} }) => { albums.forEach((album) => {
const { if (album.statistics) {
trackFileCount: albumTrackFileCount = 0, sizeOnDisk = sizeOnDisk + album.statistics.sizeOnDisk;
totalTrackCount: albumTotalTrackCount = 0, trackFileCount = trackFileCount + album.statistics.trackFileCount;
sizeOnDisk: albumSizeOnDisk = 0
} = statistics;
const hasFiles = albumTrackFileCount > 0 && albumTrackFileCount === albumTotalTrackCount; if (album.statistics.trackFileCount === album.statistics.totalTrackCount || (album.monitored && isBefore(album.airDateUtc))) {
albumCount++;
if (hasFiles || (monitored && isBefore(releaseDate))) { }
albumCount++;
} }
if (hasFiles) { if (album.monitored) {
albumFileCount++;
}
if (monitored) {
monitoredAlbumCount++; monitoredAlbumCount++;
hasMonitoredAlbums = true; hasMonitoredAlbums = true;
} }
totalAlbumCount++; totalAlbumCount++;
trackFileCount = trackFileCount + albumTrackFileCount;
sizeOnDisk = sizeOnDisk + albumSizeOnDisk;
}); });
return { return {
albumCount, albumCount,
albumFileCount,
totalAlbumCount, totalAlbumCount,
trackFileCount, trackFileCount,
monitoredAlbumCount, monitoredAlbumCount,
@ -67,8 +56,8 @@ function getAlbumStatistics(albums) {
}; };
} }
function getAlbumCountKind(monitored, albumCount, albumFileCount) { function getAlbumCountKind(monitored, albumCount, monitoredAlbumCount) {
if (albumCount === albumFileCount && albumFileCount > 0) { if (albumCount === monitoredAlbumCount && monitoredAlbumCount > 0) {
return kinds.SUCCESS; return kinds.SUCCESS;
} }
@ -203,7 +192,6 @@ class ArtistDetailsSeason extends Component {
const { const {
albumCount, albumCount,
albumFileCount,
totalAlbumCount, totalAlbumCount,
trackFileCount, trackFileCount,
monitoredAlbumCount, monitoredAlbumCount,
@ -238,9 +226,9 @@ class ArtistDetailsSeason extends Component {
anchor={ anchor={
<Label <Label
size={sizes.LARGE} size={sizes.LARGE}
kind={getAlbumCountKind(hasMonitoredAlbums, albumCount, albumFileCount)} kind={getAlbumCountKind(hasMonitoredAlbums, albumCount, monitoredAlbumCount)}
> >
<span>{albumFileCount} / {albumCount}</span> <span>{albumCount} / {monitoredAlbumCount}</span>
</Label> </Label>
} }
title={translate('GroupInformation')} title={translate('GroupInformation')}
@ -249,7 +237,6 @@ class ArtistDetailsSeason extends Component {
<AlbumGroupInfo <AlbumGroupInfo
totalAlbumCount={totalAlbumCount} totalAlbumCount={totalAlbumCount}
monitoredAlbumCount={monitoredAlbumCount} monitoredAlbumCount={monitoredAlbumCount}
albumFileCount={albumFileCount}
trackFileCount={trackFileCount} trackFileCount={trackFileCount}
sizeOnDisk={sizeOnDisk} sizeOnDisk={sizeOnDisk}
/> />

View file

@ -2,7 +2,6 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import createArtistSelector from 'Store/Selectors/createArtistSelector'; import createArtistSelector from 'Store/Selectors/createArtistSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector'; import createTagsSelector from 'Store/Selectors/createTagsSelector';
import sortByProp from 'Utilities/Array/sortByProp';
import ArtistTags from './ArtistTags'; import ArtistTags from './ArtistTags';
function createMapStateToProps() { function createMapStateToProps() {
@ -13,8 +12,8 @@ function createMapStateToProps() {
const tags = artist.tags const tags = artist.tags
.map((tagId) => tagList.find((tag) => tag.id === tagId)) .map((tagId) => tagList.find((tag) => tag.id === tagId))
.filter((tag) => !!tag) .filter((tag) => !!tag)
.sort(sortByProp('label')) .map((tag) => tag.label)
.map((tag) => tag.label); .sort((a, b) => a.localeCompare(b));
return { return {
tags tags

View file

@ -15,7 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import Popover from 'Components/Tooltip/Popover'; import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, sizes, tooltipPositions } from 'Helpers/Props'; import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './EditArtistModalContent.css'; import styles from './EditArtistModalContent.css';
@ -93,7 +93,7 @@ class EditArtistModalContent extends Component {
<ModalBody> <ModalBody>
<Form {...otherProps}> <Form {...otherProps}>
<FormGroup size={sizes.MEDIUM}> <FormGroup>
<FormLabel> <FormLabel>
{translate('Monitored')} {translate('Monitored')}
</FormLabel> </FormLabel>
@ -107,10 +107,9 @@ class EditArtistModalContent extends Component {
/> />
</FormGroup> </FormGroup>
<FormGroup size={sizes.MEDIUM}> <FormGroup>
<FormLabel> <FormLabel>
{translate('MonitorNewItems')} {translate('MonitorNewItems')}
<Popover <Popover
anchor={ anchor={
<Icon <Icon
@ -133,7 +132,7 @@ class EditArtistModalContent extends Component {
/> />
</FormGroup> </FormGroup>
<FormGroup size={sizes.MEDIUM}> <FormGroup>
<FormLabel> <FormLabel>
{translate('QualityProfile')} {translate('QualityProfile')}
</FormLabel> </FormLabel>
@ -147,10 +146,10 @@ class EditArtistModalContent extends Component {
</FormGroup> </FormGroup>
{ {
showMetadataProfile ? showMetadataProfile &&
<FormGroup size={sizes.MEDIUM}> <FormGroup>
<FormLabel> <FormLabel>
{translate('MetadataProfile')} Metadata Profile
<Popover <Popover
anchor={ anchor={
@ -174,11 +173,10 @@ class EditArtistModalContent extends Component {
{...metadataProfileId} {...metadataProfileId}
onChange={onInputChange} onChange={onInputChange}
/> />
</FormGroup> : </FormGroup>
null
} }
<FormGroup size={sizes.MEDIUM}> <FormGroup>
<FormLabel> <FormLabel>
{translate('Path')} {translate('Path')}
</FormLabel> </FormLabel>
@ -191,7 +189,7 @@ class EditArtistModalContent extends Component {
/> />
</FormGroup> </FormGroup>
<FormGroup size={sizes.MEDIUM}> <FormGroup>
<FormLabel> <FormLabel>
{translate('Tags')} {translate('Tags')}
</FormLabel> </FormLabel>
@ -211,7 +209,7 @@ class EditArtistModalContent extends Component {
kind={kinds.DANGER} kind={kinds.DANGER}
onPress={onDeleteArtistPress} onPress={onDeleteArtistPress}
> >
{translate('Delete')} Delete
</Button> </Button>
<Button <Button

View file

@ -79,7 +79,7 @@ function ArtistIndexSortMenu(props: SeriesIndexSortMenuProps) {
sortDirection={sortDirection} sortDirection={sortDirection}
onPress={onSortSelect} onPress={onSortSelect}
> >
{translate('LastAlbum')} {translate('Last Album')}
</SortMenuItem> </SortMenuItem>
<SortMenuItem <SortMenuItem

View file

@ -6,7 +6,7 @@ import { icons } from 'Helpers/Props';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import dimensions from 'Styles/Variables/dimensions'; import dimensions from 'Styles/Variables/dimensions';
import QualityProfile from 'typings/QualityProfile'; import QualityProfile from 'typings/QualityProfile';
import UiSettings from 'typings/Settings/UiSettings'; import { UiSettings } from 'typings/UiSettings';
import formatDateTime from 'Utilities/Date/formatDateTime'; import formatDateTime from 'Utilities/Date/formatDateTime';
import getRelativeDate from 'Utilities/Date/getRelativeDate'; import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes'; import formatBytes from 'Utilities/Number/formatBytes';

View file

@ -1,7 +1,6 @@
import _ from 'lodash'; import _ from 'lodash';
import React, { useEffect, useMemo } from 'react'; import React, { useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { Statistics } from 'Album/Album';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
@ -57,7 +56,7 @@ function AlbumDetails(props: AlbumDetailsProps) {
disambiguation, disambiguation,
albumType, albumType,
monitored, monitored,
statistics = {} as Statistics, statistics,
isSaving = false, isSaving = false,
} = album; } = album;

View file

@ -35,7 +35,7 @@ const monitoredOptions = [
get value() { get value() {
return translate('NoChange'); return translate('NoChange');
}, },
isDisabled: true, disabled: true,
}, },
{ {
key: 'monitored', key: 'monitored',

View file

@ -14,7 +14,7 @@ function ArtistInteractiveSearchModal(props) {
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
size={sizes.EXTRA_EXTRA_LARGE} size={sizes.EXTRA_LARGE}
closeOnBackgroundClick={false} closeOnBackgroundClick={false}
onModalClose={onModalClose} onModalClose={onModalClose}
> >

View file

@ -164,7 +164,7 @@ class CalendarLinkModalContent extends Component {
type={inputTypes.TAG} type={inputTypes.TAG}
name="tags" name="tags"
value={tags} value={tags}
helpText={translate('ICalTagsArtistHelpText')} helpText={translate('TagsHelpText')}
onChange={this.onInputChange} onChange={this.onInputChange}
/> />
</FormGroup> </FormGroup>

View file

@ -13,7 +13,6 @@ export interface CommandBody {
trigger: string; trigger: string;
suppressMessages: boolean; suppressMessages: boolean;
artistId?: number; artistId?: number;
artistIds?: number[];
} }
interface Command extends ModelBase { interface Command extends ModelBase {

View file

@ -3,8 +3,8 @@ import React, { Component } from 'react';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import PathInput from 'Components/Form/PathInput'; import PathInput from 'Components/Form/PathInput';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody'; import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent'; import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
@ -117,7 +117,7 @@ class FileBrowserModalContent extends Component {
className={styles.mappedDrivesWarning} className={styles.mappedDrivesWarning}
kind={kinds.WARNING} kind={kinds.WARNING}
> >
<InlineMarkdown data={translate('MappedNetworkDrivesWindowsService', { url: 'https://wiki.servarr.com/lidarr/faq#why-cant-lidarr-see-my-files-on-a-remote-server' })} /> Mapped network drives are not available when running as a Windows Service, see the <Link className={styles.faqLink} to="https://wiki.servarr.com/lidarr/faq">FAQ</Link> for more information.
</Alert> </Alert>
} }

View file

@ -2,7 +2,7 @@ import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import Artist from 'Artist/Artist'; import Artist from 'Artist/Artist';
import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
import sortByProp from 'Utilities/Array/sortByProp'; import sortByName from 'Utilities/Array/sortByName';
import FilterBuilderRowValue from './FilterBuilderRowValue'; import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValueProps from './FilterBuilderRowValueProps'; import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
@ -11,7 +11,7 @@ function ArtistFilterBuilderRowValue(props: FilterBuilderRowValueProps) {
const tagList = allArtists const tagList = allArtists
.map((artist) => ({ id: artist.id, name: artist.artistName })) .map((artist) => ({ id: artist.id, name: artist.artistName }))
.sort(sortByProp('name')); .sort(sortByName);
return <FilterBuilderRowValue {...props} tagList={tagList} />; return <FilterBuilderRowValue {...props} tagList={tagList} />;
} }

View file

@ -3,7 +3,6 @@ import React, { Component } from 'react';
import SelectInput from 'Components/Form/SelectInput'; import SelectInput from 'Components/Form/SelectInput';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props'; import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props';
import sortByProp from 'Utilities/Array/sortByProp';
import ArtistFilterBuilderRowValue from './ArtistFilterBuilderRowValue'; import ArtistFilterBuilderRowValue from './ArtistFilterBuilderRowValue';
import ArtistStatusFilterBuilderRowValue from './ArtistStatusFilterBuilderRowValue'; import ArtistStatusFilterBuilderRowValue from './ArtistStatusFilterBuilderRowValue';
import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue'; import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue';
@ -11,11 +10,11 @@ import DateFilterBuilderRowValue from './DateFilterBuilderRowValue';
import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector'; import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector';
import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue'; import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue';
import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector'; import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector';
import MetadataProfileFilterBuilderRowValue from './MetadataProfileFilterBuilderRowValue'; import MetadataProfileFilterBuilderRowValueConnector from './MetadataProfileFilterBuilderRowValueConnector';
import MonitorNewItemsFilterBuilderRowValue from './MonitorNewItemsFilterBuilderRowValue'; import MonitorNewItemsFilterBuilderRowValue from './MonitorNewItemsFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector'; import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue'; import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector';
import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector'; import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector';
import styles from './FilterBuilderRow.css'; import styles from './FilterBuilderRow.css';
@ -68,7 +67,7 @@ function getRowValueConnector(selectedFilterBuilderProp) {
return IndexerFilterBuilderRowValueConnector; return IndexerFilterBuilderRowValueConnector;
case filterBuilderValueTypes.METADATA_PROFILE: case filterBuilderValueTypes.METADATA_PROFILE:
return MetadataProfileFilterBuilderRowValue; return MetadataProfileFilterBuilderRowValueConnector;
case filterBuilderValueTypes.MONITOR_NEW_ITEMS: case filterBuilderValueTypes.MONITOR_NEW_ITEMS:
return MonitorNewItemsFilterBuilderRowValue; return MonitorNewItemsFilterBuilderRowValue;
@ -80,7 +79,7 @@ function getRowValueConnector(selectedFilterBuilderProp) {
return QualityFilterBuilderRowValueConnector; return QualityFilterBuilderRowValueConnector;
case filterBuilderValueTypes.QUALITY_PROFILE: case filterBuilderValueTypes.QUALITY_PROFILE:
return QualityProfileFilterBuilderRowValue; return QualityProfileFilterBuilderRowValueConnector;
case filterBuilderValueTypes.ARTIST: case filterBuilderValueTypes.ARTIST:
return ArtistFilterBuilderRowValue; return ArtistFilterBuilderRowValue;
@ -225,7 +224,7 @@ class FilterBuilderRow extends Component {
key: name, key: name,
value: typeof label === 'function' ? label() : label value: typeof label === 'function' ? label() : label
}; };
}).sort(sortByProp('value')); }).sort((a, b) => a.value.localeCompare(b.value));
const ValueComponent = getRowValueConnector(selectedFilterBuilderProp); const ValueComponent = getRowValueConnector(selectedFilterBuilderProp);

View file

@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { filterBuilderTypes } from 'Helpers/Props'; import { filterBuilderTypes } from 'Helpers/Props';
import * as filterTypes from 'Helpers/Props/filterTypes'; import * as filterTypes from 'Helpers/Props/filterTypes';
import sortByProp from 'Utilities/Array/sortByProp'; import sortByName from 'Utilities/Array/sortByName';
import FilterBuilderRowValue from './FilterBuilderRowValue'; import FilterBuilderRowValue from './FilterBuilderRowValue';
function createTagListSelector() { function createTagListSelector() {
@ -38,7 +38,7 @@ function createTagListSelector() {
} }
return acc; return acc;
}, []).sort(sortByProp('name')); }, []).sort(sortByName);
} }
return _.uniqBy(items, 'id'); return _.uniqBy(items, 'id');

View file

@ -25,7 +25,7 @@ const EVENT_TYPE_OPTIONS = [
{ {
id: 7, id: 7,
get name() { get name() {
return translate('ImportCompleteFailed'); return translate('ImportFailed');
}, },
}, },
{ {

View file

@ -1,30 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps';
import sortByProp from 'Utilities/Array/sortByProp';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createMetadataProfilesSelector() {
return createSelector(
(state: AppState) => state.settings.metadataProfiles.items,
(metadataProfiles) => {
return metadataProfiles;
}
);
}
function MetadataProfileFilterBuilderRowValue(
props: FilterBuilderRowValueProps
) {
const metadataProfiles = useSelector(createMetadataProfilesSelector());
const tagList = metadataProfiles
.map(({ id, name }) => ({ id, name }))
.sort(sortByProp('name'));
return <FilterBuilderRowValue {...props} tagList={tagList} />;
}
export default MetadataProfileFilterBuilderRowValue;

View file

@ -0,0 +1,28 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.metadataProfiles,
(metadataProfiles) => {
const tagList = metadataProfiles.items.map((metadataProfile) => {
const {
id,
name
} = metadataProfile;
return {
id,
name
};
});
return {
tagList
};
}
);
}
export default connect(createMapStateToProps)(FilterBuilderRowValue);

View file

@ -1,30 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps';
import sortByProp from 'Utilities/Array/sortByProp';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createQualityProfilesSelector() {
return createSelector(
(state: AppState) => state.settings.qualityProfiles.items,
(qualityProfiles) => {
return qualityProfiles;
}
);
}
function QualityProfileFilterBuilderRowValue(
props: FilterBuilderRowValueProps
) {
const qualityProfiles = useSelector(createQualityProfilesSelector());
const tagList = qualityProfiles
.map(({ id, name }) => ({ id, name }))
.sort(sortByProp('name'));
return <FilterBuilderRowValue {...props} tagList={tagList} />;
}
export default QualityProfileFilterBuilderRowValue;

View file

@ -0,0 +1,28 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.qualityProfiles,
(qualityProfiles) => {
const tagList = qualityProfiles.items.map((qualityProfile) => {
const {
id,
name
} = qualityProfile;
return {
id,
name
};
});
return {
tagList
};
}
);
}
export default connect(createMapStateToProps)(FilterBuilderRowValue);

View file

@ -5,7 +5,6 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent'; import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import CustomFilter from './CustomFilter'; import CustomFilter from './CustomFilter';
import styles from './CustomFiltersModalContent.css'; import styles from './CustomFiltersModalContent.css';
@ -32,7 +31,7 @@ function CustomFiltersModalContent(props) {
<ModalBody> <ModalBody>
{ {
customFilters customFilters
.sort((a, b) => sortByProp(a, b, 'label')) .sort((a, b) => a.label.localeCompare(b.label))
.map((customFilter) => { .map((customFilter) => {
return ( return (
<CustomFilter <CustomFilter

View file

@ -1,53 +0,0 @@
import React, { useCallback } from 'react';
import TagInputConnector from './TagInputConnector';
interface ArtistTagInputProps {
name: string;
value: number | number[];
onChange: ({
name,
value,
}: {
name: string;
value: number | number[];
}) => void;
}
export default function ArtistTagInput(props: ArtistTagInputProps) {
const { value, onChange, ...otherProps } = props;
const isArray = Array.isArray(value);
const handleChange = useCallback(
({ name, value: newValue }: { name: string; value: number[] }) => {
if (isArray) {
onChange({ name, value: newValue });
} else {
onChange({
name,
value: newValue.length ? newValue[newValue.length - 1] : 0,
});
}
},
[isArray, onChange]
);
let finalValue: number[] = [];
if (isArray) {
finalValue = value;
} else if (value === 0) {
finalValue = [];
} else {
finalValue = [value];
}
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore 2786 'TagInputConnector' isn't typed yet
<TagInputConnector
{...otherProps}
value={finalValue}
onChange={handleChange}
/>
);
}

View file

@ -4,8 +4,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { fetchDownloadClients } from 'Store/Actions/settingsActions'; import { fetchDownloadClients } from 'Store/Actions/settingsActions';
import sortByProp from 'Utilities/Array/sortByProp'; import sortByName from 'Utilities/Array/sortByName';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput'; import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() { function createMapStateToProps() {
@ -23,7 +22,7 @@ function createMapStateToProps() {
const filteredItems = items.filter((item) => item.protocol === protocolFilter); const filteredItems = items.filter((item) => item.protocol === protocolFilter);
const values = _.map(filteredItems.sort(sortByProp('name')), (downloadClient) => { const values = _.map(filteredItems.sort(sortByName), (downloadClient) => {
return { return {
key: downloadClient.id, key: downloadClient.id,
value: downloadClient.name, value: downloadClient.name,
@ -34,7 +33,7 @@ function createMapStateToProps() {
if (includeAny) { if (includeAny) {
values.unshift({ values.unshift({
key: 0, key: 0,
value: `(${translate('Any')})` value: '(Any)'
}); });
} }

View file

@ -19,7 +19,7 @@
.isDisabled { .isDisabled {
opacity: 0.7; opacity: 0.7;
cursor: not-allowed !important; cursor: not-allowed;
} }
.dropdownArrowContainer { .dropdownArrowContainer {

View file

@ -20,8 +20,6 @@ import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import TextInput from './TextInput'; import TextInput from './TextInput';
import styles from './EnhancedSelectInput.css'; import styles from './EnhancedSelectInput.css';
const MINIMUM_DISTANCE_FROM_EDGE = 10;
function isArrowKey(keyCode) { function isArrowKey(keyCode) {
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW; return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
} }
@ -139,9 +137,18 @@ class EnhancedSelectInput extends Component {
// Listeners // Listeners
onComputeMaxHeight = (data) => { onComputeMaxHeight = (data) => {
const {
top,
bottom
} = data.offsets.reference;
const windowHeight = window.innerHeight; const windowHeight = window.innerHeight;
data.styles.maxHeight = windowHeight - MINIMUM_DISTANCE_FROM_EDGE; if ((/^botton/).test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
return data; return data;
}; };
@ -450,10 +457,6 @@ class EnhancedSelectInput extends Component {
order: 851, order: 851,
enabled: true, enabled: true,
fn: this.onComputeMaxHeight fn: this.onComputeMaxHeight
},
preventOverflow: {
enabled: true,
boundariesElement: 'viewport'
} }
}} }}
> >

View file

@ -4,7 +4,6 @@ import Link from 'Components/Link/Link';
import { inputTypes, kinds } from 'Helpers/Props'; import { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import AlbumReleaseSelectInputConnector from './AlbumReleaseSelectInputConnector'; import AlbumReleaseSelectInputConnector from './AlbumReleaseSelectInputConnector';
import ArtistTagInput from './ArtistTagInput';
import AutoCompleteInput from './AutoCompleteInput'; import AutoCompleteInput from './AutoCompleteInput';
import CaptchaInputConnector from './CaptchaInputConnector'; import CaptchaInputConnector from './CaptchaInputConnector';
import CheckInput from './CheckInput'; import CheckInput from './CheckInput';
@ -13,7 +12,6 @@ import DownloadClientSelectInputConnector from './DownloadClientSelectInputConne
import EnhancedSelectInput from './EnhancedSelectInput'; import EnhancedSelectInput from './EnhancedSelectInput';
import EnhancedSelectInputConnector from './EnhancedSelectInputConnector'; import EnhancedSelectInputConnector from './EnhancedSelectInputConnector';
import FormInputHelpText from './FormInputHelpText'; import FormInputHelpText from './FormInputHelpText';
import IndexerFlagsSelectInput from './IndexerFlagsSelectInput';
import IndexerSelectInputConnector from './IndexerSelectInputConnector'; import IndexerSelectInputConnector from './IndexerSelectInputConnector';
import KeyValueListInput from './KeyValueListInput'; import KeyValueListInput from './KeyValueListInput';
import MetadataProfileSelectInputConnector from './MetadataProfileSelectInputConnector'; import MetadataProfileSelectInputConnector from './MetadataProfileSelectInputConnector';
@ -49,12 +47,12 @@ function getComponent(type) {
case inputTypes.DEVICE: case inputTypes.DEVICE:
return DeviceInputConnector; return DeviceInputConnector;
case inputTypes.KEY_VALUE_LIST:
return KeyValueListInput;
case inputTypes.PLAYLIST: case inputTypes.PLAYLIST:
return PlaylistInputConnector; return PlaylistInputConnector;
case inputTypes.KEY_VALUE_LIST:
return KeyValueListInput;
case inputTypes.MONITOR_ALBUMS_SELECT: case inputTypes.MONITOR_ALBUMS_SELECT:
return MonitorAlbumsSelectInput; return MonitorAlbumsSelectInput;
@ -85,9 +83,6 @@ function getComponent(type) {
case inputTypes.INDEXER_SELECT: case inputTypes.INDEXER_SELECT:
return IndexerSelectInputConnector; return IndexerSelectInputConnector;
case inputTypes.INDEXER_FLAGS_SELECT:
return IndexerFlagsSelectInput;
case inputTypes.DOWNLOAD_CLIENT_SELECT: case inputTypes.DOWNLOAD_CLIENT_SELECT:
return DownloadClientSelectInputConnector; return DownloadClientSelectInputConnector;
@ -100,9 +95,6 @@ function getComponent(type) {
case inputTypes.DYNAMIC_SELECT: case inputTypes.DYNAMIC_SELECT:
return EnhancedSelectInputConnector; return EnhancedSelectInputConnector;
case inputTypes.ARTIST_TAG:
return ArtistTagInput;
case inputTypes.SERIES_TYPE_SELECT: case inputTypes.SERIES_TYPE_SELECT:
return SeriesTypeSelectInput; return SeriesTypeSelectInput;
@ -300,7 +292,6 @@ FormInputGroup.propTypes = {
includeNoChangeDisabled: PropTypes.bool, includeNoChangeDisabled: PropTypes.bool,
includeNone: PropTypes.bool, includeNone: PropTypes.bool,
selectedValueOptions: PropTypes.object, selectedValueOptions: PropTypes.object,
indexerFlags: PropTypes.number,
pending: PropTypes.bool, pending: PropTypes.bool,
errors: PropTypes.arrayOf(PropTypes.object), errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object), warnings: PropTypes.arrayOf(PropTypes.object),

View file

@ -1,62 +0,0 @@
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import EnhancedSelectInput from './EnhancedSelectInput';
const selectIndexerFlagsValues = (selectedFlags: number) =>
createSelector(
(state: AppState) => state.settings.indexerFlags,
(indexerFlags) => {
const value = indexerFlags.items.reduce((acc: number[], { id }) => {
// eslint-disable-next-line no-bitwise
if ((selectedFlags & id) === id) {
acc.push(id);
}
return acc;
}, []);
const values = indexerFlags.items.map(({ id, name }) => ({
key: id,
value: name,
}));
return {
value,
values,
};
}
);
interface IndexerFlagsSelectInputProps {
name: string;
indexerFlags: number;
onChange(payload: object): void;
}
function IndexerFlagsSelectInput(props: IndexerFlagsSelectInputProps) {
const { indexerFlags, onChange } = props;
const { value, values } = useSelector(selectIndexerFlagsValues(indexerFlags));
const onChangeWrapper = useCallback(
({ name, value }: { name: string; value: number[] }) => {
const indexerFlags = value.reduce((acc, flagId) => acc + flagId, 0);
onChange({ name, value: indexerFlags });
},
[onChange]
);
return (
<EnhancedSelectInput
{...props}
value={value}
values={values}
onChange={onChangeWrapper}
/>
);
}
export default IndexerFlagsSelectInput;

View file

@ -4,8 +4,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { fetchIndexers } from 'Store/Actions/settingsActions'; import { fetchIndexers } from 'Store/Actions/settingsActions';
import sortByProp from 'Utilities/Array/sortByProp'; import sortByName from 'Utilities/Array/sortByName';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput'; import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() { function createMapStateToProps() {
@ -20,7 +19,7 @@ function createMapStateToProps() {
items items
} = indexers; } = indexers;
const values = _.map(items.sort(sortByProp('name')), (indexer) => { const values = _.map(items.sort(sortByName), (indexer) => {
return { return {
key: indexer.id, key: indexer.id,
value: indexer.name value: indexer.name
@ -30,7 +29,7 @@ function createMapStateToProps() {
if (includeAny) { if (includeAny) {
values.unshift({ values.unshift({
key: 0, key: 0,
value: `(${translate('Any')})` value: '(Any)'
}); });
} }

View file

@ -0,0 +1,156 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import KeyValueListInputItem from './KeyValueListInputItem';
import styles from './KeyValueListInput.css';
class KeyValueListInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isFocused: false
};
}
//
// Listeners
onItemChange = (index, itemValue) => {
const {
name,
value,
onChange
} = this.props;
const newValue = [...value];
if (index == null) {
newValue.push(itemValue);
} else {
newValue.splice(index, 1, itemValue);
}
onChange({
name,
value: newValue
});
};
onRemoveItem = (index) => {
const {
name,
value,
onChange
} = this.props;
const newValue = [...value];
newValue.splice(index, 1);
onChange({
name,
value: newValue
});
};
onFocus = () => {
this.setState({
isFocused: true
});
};
onBlur = () => {
this.setState({
isFocused: false
});
const {
name,
value,
onChange
} = this.props;
const newValue = value.reduce((acc, v) => {
if (v.key || v.value) {
acc.push(v);
}
return acc;
}, []);
if (newValue.length !== value.length) {
onChange({
name,
value: newValue
});
}
};
//
// Render
render() {
const {
className,
value,
keyPlaceholder,
valuePlaceholder,
hasError,
hasWarning
} = this.props;
const { isFocused } = this.state;
return (
<div className={classNames(
className,
isFocused && styles.isFocused,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
>
{
[...value, { key: '', value: '' }].map((v, index) => {
return (
<KeyValueListInputItem
key={index}
index={index}
keyValue={v.key}
value={v.value}
keyPlaceholder={keyPlaceholder}
valuePlaceholder={valuePlaceholder}
isNew={index === value.length}
onChange={this.onItemChange}
onRemove={this.onRemoveItem}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
);
})
}
</div>
);
}
}
KeyValueListInput.propTypes = {
className: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.arrayOf(PropTypes.object).isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
keyPlaceholder: PropTypes.string,
valuePlaceholder: PropTypes.string,
onChange: PropTypes.func.isRequired
};
KeyValueListInput.defaultProps = {
className: styles.inputContainer,
value: []
};
export default KeyValueListInput;

View file

@ -1,104 +0,0 @@
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
import { InputOnChange } from 'typings/inputs';
import KeyValueListInputItem from './KeyValueListInputItem';
import styles from './KeyValueListInput.css';
interface KeyValue {
key: string;
value: string;
}
export interface KeyValueListInputProps {
className?: string;
name: string;
value: KeyValue[];
hasError?: boolean;
hasWarning?: boolean;
keyPlaceholder?: string;
valuePlaceholder?: string;
onChange: InputOnChange<KeyValue[]>;
}
function KeyValueListInput({
className = styles.inputContainer,
name,
value = [],
hasError = false,
hasWarning = false,
keyPlaceholder,
valuePlaceholder,
onChange,
}: KeyValueListInputProps): JSX.Element {
const [isFocused, setIsFocused] = useState(false);
const handleItemChange = useCallback(
(index: number | null, itemValue: KeyValue) => {
const newValue = [...value];
if (index === null) {
newValue.push(itemValue);
} else {
newValue.splice(index, 1, itemValue);
}
onChange({ name, value: newValue });
},
[value, name, onChange]
);
const handleRemoveItem = useCallback(
(index: number) => {
const newValue = [...value];
newValue.splice(index, 1);
onChange({ name, value: newValue });
},
[value, name, onChange]
);
const onFocus = useCallback(() => setIsFocused(true), []);
const onBlur = useCallback(() => {
setIsFocused(false);
const newValue = value.reduce((acc: KeyValue[], v) => {
if (v.key || v.value) {
acc.push(v);
}
return acc;
}, []);
if (newValue.length !== value.length) {
onChange({ name, value: newValue });
}
}, [value, name, onChange]);
return (
<div
className={classNames(
className,
isFocused && styles.isFocused,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
>
{[...value, { key: '', value: '' }].map((v, index) => (
<KeyValueListInputItem
key={index}
index={index}
keyValue={v.key}
value={v.value}
keyPlaceholder={keyPlaceholder}
valuePlaceholder={valuePlaceholder}
isNew={index === value.length}
onChange={handleItemChange}
onRemove={handleRemoveItem}
onFocus={onFocus}
onBlur={onBlur}
/>
))}
</div>
);
}
export default KeyValueListInput;

View file

@ -5,19 +5,13 @@
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
border-bottom: 0;
} }
} }
.keyInputWrapper { .inputWrapper {
flex: 1 0 0; flex: 1 0 0;
} }
.valueInputWrapper {
flex: 1 0 0;
min-width: 40px;
}
.buttonWrapper { .buttonWrapper {
flex: 0 0 22px; flex: 0 0 22px;
} }
@ -26,10 +20,6 @@
.valueInput { .valueInput {
width: 100%; width: 100%;
border: none; border: none;
background-color: transparent; background-color: var(--inputBackgroundColor);
color: var(--textColor); color: var(--textColor);
&::placeholder {
color: var(--helpTextColor);
}
} }

View file

@ -2,11 +2,10 @@
// Please do not change this file! // Please do not change this file!
interface CssExports { interface CssExports {
'buttonWrapper': string; 'buttonWrapper': string;
'inputWrapper': string;
'itemContainer': string; 'itemContainer': string;
'keyInput': string; 'keyInput': string;
'keyInputWrapper': string;
'valueInput': string; 'valueInput': string;
'valueInputWrapper': string;
} }
export const cssExports: CssExports; export const cssExports: CssExports;
export default cssExports; export default cssExports;

View file

@ -0,0 +1,124 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props';
import TextInput from './TextInput';
import styles from './KeyValueListInputItem.css';
class KeyValueListInputItem extends Component {
//
// Listeners
onKeyChange = ({ value: keyValue }) => {
const {
index,
value,
onChange
} = this.props;
onChange(index, { key: keyValue, value });
};
onValueChange = ({ value }) => {
// TODO: Validate here or validate at a lower level component
const {
index,
keyValue,
onChange
} = this.props;
onChange(index, { key: keyValue, value });
};
onRemovePress = () => {
const {
index,
onRemove
} = this.props;
onRemove(index);
};
onFocus = () => {
this.props.onFocus();
};
onBlur = () => {
this.props.onBlur();
};
//
// Render
render() {
const {
keyValue,
value,
keyPlaceholder,
valuePlaceholder,
isNew
} = this.props;
return (
<div className={styles.itemContainer}>
<div className={styles.inputWrapper}>
<TextInput
className={styles.keyInput}
name="key"
value={keyValue}
placeholder={keyPlaceholder}
onChange={this.onKeyChange}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
</div>
<div className={styles.inputWrapper}>
<TextInput
className={styles.valueInput}
name="value"
value={value}
placeholder={valuePlaceholder}
onChange={this.onValueChange}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
</div>
<div className={styles.buttonWrapper}>
{
isNew ?
null :
<IconButton
name={icons.REMOVE}
tabIndex={-1}
onPress={this.onRemovePress}
/>
}
</div>
</div>
);
}
}
KeyValueListInputItem.propTypes = {
index: PropTypes.number,
keyValue: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
keyPlaceholder: PropTypes.string.isRequired,
valuePlaceholder: PropTypes.string.isRequired,
isNew: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
onFocus: PropTypes.func.isRequired,
onBlur: PropTypes.func.isRequired
};
KeyValueListInputItem.defaultProps = {
keyPlaceholder: 'Key',
valuePlaceholder: 'Value'
};
export default KeyValueListInputItem;

View file

@ -1,89 +0,0 @@
import React, { useCallback } from 'react';
import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props';
import TextInput from './TextInput';
import styles from './KeyValueListInputItem.css';
interface KeyValueListInputItemProps {
index: number;
keyValue: string;
value: string;
keyPlaceholder?: string;
valuePlaceholder?: string;
isNew: boolean;
onChange: (index: number, itemValue: { key: string; value: string }) => void;
onRemove: (index: number) => void;
onFocus: () => void;
onBlur: () => void;
}
function KeyValueListInputItem({
index,
keyValue,
value,
keyPlaceholder = 'Key',
valuePlaceholder = 'Value',
isNew,
onChange,
onRemove,
onFocus,
onBlur,
}: KeyValueListInputItemProps): JSX.Element {
const handleKeyChange = useCallback(
({ value: keyValue }: { value: string }) => {
onChange(index, { key: keyValue, value });
},
[index, value, onChange]
);
const handleValueChange = useCallback(
({ value }: { value: string }) => {
onChange(index, { key: keyValue, value });
},
[index, keyValue, onChange]
);
const handleRemovePress = useCallback(() => {
onRemove(index);
}, [index, onRemove]);
return (
<div className={styles.itemContainer}>
<div className={styles.keyInputWrapper}>
<TextInput
className={styles.keyInput}
name="key"
value={keyValue}
placeholder={keyPlaceholder}
onChange={handleKeyChange}
onFocus={onFocus}
onBlur={onBlur}
/>
</div>
<div className={styles.valueInputWrapper}>
<TextInput
className={styles.valueInput}
name="value"
value={value}
placeholder={valuePlaceholder}
onChange={handleValueChange}
onFocus={onFocus}
onBlur={onBlur}
/>
</div>
<div className={styles.buttonWrapper}>
{isNew ? null : (
<IconButton
name={icons.REMOVE}
tabIndex={-1}
onPress={handleRemovePress}
/>
)}
</div>
</div>
);
}
export default KeyValueListInputItem;

View file

@ -5,13 +5,13 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { metadataProfileNames } from 'Helpers/Props'; import { metadataProfileNames } from 'Helpers/Props';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByProp from 'Utilities/Array/sortByProp'; import sortByName from 'Utilities/Array/sortByName';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput'; import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
createSortedSectionSelector('settings.metadataProfiles', sortByProp('name')), createSortedSectionSelector('settings.metadataProfiles', sortByName),
(state, { includeNoChange }) => includeNoChange, (state, { includeNoChange }) => includeNoChange,
(state, { includeNoChangeDisabled }) => includeNoChangeDisabled, (state, { includeNoChangeDisabled }) => includeNoChangeDisabled,
(state, { includeMixed }) => includeMixed, (state, { includeMixed }) => includeMixed,
@ -38,7 +38,7 @@ function createMapStateToProps() {
values.unshift({ values.unshift({
key: 'noChange', key: 'noChange',
value: translate('NoChange'), value: translate('NoChange'),
isDisabled: includeNoChangeDisabled disabled: includeNoChangeDisabled
}); });
} }
@ -46,7 +46,7 @@ function createMapStateToProps() {
values.unshift({ values.unshift({
key: 'mixed', key: 'mixed',
value: '(Mixed)', value: '(Mixed)',
isDisabled: true disabled: true
}); });
} }

View file

@ -18,15 +18,15 @@ function MonitorAlbumsSelectInput(props) {
values.unshift({ values.unshift({
key: 'noChange', key: 'noChange',
value: translate('NoChange'), value: translate('NoChange'),
isDisabled: includeNoChangeDisabled disabled: includeNoChangeDisabled
}); });
} }
if (includeMixed) { if (includeMixed) {
values.unshift({ values.unshift({
key: 'mixed', key: 'mixed',
value: `(${translate('Mixed')})`, value: '(Mixed)',
isDisabled: true disabled: true
}); });
} }

View file

@ -18,7 +18,7 @@ function MonitorNewItemsSelectInput(props) {
values.unshift({ values.unshift({
key: 'noChange', key: 'noChange',
value: translate('NoChange'), value: translate('NoChange'),
isDisabled: includeNoChangeDisabled disabled: includeNoChangeDisabled
}); });
} }
@ -26,7 +26,7 @@ function MonitorNewItemsSelectInput(props) {
values.unshift({ values.unshift({
key: 'mixed', key: 'mixed',
value: '(Mixed)', value: '(Mixed)',
isDisabled: true disabled: true
}); });
} }

View file

@ -0,0 +1,5 @@
.input {
composes: input from '~Components/Form/TextInput.css';
font-family: $passwordFamily;
}

View file

@ -1,7 +1,7 @@
// This file is automatically generated. // This file is automatically generated.
// Please do not change this file! // Please do not change this file!
interface CssExports { interface CssExports {
'modalBody': string; 'input': string;
} }
export const cssExports: CssExports; export const cssExports: CssExports;
export default cssExports; export default cssExports;

View file

@ -1,5 +1,7 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import TextInput from './TextInput'; import TextInput from './TextInput';
import styles from './PasswordInput.css';
// Prevent a user from copying (or cutting) the password from the input // Prevent a user from copying (or cutting) the password from the input
function onCopy(e) { function onCopy(e) {
@ -11,14 +13,17 @@ function PasswordInput(props) {
return ( return (
<TextInput <TextInput
{...props} {...props}
type="password"
onCopy={onCopy} onCopy={onCopy}
/> />
); );
} }
PasswordInput.propTypes = { PasswordInput.propTypes = {
...TextInput.props className: PropTypes.string.isRequired
};
PasswordInput.defaultProps = {
className: styles.input
}; };
export default PasswordInput; export default PasswordInput;

View file

@ -14,8 +14,6 @@ function getType({ type, selectOptionsProviderAction }) {
return inputTypes.CHECK; return inputTypes.CHECK;
case 'device': case 'device':
return inputTypes.DEVICE; return inputTypes.DEVICE;
case 'keyValueList':
return inputTypes.KEY_VALUE_LIST;
case 'playlist': case 'playlist':
return inputTypes.PLAYLIST; return inputTypes.PLAYLIST;
case 'password': case 'password':
@ -31,8 +29,6 @@ function getType({ type, selectOptionsProviderAction }) {
return inputTypes.DYNAMIC_SELECT; return inputTypes.DYNAMIC_SELECT;
} }
return inputTypes.SELECT; return inputTypes.SELECT;
case 'artistTag':
return inputTypes.ARTIST_TAG;
case 'tag': case 'tag':
return inputTypes.TEXT_TAG; return inputTypes.TEXT_TAG;
case 'tagSelect': case 'tagSelect':

View file

@ -4,13 +4,13 @@ import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByProp from 'Utilities/Array/sortByProp'; import sortByName from 'Utilities/Array/sortByName';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput'; import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
createSortedSectionSelector('settings.qualityProfiles', sortByProp('name')), createSortedSectionSelector('settings.qualityProfiles', sortByName),
(state, { includeNoChange }) => includeNoChange, (state, { includeNoChange }) => includeNoChange,
(state, { includeNoChangeDisabled }) => includeNoChangeDisabled, (state, { includeNoChangeDisabled }) => includeNoChangeDisabled,
(state, { includeMixed }) => includeMixed, (state, { includeMixed }) => includeMixed,
@ -26,7 +26,7 @@ function createMapStateToProps() {
values.unshift({ values.unshift({
key: 'noChange', key: 'noChange',
value: translate('NoChange'), value: translate('NoChange'),
isDisabled: includeNoChangeDisabled disabled: includeNoChangeDisabled
}); });
} }
@ -34,7 +34,7 @@ function createMapStateToProps() {
values.unshift({ values.unshift({
key: 'mixed', key: 'mixed',
value: '(Mixed)', value: '(Mixed)',
isDisabled: true disabled: true
}); });
} }

View file

@ -52,7 +52,6 @@ class SelectInput extends Component {
const { const {
key, key,
value: optionValue, value: optionValue,
isDisabled: optionIsDisabled = false,
...otherOptionProps ...otherOptionProps
} = option; } = option;
@ -60,7 +59,6 @@ class SelectInput extends Component {
<option <option
key={key} key={key}
value={key} value={key}
disabled={optionIsDisabled}
{...otherOptionProps} {...otherOptionProps}
> >
{typeof optionValue === 'function' ? optionValue() : optionValue} {typeof optionValue === 'function' ? optionValue() : optionValue}

View file

@ -22,7 +22,7 @@ function SeriesTypeSelectInput(props) {
values.unshift({ values.unshift({
key: 'noChange', key: 'noChange',
value: translate('NoChange'), value: translate('NoChange'),
isDisabled: includeNoChangeDisabled disabled: includeNoChangeDisabled
}); });
} }
@ -30,7 +30,7 @@ function SeriesTypeSelectInput(props) {
values.unshift({ values.unshift({
key: 'mixed', key: 'mixed',
value: '(Mixed)', value: '(Mixed)',
isDisabled: true disabled: true
}); });
} }

View file

@ -1,6 +1,5 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import FilterMenuItem from './FilterMenuItem'; import FilterMenuItem from './FilterMenuItem';
import MenuContent from './MenuContent'; import MenuContent from './MenuContent';
@ -48,7 +47,7 @@ class FilterMenuContent extends Component {
{ {
customFilters customFilters
.sort(sortByProp('label')) .sort((a, b) => a.label.localeCompare(b.label))
.map((filter) => { .map((filter) => {
return ( return (
<FilterMenuItem <FilterMenuItem

View file

@ -63,13 +63,6 @@
width: 1280px; width: 1280px;
} }
.extraExtraLarge {
composes: modal;
width: 1600px;
}
@media only screen and (max-width: $breakpointExtraLarge) { @media only screen and (max-width: $breakpointExtraLarge) {
.modal.extraLarge { .modal.extraLarge {
width: 90%; width: 90%;
@ -83,6 +76,13 @@
} }
@media only screen and (max-width: $breakpointMedium) { @media only screen and (max-width: $breakpointMedium) {
.modal.small,
.modal.medium {
width: 90%;
}
}
@media only screen and (max-width: $breakpointSmall) {
.modalContainer { .modalContainer {
position: fixed; position: fixed;
} }
@ -90,8 +90,7 @@
.modal.small, .modal.small,
.modal.medium, .modal.medium,
.modal.large, .modal.large,
.modal.extraLarge, .modal.extraLarge {
.modal.extraExtraLarge {
max-height: 100%; max-height: 100%;
width: 100%; width: 100%;
height: 100% !important; height: 100% !important;

View file

@ -1,7 +1,6 @@
// This file is automatically generated. // This file is automatically generated.
// Please do not change this file! // Please do not change this file!
interface CssExports { interface CssExports {
'extraExtraLarge': string;
'extraLarge': string; 'extraLarge': string;
'large': string; 'large': string;
'medium': string; 'medium': string;

View file

@ -3,9 +3,9 @@
padding: 0; padding: 0;
font-size: inherit; font-size: inherit;
}
&.isDisabled {
color: var(--disabledColor); .isDisabled {
cursor: not-allowed; color: var(--disabledColor);
} cursor: not-allowed;
} }

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