Compare commits

..

No commits in common. "develop" and "v1.4.3.3586" have entirely different histories.

1307 changed files with 23817 additions and 45745 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
- Develop
- Nightly
- Plugins (experimental)
- Other (This issue will be closed)
validations:
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

@ -1,16 +0,0 @@
# Configuration for Label Actions - https://github.com/dessant/label-actions
'Type: Support':
comment: >
:wave: @{issue-author}, we use the issue tracker exclusively
for bug reports and feature requests. However, this issue appears
to be a support request. Please hop over onto our [Discord](https://lidarr.audio/discord).
close: true
close-reason: 'not planned'
'Status: Logs Needed':
comment: >
:wave: @{issue-author}, In order to help you further we'll need to see logs.
You'll need to enable trace logging and replicate the problem that you encountered.
Guidance on how to enable trace logging can be found in
our [troubleshooting guide](https://wiki.servarr.com/lidarr/troubleshooting#logging-and-log-files).

View file

@ -1,17 +0,0 @@
name: 'Label Actions'
on:
issues:
types: [labeled, unlabeled]
permissions:
contents: read
issues: write
jobs:
action:
runs-on: ubuntu-latest
steps:
- uses: dessant/label-actions@v4
with:
process-only: 'issues'

View file

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

32
.github/workflows/support.yml vendored Normal file
View file

@ -0,0 +1,32 @@
name: 'Support requests'
on:
issues:
types: [labeled, unlabeled, reopened]
jobs:
support:
runs-on: ubuntu-latest
steps:
- uses: dessant/support-requests@v3
with:
github-token: ${{ github.token }}
support-label: 'Type: Support'
issue-comment: >
:wave: @{issue-author}, we use the issue tracker exclusively
for bug reports and feature requests. However, this issue appears
to be a support request. Please hop over onto our [Discord](https://lidarr.audio/discord).
close-issue: true
close-reason: 'not planned'
lock-issue: false
- uses: dessant/support-requests@v3
with:
github-token: ${{ github.token }}
support-label: 'Status: Logs Needed'
issue-comment: >
:wave: @{issue-author}, In order to help you further we'll need to see logs.
You'll need to enable trace logging and replicate the problem that you encountered.
Guidance on how to enable trace logging can be found in
our [troubleshooting guide](https://wiki.servarr.com/lidarr/troubleshooting#logging-and-log-files).
close-issue: false
lock-issue: false

36
.gitignore vendored
View file

@ -121,13 +121,11 @@ _artifacts
_rawPackage/
_dotTrace*
_tests/
_temp*
*.Result.xml
coverage*.xml
coverage*.json
setup/Output/
*.~is
.mono
# VS outout folders
bin
@ -140,6 +138,12 @@ project.fragment.lock.json
artifacts/
**/Properties/launchSettings.json
#VS outout folders
bin
obj
output/*
# macOS metadata files
._*
.DS_Store
@ -158,12 +162,34 @@ Thumbs.db
/tools/Addins/*
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
node_modules
node_modules.nosync
# API doc generation
.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
[![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)
![Github Downloads](https://img.shields.io/github/downloads/lidarr/lidarr/total.svg)
[![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.
> [!WARNING]
> NOTICE - The Lidarr Metadata Server is recovering and rebuilding the cache which is impacting adding artists, library imports, etc. Please follow [GHI 5498](https://github.com/Lidarr/Lidarr/issues/5498) or see Discord for details.
## Major Features Include:
* Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc.

View file

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

View file

@ -1,182 +0,0 @@
#!/bin/bash
### Description: Lidarr .NET Debian install
### Originally written for Radarr by: DoctorArr - doctorarr@the-rowlands.co.uk on 2021-10-01 v1.0
### Updates for servarr suite made by Bakerboy448, DoctorArr, brightghost, aeramor and VP-EN
### Version v1.0.0 2023-12-29 - StevieTV - adapted from servarr script for Lidarr installs
### Version V1.0.1 2024-01-02 - StevieTV - remove UTF8-BOM
### Version V1.0.2 2024-01-03 - markus101 - Get user input from /dev/tty
### Version V1.0.3 2024-01-06 - StevieTV - exit script when it is ran from install directory
### Boilerplate Warning
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
#EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
#MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
#NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
#LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
#OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
#WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
scriptversion="1.0.3"
scriptdate="2024-01-06"
set -euo pipefail
echo "Running Lidarr Install Script - Version [$scriptversion] as of [$scriptdate]"
# Am I root?, need root!
if [ "$EUID" -ne 0 ]; then
echo "Please run as root."
exit
fi
app="lidarr"
app_port="8686"
app_prereq="curl sqlite3 wget"
app_umask="0002"
branch="main"
# Constants
### Update these variables as required for your specific instance
installdir="/opt" # {Update me if needed} Install Location
bindir="${installdir}/${app^}" # Full Path to Install Location
datadir="/var/lib/$app/" # {Update me if needed} AppData directory to use
app_bin=${app^} # Binary Name of the app
# This script should not be ran from installdir, otherwise later in the script the extracted files will be removed before they can be moved to installdir.
if [ "$installdir" == "$(dirname -- "$( readlink -f -- "$0"; )")" ] || [ "$bindir" == "$(dirname -- "$( readlink -f -- "$0"; )")" ]; then
echo "You should not run this script from the intended install directory. The script will exit. Please re-run it from another directory"
exit
fi
# Prompt User
read -r -p "What user should ${app^} run as? (Default: $app): " app_uid < /dev/tty
app_uid=$(echo "$app_uid" | tr -d ' ')
app_uid=${app_uid:-$app}
# Prompt Group
read -r -p "What group should ${app^} run as? (Default: media): " app_guid < /dev/tty
app_guid=$(echo "$app_guid" | tr -d ' ')
app_guid=${app_guid:-media}
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"
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
# Create User / Group as needed
if [ "$app_guid" != "$app_uid" ]; then
if ! getent group "$app_guid" >/dev/null; then
groupadd "$app_guid"
fi
fi
if ! getent passwd "$app_uid" >/dev/null; then
adduser --system --no-create-home --ingroup "$app_guid" "$app_uid"
echo "Created and added User [$app_uid] to Group [$app_guid]"
fi
if ! getent group "$app_guid" | grep -qw "$app_uid"; then
echo "User [$app_uid] did not exist in Group [$app_guid]"
usermod -a -G "$app_guid" "$app_uid"
echo "Added User [$app_uid] to Group [$app_guid]"
fi
# Stop the App if running
if service --status-all | grep -Fq "$app"; then
systemctl stop "$app"
systemctl disable "$app".service
echo "Stopped existing $app"
fi
# Create Appdata Directory
# AppData
mkdir -p "$datadir"
chown -R "$app_uid":"$app_guid" "$datadir"
chmod 775 "$datadir"
echo "Directories created"
# Download and install the App
# prerequisite packages
echo ""
echo "Installing pre-requisite Packages"
# shellcheck disable=SC2086
apt update && apt install -y $app_prereq
echo ""
ARCH=$(dpkg --print-architecture)
# get arch
dlbase="https://lidarr.servarr.com/v1/update/$branch/updatefile?os=linux&runtime=netcore"
case "$ARCH" in
"amd64") DLURL="${dlbase}&arch=x64" ;;
"armhf") DLURL="${dlbase}&arch=arm" ;;
"arm64") DLURL="${dlbase}&arch=arm64" ;;
*)
echo "Arch not supported"
exit 1
;;
esac
echo ""
echo "Removing previous tarballs"
# -f to Force so we fail if it doesn't exist
rm -f "${app^}".*.tar.gz
echo ""
echo "Downloading..."
wget --content-disposition "$DLURL"
tar -xvzf "${app^}".*.tar.gz
echo ""
echo "Installation files downloaded and extracted"
# remove existing installs
echo "Removing existing installation"
rm -rf "$bindir"
echo "Installing..."
mv "${app^}" $installdir
chown "$app_uid":"$app_guid" -R "$bindir"
chmod 775 "$bindir"
rm -rf "${app^}.*.tar.gz"
# Ensure we check for an update in case user installs older version or different branch
touch "$datadir"/update_required
chown "$app_uid":"$app_guid" "$datadir"/update_required
echo "App Installed"
# Configure Autostart
# Remove any previous app .service
echo "Removing old service file"
rm -rf /etc/systemd/system/"$app".service
# Create app .service with correct user startup
echo "Creating service file"
cat <<EOF | tee /etc/systemd/system/"$app".service >/dev/null
[Unit]
Description=${app^} Daemon
After=syslog.target network.target
[Service]
User=$app_uid
Group=$app_guid
UMask=$app_umask
Type=simple
ExecStart=$bindir/$app_bin -nobrowser -data=$datadir
TimeoutStopSec=20
KillMode=process
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
# Start the App
echo "Service file created. Attempting to start the app"
systemctl -q daemon-reload
systemctl enable --now -q "$app"
# Finish Update/Installation
host=$(hostname -I)
ip_local=$(grep -oP '^\S*' <<<"$host")
echo ""
echo "Install complete"
sleep 10
STATUS="$(systemctl is-active "$app")"
if [ "${STATUS}" = "active" ]; then
echo "Browse to http://$ip_local:$app_port for the ${app^} GUI"
else
echo "${app^} failed to start"
fi
# Exit
exit 0

View file

@ -1,20 +0,0 @@
# This file is owned by the lidarr package, DO NOT MODIFY MANUALLY
# Instead use 'dpkg-reconfigure -plow lidarr' to modify User/Group/UMask/-data
# Or use systemd built-in override functionality using 'systemctl edit lidarr'
[Unit]
Description=Lidarr Daemon
After=network.target
[Service]
User=lidarr
Group=lidarr
UMask=002
Type=simple
ExecStart=/opt/Lidarr/Lidarr -nobrowser -data=/var/lib/lidarr
TimeoutStopSec=20
KillMode=process
Restart=on-failure
[Install]
WantedBy=multi-user.target

23
docs.sh
View file

@ -1,18 +1,13 @@
#!/bin/bash
set -e
FRAMEWORK="net6.0"
PLATFORM=$1
ARCHITECTURE="${2:-x64}"
if [ "$PLATFORM" = "Windows" ]; then
RUNTIME="win-$ARCHITECTURE"
RUNTIME="win-x64"
elif [ "$PLATFORM" = "Linux" ]; then
RUNTIME="linux-$ARCHITECTURE"
RUNTIME="linux-x64"
elif [ "$PLATFORM" = "Mac" ]; then
RUNTIME="osx-$ARCHITECTURE"
RUNTIME="osx-x64"
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
fi
@ -26,21 +21,15 @@ slnFile=src/Lidarr.sln
platform=Posix
if [ "$PLATFORM" = "Windows" ]; then
application=Lidarr.Console.dll
else
application=Lidarr.dll
fi
dotnet clean $slnFile -c Debug
dotnet clean $slnFile -c Release
dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids
dotnet new tool-manifest
dotnet tool install --version 6.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

View file

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

View file

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

View file

@ -2,8 +2,6 @@ const loose = true;
module.exports = {
plugins: [
'@babel/plugin-transform-logical-assignment-operators',
// Stage 1
'@babel/plugin-proposal-export-default-from',
['@babel/plugin-transform-optional-chaining', { loose }],

View file

@ -26,7 +26,6 @@ module.exports = (env) => {
const config = {
mode: isProduction ? 'production' : 'development',
devtool: isProduction ? 'source-map' : 'eval-source-map',
target: 'web',
stats: {
children: false
@ -68,7 +67,7 @@ module.exports = (env) => {
output: {
path: distFolder,
publicPath: '/',
filename: isProduction ? '[name]-[contenthash].js' : '[name].js',
filename: '[name]-[contenthash].js',
sourceMapFilename: '[file].map'
},
@ -93,7 +92,7 @@ module.exports = (env) => {
new MiniCssExtractPlugin({
filename: 'Content/styles.css',
chunkFilename: isProduction ? 'Content/[id]-[chunkhash].css' : 'Content/[id].css'
chunkFilename: 'Content/[id]-[chunkhash].css'
}),
new HtmlWebpackPlugin({
@ -135,12 +134,6 @@ module.exports = (env) => {
{
source: 'frontend/src/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,
debug: false,
useBuiltIns: 'entry',
corejs: '3.41'
corejs: 3
}
]
]
@ -209,7 +202,7 @@ module.exports = (env) => {
options: {
importLoaders: 1,
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 = {
plugins: [
'autoprefixer',
['postcss-mixins', {
mixinsFiles
}],

View file

@ -36,7 +36,6 @@ class Blocklist extends Component {
lastToggled: null,
selectedState: {},
isConfirmRemoveModalOpen: false,
isConfirmClearModalOpen: false,
items: props.items
};
}
@ -91,19 +90,6 @@ class Blocklist extends Component {
this.setState({ isConfirmRemoveModalOpen: false });
};
onClearBlocklistPress = () => {
this.setState({ isConfirmClearModalOpen: true });
};
onClearBlocklistConfirmed = () => {
this.props.onClearBlocklistPress();
this.setState({ isConfirmClearModalOpen: false });
};
onConfirmClearModalClose = () => {
this.setState({ isConfirmClearModalOpen: false });
};
//
// Render
@ -119,6 +105,7 @@ class Blocklist extends Component {
totalRecords,
isRemoving,
isClearingBlocklistExecuting,
onClearBlocklistPress,
...otherProps
} = this.props;
@ -129,8 +116,7 @@ class Blocklist extends Component {
allSelected,
allUnselected,
selectedState,
isConfirmRemoveModalOpen,
isConfirmClearModalOpen
isConfirmRemoveModalOpen
} = this.state;
const selectedIds = this.getSelectedIds();
@ -150,9 +136,8 @@ class Blocklist extends Component {
<PageToolbarButton
label={translate('Clear')}
iconName={icons.CLEAR}
isDisabled={!items.length}
isSpinning={isClearingBlocklistExecuting}
onPress={this.onClearBlocklistPress}
onPress={onClearBlocklistPress}
/>
</PageToolbarSection>
@ -235,16 +220,6 @@ class Blocklist extends Component {
onConfirm={this.onRemoveSelectedConfirmed}
onCancel={this.onConfirmRemoveModalClose}
/>
<ConfirmModal
isOpen={isConfirmClearModalOpen}
kind={kinds.DANGER}
title={translate('ClearBlocklist')}
message={translate('ClearBlocklistMessageText')}
confirmLabel={translate('Clear')}
onConfirm={this.onClearBlocklistConfirmed}
onCancel={this.onConfirmClearModalClose}
/>
</PageContent>
);
}

View file

@ -60,7 +60,6 @@ function HistoryDetails(props) {
eventType,
sourceTitle,
data,
downloadId,
shortDateFormat,
timeFormat
} = props;
@ -73,6 +72,7 @@ function HistoryDetails(props) {
nzbInfoUrl,
downloadClient,
downloadClientName,
downloadId,
age,
ageHours,
ageMinutes,
@ -90,22 +90,20 @@ function HistoryDetails(props) {
/>
{
indexer ?
!!indexer &&
<DescriptionListItem
title={translate('Indexer')}
data={indexer}
/> :
null
/>
}
{
releaseGroup ?
!!releaseGroup &&
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('ReleaseGroup')}
data={releaseGroup}
/> :
null
/>
}
{
@ -121,7 +119,7 @@ function HistoryDetails(props) {
nzbInfoUrl ?
<span>
<DescriptionListItemTitle>
{translate('InfoUrl')}
Info URL
</DescriptionListItemTitle>
<DescriptionListItemDescription>
@ -141,30 +139,27 @@ function HistoryDetails(props) {
}
{
downloadId ?
!!downloadId &&
<DescriptionListItem
title={translate('GrabId')}
title={translate('GrabID')}
data={downloadId}
/> :
null
/>
}
{
age || ageHours || ageMinutes ?
!!indexer &&
<DescriptionListItem
title={translate('AgeWhenGrabbed')}
data={formatAge(age, ageHours, ageMinutes)}
/> :
null
/>
}
{
publishedDate ?
!!publishedDate &&
<DescriptionListItem
title={translate('PublishedDate')}
data={formatDateTime(publishedDate, shortDateFormat, timeFormat, { includeSeconds: true })}
/> :
null
/>
}
</DescriptionList>
);
@ -172,8 +167,7 @@ function HistoryDetails(props) {
if (eventType === 'downloadFailed') {
const {
message,
indexer
message
} = data;
return (
@ -185,29 +179,11 @@ function HistoryDetails(props) {
/>
{
downloadId ?
<DescriptionListItem
title={translate('GrabId')}
data={downloadId}
/> :
null
}
{
indexer ? (
<DescriptionListItem
title={translate('Indexer')}
data={indexer}
/>
) : null}
{
message ?
!!message &&
<DescriptionListItem
title={translate('Message')}
data={message}
/> :
null
/>
}
</DescriptionList>
);
@ -229,13 +205,12 @@ function HistoryDetails(props) {
/>
{
droppedPath ?
!!droppedPath &&
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Source')}
data={droppedPath}
/> :
null
/>
}
{
@ -273,7 +248,7 @@ function HistoryDetails(props) {
reasonMessage = 'File was deleted by via UI';
break;
case 'MissingFromDisk':
reasonMessage = 'Lidarr was unable to find the file on disk so the file was unlinked from the album/track in the database';
reasonMessage = 'Lidarr was unable to find the file on disk so it was removed';
break;
case 'Upgrade':
reasonMessage = 'File was deleted to import an upgrade';
@ -385,9 +360,9 @@ function HistoryDetails(props) {
const {
indexer,
releaseGroup,
customFormatScore,
nzbInfoUrl,
downloadClient,
downloadId,
age,
ageHours,
ageMinutes,
@ -402,80 +377,64 @@ function HistoryDetails(props) {
/>
{
indexer ?
!!indexer &&
<DescriptionListItem
title={translate('Indexer')}
data={indexer}
/> :
null
/>
}
{
releaseGroup ?
!!releaseGroup &&
<DescriptionListItem
title={translate('ReleaseGroup')}
data={releaseGroup}
/> :
null
/>
}
{
customFormatScore && customFormatScore !== '0' ?
<DescriptionListItem
title={translate('CustomFormatScore')}
data={formatCustomFormatScore(customFormatScore)}
/> :
null
}
{
nzbInfoUrl ?
!!nzbInfoUrl &&
<span>
<DescriptionListItemTitle>
{translate('InfoUrl')}
Info URL
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to={nzbInfoUrl}>{nzbInfoUrl}</Link>
</DescriptionListItemDescription>
</span> :
null
</span>
}
{
downloadClient ?
!!downloadClient &&
<DescriptionListItem
title={translate('DownloadClient')}
data={downloadClient}
/> :
null
/>
}
{
downloadId ?
!!downloadId &&
<DescriptionListItem
title={translate('GrabId')}
title={translate('GrabID')}
data={downloadId}
/> :
null
/>
}
{
age || ageHours || ageMinutes ?
!!indexer &&
<DescriptionListItem
title={translate('AgeWhenGrabbed')}
data={formatAge(age, ageHours, ageMinutes)}
/> :
null
/>
}
{
publishedDate ?
!!publishedDate &&
<DescriptionListItem
title={translate('PublishedDate')}
data={formatDateTime(publishedDate, shortDateFormat, timeFormat, { includeSeconds: true })}
/> :
null
/>
}
</DescriptionList>
);
@ -495,21 +454,11 @@ function HistoryDetails(props) {
/>
{
downloadId ?
<DescriptionListItem
title={translate('GrabId')}
data={downloadId}
/> :
null
}
{
message ?
!!message &&
<DescriptionListItem
title={translate('Message')}
data={message}
/> :
null
/>
}
</DescriptionList>
);
@ -530,7 +479,6 @@ HistoryDetails.propTypes = {
eventType: PropTypes.string.isRequired,
sourceTitle: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
downloadId: PropTypes.string,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired
};

View file

@ -42,7 +42,6 @@ function HistoryDetailsModal(props) {
eventType,
sourceTitle,
data,
downloadId,
isMarkingAsFailed,
shortDateFormat,
timeFormat,
@ -65,7 +64,6 @@ function HistoryDetailsModal(props) {
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
downloadId={downloadId}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
/>
@ -100,7 +98,6 @@ HistoryDetailsModal.propTypes = {
eventType: PropTypes.string.isRequired,
sourceTitle: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
downloadId: PropTypes.string,
isMarkingAsFailed: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,

View file

@ -15,7 +15,6 @@ import TablePager from 'Components/Table/TablePager';
import { align, icons, kinds } from 'Helpers/Props';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import translate from 'Utilities/String/translate';
import HistoryFilterModal from './HistoryFilterModal';
import HistoryRowConnector from './HistoryRowConnector';
class History extends Component {
@ -53,7 +52,6 @@ class History extends Component {
columns,
selectedFilterKey,
filters,
customFilters,
totalRecords,
isArtistFetching,
isArtistPopulated,
@ -96,8 +94,7 @@ class History extends Component {
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={HistoryFilterModal}
customFilters={[]}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
@ -168,9 +165,8 @@ History.propTypes = {
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number,
isArtistFetching: PropTypes.bool.isRequired,
isArtistPopulated: PropTypes.bool.isRequired,

View file

@ -6,7 +6,6 @@ import withCurrentPage from 'Components/withCurrentPage';
import { clearAlbums, fetchAlbums } from 'Store/Actions/albumActions';
import * as historyActions from 'Store/Actions/historyActions';
import { clearTracks, fetchTracks } from 'Store/Actions/trackActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
@ -18,8 +17,7 @@ function createMapStateToProps() {
(state) => state.artist,
(state) => state.albums,
(state) => state.tracks,
createCustomFiltersSelector('history'),
(history, artist, albums, tracks, customFilters) => {
(history, artist, albums, tracks) => {
return {
isArtistFetching: artist.isFetching,
isArtistPopulated: artist.isPopulated,
@ -29,7 +27,6 @@ function createMapStateToProps() {
isTracksFetching: tracks.isFetching,
isTracksPopulated: tracks.isPopulated,
tracksError: tracks.error,
customFilters,
...history
};
}

View file

@ -3,10 +3,9 @@ import React from 'react';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './HistoryEventTypeCell.css';
function getIconName(eventType, data) {
function getIconName(eventType) {
switch (eventType) {
case 'grabbed':
return icons.DOWNLOADING;
@ -17,7 +16,7 @@ function getIconName(eventType, data) {
case 'downloadFailed':
return icons.DOWNLOADING;
case 'trackFileDeleted':
return data.reason === 'MissingFromDisk' ? icons.FILE_MISSING : icons.DELETE;
return icons.DELETE;
case 'trackFileRenamed':
return icons.ORGANIZE;
case 'trackFileRetagged':
@ -55,11 +54,11 @@ function getTooltip(eventType, data) {
case 'downloadFailed':
return 'Album download failed';
case 'trackFileDeleted':
return data.reason === 'MissingFromDisk' ? translate('TrackFileMissingTooltip') : translate('TrackFileDeletedTooltip');
return 'Track file deleted';
case 'trackFileRenamed':
return translate('TrackFileRenamedTooltip');
return 'Track file renamed';
case 'trackFileRetagged':
return translate('TrackFileTagsUpdatedTooltip');
return 'Track file tags updated';
case 'albumImportIncomplete':
return 'Files downloaded but not all could be imported';
case 'downloadImported':
@ -72,7 +71,7 @@ function getTooltip(eventType, data) {
}
function HistoryEventTypeCell({ eventType, data }) {
const iconName = getIconName(eventType, data);
const iconName = getIconName(eventType);
const iconKind = getIconKind(eventType);
const tooltip = getTooltip(eventType, data);

View file

@ -1,54 +0,0 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import { setHistoryFilter } from 'Store/Actions/historyActions';
function createHistorySelector() {
return createSelector(
(state: AppState) => state.history.items,
(queueItems) => {
return queueItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.history.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
interface HistoryFilterModalProps {
isOpen: boolean;
}
export default function HistoryFilterModal(props: HistoryFilterModalProps) {
const sectionItems = useSelector(createHistorySelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'history';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
dispatch(setHistoryFilter(payload));
},
[dispatch]
);
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>
);
}

View file

@ -65,7 +65,6 @@ class HistoryRow extends Component {
sourceTitle,
date,
data,
downloadId,
isMarkingAsFailed,
columns,
shortDateFormat,
@ -245,7 +244,6 @@ class HistoryRow extends Component {
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
downloadId={downloadId}
isMarkingAsFailed={isMarkingAsFailed}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
@ -271,7 +269,6 @@ HistoryRow.propTypes = {
sourceTitle: PropTypes.string.isRequired,
date: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
downloadId: PropTypes.string,
isMarkingAsFailed: PropTypes.bool,
markAsFailedError: PropTypes.object,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,

View file

@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
@ -22,10 +21,9 @@ import getSelectedIds from 'Utilities/Table/getSelectedIds';
import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import QueueFilterModal from './QueueFilterModal';
import QueueOptionsConnector from './QueueOptionsConnector';
import QueueRowConnector from './QueueRowConnector';
import RemoveQueueItemModal from './RemoveQueueItemModal';
import RemoveQueueItemsModal from './RemoveQueueItemsModal';
class Queue extends Component {
@ -157,16 +155,11 @@ class Queue extends Component {
isAlbumsPopulated,
albumsError,
columns,
selectedFilterKey,
filters,
customFilters,
count,
totalRecords,
isGrabbing,
isRemoving,
isRefreshMonitoredDownloadsExecuting,
onRefreshPress,
onFilterSelect,
...otherProps
} = this.props;
@ -229,15 +222,6 @@ class Queue extends Component {
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={QueueFilterModal}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
@ -259,11 +243,7 @@ class Queue extends Component {
{
isAllPopulated && !hasError && !items.length ?
<Alert kind={kinds.INFO}>
{
selectedFilterKey !== 'all' && count > 0 ?
translate('QueueFilterHasNoItems') :
translate('QueueIsEmpty')
}
{translate('QueueIsEmpty')}
</Alert> :
null
}
@ -309,16 +289,9 @@ class Queue extends Component {
}
</PageContentBody>
<RemoveQueueItemModal
<RemoveQueueItemsModal
isOpen={isConfirmRemoveModalOpen}
selectedCount={selectedCount}
canChangeCategory={isConfirmRemoveModalOpen && (
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
return !!(item && item.downloadClientHasPostImportCategory);
})
)}
canIgnore={isConfirmRemoveModalOpen && (
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
@ -326,17 +299,6 @@ class Queue extends Component {
return !!(item && item.artistId && item.albumId);
})
)}
pending={isConfirmRemoveModalOpen && (
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
if (!item) {
return false;
}
return item.status === 'delay' || item.status === 'downloadClientUnavailable';
})
)}
onRemovePress={this.onRemoveSelectedConfirmed}
onModalClose={this.onConfirmRemoveModalClose}
/>
@ -356,22 +318,13 @@ Queue.propTypes = {
isAlbumsPopulated: PropTypes.bool.isRequired,
albumsError: PropTypes.object,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
count: PropTypes.number.isRequired,
totalRecords: PropTypes.number,
isGrabbing: PropTypes.bool.isRequired,
isRemoving: PropTypes.bool.isRequired,
isRefreshMonitoredDownloadsExecuting: PropTypes.bool.isRequired,
onRefreshPress: PropTypes.func.isRequired,
onGrabSelectedPress: PropTypes.func.isRequired,
onRemoveSelectedPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired
};
Queue.defaultProps = {
count: 0
onRemoveSelectedPress: PropTypes.func.isRequired
};
export default Queue;

View file

@ -7,7 +7,6 @@ import withCurrentPage from 'Components/withCurrentPage';
import { clearAlbums, fetchAlbums } from 'Store/Actions/albumActions';
import { executeCommand } from 'Store/Actions/commandActions';
import * as queueActions from 'Store/Actions/queueActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
@ -20,18 +19,14 @@ function createMapStateToProps() {
(state) => state.albums,
(state) => state.queue.options,
(state) => state.queue.paged,
(state) => state.queue.status.item,
createCustomFiltersSelector('queue'),
createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS),
(artist, albums, options, queue, status, customFilters, isRefreshMonitoredDownloadsExecuting) => {
(artist, albums, options, queue, isRefreshMonitoredDownloadsExecuting) => {
return {
count: options.includeUnknownArtistItems ? status.totalCount : status.count,
isArtistFetching: artist.isFetching,
isArtistPopulated: artist.isPopulated,
isAlbumsFetching: albums.isFetching,
isAlbumsPopulated: albums.isPopulated,
albumsError: albums.error,
customFilters,
isRefreshMonitoredDownloadsExecuting,
...options,
...queue
@ -130,10 +125,6 @@ class QueueConnector extends Component {
this.props.setQueueSort({ sortKey });
};
onFilterSelect = (selectedFilterKey) => {
this.props.setQueueFilter({ selectedFilterKey });
};
onTableOptionChange = (payload) => {
this.props.setQueueTableOption(payload);
@ -168,7 +159,6 @@ class QueueConnector extends Component {
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
onRefreshPress={this.onRefreshPress}
onGrabSelectedPress={this.onGrabSelectedPress}
@ -191,7 +181,6 @@ QueueConnector.propTypes = {
gotoQueueLastPage: PropTypes.func.isRequired,
gotoQueuePage: PropTypes.func.isRequired,
setQueueSort: PropTypes.func.isRequired,
setQueueFilter: PropTypes.func.isRequired,
setQueueTableOption: PropTypes.func.isRequired,
clearQueue: PropTypes.func.isRequired,
grabQueueItems: PropTypes.func.isRequired,

View file

@ -1,54 +0,0 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import { setQueueFilter } from 'Store/Actions/queueActions';
function createQueueSelector() {
return createSelector(
(state: AppState) => state.queue.paged.items,
(queueItems) => {
return queueItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.queue.paged.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
interface QueueFilterModalProps {
isOpen: boolean;
}
export default function QueueFilterModal(props: QueueFilterModalProps) {
const sectionItems = useSelector(createQueueSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'queue';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
dispatch(setQueueFilter(payload));
},
[dispatch]
);
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>
);
}

View file

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

View file

@ -98,10 +98,8 @@ class QueueRow extends Component {
indexer,
outputPath,
downloadClient,
downloadClientHasPostImportCategory,
downloadForced,
estimatedCompletionTime,
added,
timeleft,
size,
sizeleft,
@ -330,15 +328,6 @@ class QueueRow extends Component {
);
}
if (name === 'added') {
return (
<RelativeDateCellConnector
key={name}
date={added}
/>
);
}
if (name === 'actions') {
return (
<TableRowCell
@ -404,9 +393,7 @@ class QueueRow extends Component {
<RemoveQueueItemModal
isOpen={isRemoveQueueItemModalOpen}
sourceTitle={title}
canChangeCategory={!!downloadClientHasPostImportCategory}
canIgnore={!!artist}
isPending={isPending}
onRemovePress={this.onRemoveQueueItemModalConfirmed}
onModalClose={this.onRemoveQueueItemModalClose}
/>
@ -434,10 +421,8 @@ QueueRow.propTypes = {
indexer: PropTypes.string,
outputPath: PropTypes.string,
downloadClient: PropTypes.string,
downloadClientHasPostImportCategory: PropTypes.bool,
downloadForced: PropTypes.bool.isRequired,
estimatedCompletionTime: PropTypes.string,
added: PropTypes.string,
timeleft: PropTypes.string,
size: PropTypes.number,
sizeleft: PropTypes.number,

View file

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

View file

@ -0,0 +1,171 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
class RemoveQueueItemModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
removeFromClient: true,
blocklist: false,
skipRedownload: false
};
}
//
// Control
resetState = function() {
this.setState({
removeFromClient: true,
blocklist: false,
skipRedownload: false
});
};
//
// Listeners
onRemoveFromClientChange = ({ value }) => {
this.setState({ removeFromClient: value });
};
onBlocklistChange = ({ value }) => {
this.setState({ blocklist: value });
};
onSkipRedownloadChange = ({ value }) => {
this.setState({ skipRedownload: value });
};
onRemoveConfirmed = () => {
const state = this.state;
this.resetState();
this.props.onRemovePress(state);
};
onModalClose = () => {
this.resetState();
this.props.onModalClose();
};
//
// Render
render() {
const {
isOpen,
sourceTitle,
canIgnore
} = this.props;
const { removeFromClient, blocklist, skipRedownload } = this.state;
return (
<Modal
isOpen={isOpen}
size={sizes.MEDIUM}
onModalClose={this.onModalClose}
>
<ModalContent
onModalClose={this.onModalClose}
>
<ModalHeader>
Remove - {sourceTitle}
</ModalHeader>
<ModalBody>
<div>
Are you sure you want to remove '{sourceTitle}' from the queue?
</div>
<FormGroup>
<FormLabel>
{translate('RemoveFromDownloadClient')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="removeFromClient"
value={removeFromClient}
helpTextWarning={translate('RemoveFromDownloadClientHelpTextWarning')}
isDisabled={!canIgnore}
onChange={this.onRemoveFromClientChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('BlocklistRelease')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="blocklist"
value={blocklist}
helpText={translate('BlocklistReleaseHelpText')}
onChange={this.onBlocklistChange}
/>
</FormGroup>
{
blocklist &&
<FormGroup>
<FormLabel>
{translate('SkipRedownload')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="skipRedownload"
value={skipRedownload}
helpText={translate('SkipRedownloadHelpText')}
onChange={this.onSkipRedownloadChange}
/>
</FormGroup>
}
</ModalBody>
<ModalFooter>
<Button onPress={this.onModalClose}>
Close
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onRemoveConfirmed}
>
Remove
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
RemoveQueueItemModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
sourceTitle: PropTypes.string.isRequired,
canIgnore: PropTypes.bool.isRequired,
onRemovePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default RemoveQueueItemModal;

View file

@ -1,231 +0,0 @@
import React, { useCallback, useMemo, useState } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './RemoveQueueItemModal.css';
interface RemovePressProps {
removeFromClient: boolean;
changeCategory: boolean;
blocklist: boolean;
skipRedownload: boolean;
}
interface RemoveQueueItemModalProps {
isOpen: boolean;
sourceTitle: string;
canChangeCategory: boolean;
canIgnore: boolean;
isPending: boolean;
selectedCount?: number;
onRemovePress(props: RemovePressProps): void;
onModalClose: () => void;
}
type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore';
type BlocklistMethod =
| 'doNotBlocklist'
| 'blocklistAndSearch'
| 'blocklistOnly';
function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
const {
isOpen,
sourceTitle,
canIgnore,
canChangeCategory,
isPending,
selectedCount,
onRemovePress,
onModalClose,
} = props;
const multipleSelected = selectedCount && selectedCount > 1;
const [removalMethod, setRemovalMethod] =
useState<RemovalMethod>('removeFromClient');
const [blocklistMethod, setBlocklistMethod] =
useState<BlocklistMethod>('doNotBlocklist');
const { title, message } = useMemo(() => {
if (!selectedCount) {
return {
title: translate('RemoveQueueItem', { sourceTitle }),
message: translate('RemoveQueueItemConfirmation', { sourceTitle }),
};
}
if (selectedCount === 1) {
return {
title: translate('RemoveSelectedItem'),
message: translate('RemoveSelectedItemQueueMessageText'),
};
}
return {
title: translate('RemoveSelectedItems'),
message: translate('RemoveSelectedItemsQueueMessageText', {
selectedCount,
}),
};
}, [sourceTitle, selectedCount]);
const removalMethodOptions = useMemo(() => {
return [
{
key: 'removeFromClient',
value: translate('RemoveFromDownloadClient'),
hint: multipleSelected
? translate('RemoveMultipleFromDownloadClientHint')
: translate('RemoveFromDownloadClientHint'),
},
{
key: 'changeCategory',
value: translate('ChangeCategory'),
isDisabled: !canChangeCategory,
hint: multipleSelected
? translate('ChangeCategoryMultipleHint')
: translate('ChangeCategoryHint'),
},
{
key: 'ignore',
value: multipleSelected
? translate('IgnoreDownloads')
: translate('IgnoreDownload'),
isDisabled: !canIgnore,
hint: multipleSelected
? translate('IgnoreDownloadsHint')
: translate('IgnoreDownloadHint'),
},
];
}, [canChangeCategory, canIgnore, multipleSelected]);
const blocklistMethodOptions = useMemo(() => {
return [
{
key: 'doNotBlocklist',
value: translate('DoNotBlocklist'),
hint: translate('DoNotBlocklistHint'),
},
{
key: 'blocklistAndSearch',
value: translate('BlocklistAndSearch'),
isDisabled: isPending,
hint: multipleSelected
? translate('BlocklistAndSearchMultipleHint')
: translate('BlocklistAndSearchHint'),
},
{
key: 'blocklistOnly',
value: translate('BlocklistOnly'),
hint: multipleSelected
? translate('BlocklistMultipleOnlyHint')
: translate('BlocklistOnlyHint'),
},
];
}, [isPending, multipleSelected]);
const handleRemovalMethodChange = useCallback(
({ value }: { value: RemovalMethod }) => {
setRemovalMethod(value);
},
[setRemovalMethod]
);
const handleBlocklistMethodChange = useCallback(
({ value }: { value: BlocklistMethod }) => {
setBlocklistMethod(value);
},
[setBlocklistMethod]
);
const handleConfirmRemove = useCallback(() => {
onRemovePress({
removeFromClient: removalMethod === 'removeFromClient',
changeCategory: removalMethod === 'changeCategory',
blocklist: blocklistMethod !== 'doNotBlocklist',
skipRedownload: blocklistMethod === 'blocklistOnly',
});
setRemovalMethod('removeFromClient');
setBlocklistMethod('doNotBlocklist');
}, [
removalMethod,
blocklistMethod,
setRemovalMethod,
setBlocklistMethod,
onRemovePress,
]);
const handleModalClose = useCallback(() => {
setRemovalMethod('removeFromClient');
setBlocklistMethod('doNotBlocklist');
onModalClose();
}, [setRemovalMethod, setBlocklistMethod, onModalClose]);
return (
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={handleModalClose}>
<ModalContent onModalClose={handleModalClose}>
<ModalHeader>{title}</ModalHeader>
<ModalBody>
<div className={styles.message}>{message}</div>
{isPending ? null : (
<FormGroup>
<FormLabel>{translate('RemoveQueueItemRemovalMethod')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="removalMethod"
value={removalMethod}
values={removalMethodOptions}
isDisabled={!canChangeCategory && !canIgnore}
helpTextWarning={translate(
'RemoveQueueItemRemovalMethodHelpTextWarning'
)}
onChange={handleRemovalMethodChange}
/>
</FormGroup>
)}
<FormGroup>
<FormLabel>
{multipleSelected
? translate('BlocklistReleases')
: translate('BlocklistRelease')}
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="blocklistMethod"
value={blocklistMethod}
values={blocklistMethodOptions}
helpText={translate('BlocklistReleaseHelpText')}
onChange={handleBlocklistMethodChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter>
<Button onPress={handleModalClose}>{translate('Close')}</Button>
<Button kind={kinds.DANGER} onPress={handleConfirmRemove}>
{translate('Remove')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
export default RemoveQueueItemModal;

View file

@ -0,0 +1,172 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './RemoveQueueItemsModal.css';
class RemoveQueueItemsModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
removeFromClient: true,
blocklist: false,
skipRedownload: false
};
}
//
// Control
resetState = function() {
this.setState({
removeFromClient: true,
blocklist: false,
skipRedownload: false
});
};
//
// Listeners
onRemoveFromClientChange = ({ value }) => {
this.setState({ removeFromClient: value });
};
onBlocklistChange = ({ value }) => {
this.setState({ blocklist: value });
};
onSkipRedownloadChange = ({ value }) => {
this.setState({ skipRedownload: value });
};
onRemoveConfirmed = () => {
const state = this.state;
this.resetState();
this.props.onRemovePress(state);
};
onModalClose = () => {
this.resetState();
this.props.onModalClose();
};
//
// Render
render() {
const {
isOpen,
selectedCount,
canIgnore
} = this.props;
const { removeFromClient, blocklist, skipRedownload } = this.state;
return (
<Modal
isOpen={isOpen}
size={sizes.MEDIUM}
onModalClose={this.onModalClose}
>
<ModalContent
onModalClose={this.onModalClose}
>
<ModalHeader>
{selectedCount > 1 ? translate('RemoveSelectedItems') : translate('RemoveSelectedItem')}
</ModalHeader>
<ModalBody>
<div className={styles.message}>
{selectedCount > 1 ? translate('RemoveSelectedItemsQueueMessageText', [selectedCount]) : translate('RemoveSelectedItemQueueMessageText')}
</div>
<FormGroup>
<FormLabel>
{translate('RemoveFromDownloadClient')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="removeFromClient"
value={removeFromClient}
helpTextWarning={translate('RemoveFromDownloadClientHelpTextWarning')}
isDisabled={!canIgnore}
onChange={this.onRemoveFromClientChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{selectedCount > 1 ? translate('BlocklistReleases') : translate('BlocklistRelease')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="blocklist"
value={blocklist}
helpText={translate('BlocklistReleaseHelpText')}
onChange={this.onBlocklistChange}
/>
</FormGroup>
{
blocklist &&
<FormGroup>
<FormLabel>
{translate('SkipRedownload')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="skipRedownload"
value={skipRedownload}
helpText={translate('SkipRedownloadHelpText')}
onChange={this.onSkipRedownloadChange}
/>
</FormGroup>
}
</ModalBody>
<ModalFooter>
<Button onPress={this.onModalClose}>
{translate('Close')}
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onRemoveConfirmed}
>
{translate('Remove')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
RemoveQueueItemsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
selectedCount: PropTypes.number.isRequired,
canIgnore: PropTypes.bool.isRequired,
onRemovePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default RemoveQueueItemsModal;

View file

@ -1,9 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Tooltip from 'Components/Tooltip/Tooltip';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import formatTime from 'Utilities/Date/formatTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
@ -28,13 +25,11 @@ function TimeleftCell(props) {
const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
return (
<TableRowCell className={styles.timeleft}>
<Tooltip
anchor={<Icon name={icons.INFO} />}
tooltip={translate('DelayingDownloadUntil', { date, time })}
kind={kinds.INVERSE}
position={tooltipPositions.TOP}
/>
<TableRowCell
className={styles.timeleft}
title={translate('DelayingDownloadUntilInterp', [date, time])}
>
-
</TableRowCell>
);
}
@ -44,13 +39,11 @@ function TimeleftCell(props) {
const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
return (
<TableRowCell className={styles.timeleft}>
<Tooltip
anchor={<Icon name={icons.INFO} />}
tooltip={translate('RetryingDownloadOn', { date, time })}
kind={kinds.INVERSE}
position={tooltipPositions.TOP}
/>
<TableRowCell
className={styles.timeleft}
title={translate('RetryingDownloadInterp', [date, time])}
>
-
</TableRowCell>
);
}

View file

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

View file

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

View file

@ -2,17 +2,14 @@ import React from 'react';
import Alert from 'Components/Alert';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ArtistMonitoringOptionsPopoverContent.css';
function ArtistMonitoringOptionsPopoverContent() {
return (
<>
<Alert kind={kinds.INFO} className={styles.message}>
<Alert>
This is a one time adjustment to set which albums are monitored
</Alert>
<DescriptionList>
<DescriptionListItem
title={translate('AllAlbums')}

View file

@ -1,27 +0,0 @@
import ModelBase from 'App/ModelBase';
import Artist from 'Artist/Artist';
export interface Statistics {
trackCount: number;
trackFileCount: number;
percentOfTracks: number;
sizeOnDisk: number;
totalTrackCount: number;
}
interface Album extends ModelBase {
artistId: number;
artist: Artist;
foreignAlbumId: string;
title: string;
overview: string;
disambiguation?: string;
albumType: string;
monitored: boolean;
releaseDate: string;
statistics: Statistics;
lastSearchTime?: string;
isSaving?: boolean;
}
export default Album;

View file

@ -4,7 +4,6 @@ import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import AlbumInteractiveSearchModalConnector from './Search/AlbumInteractiveSearchModalConnector';
import styles from './AlbumSearchCell.css';
@ -50,13 +49,11 @@ class AlbumSearchCell extends Component {
name={icons.SEARCH}
isSpinning={isSearching}
onPress={onSearchPress}
title={translate('AutomaticSearch')}
/>
<IconButton
name={icons.INTERACTIVE}
onPress={this.onManualSearchPress}
title={translate('InteractiveSearch')}
/>
<AlbumInteractiveSearchModalConnector

View file

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

View file

@ -53,13 +53,13 @@ class DeleteAlbumModalContent extends Component {
render() {
const {
title,
statistics = {},
statistics,
onModalClose
} = this.props;
const {
trackFileCount = 0,
sizeOnDisk = 0
trackFileCount,
sizeOnDisk
} = statistics;
const deleteFiles = this.state.deleteFiles;
@ -133,14 +133,14 @@ class DeleteAlbumModalContent extends Component {
<ModalFooter>
<Button onPress={onModalClose}>
{translate('Close')}
Close
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onDeleteAlbumConfirmed}
>
{translate('Delete')}
Delete
</Button>
</ModalFooter>
</ModalContent>

View file

@ -119,10 +119,7 @@
margin: 5px 10px 5px 0;
}
.releaseDate,
.sizeOnDisk,
.albumType,
.secondaryTypes,
.qualityProfileName,
.links,
.tags {
@ -150,12 +147,6 @@
.headerContent {
padding: 15px;
}
.title {
font-weight: 300;
font-size: 30px;
line-height: 30px;
}
}
@media only screen and (max-width: $breakpointLarge) {

View file

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

View file

@ -9,7 +9,6 @@ import EditAlbumModalConnector from 'Album/Edit/EditAlbumModalConnector';
import AlbumInteractiveSearchModalConnector from 'Album/Search/AlbumInteractiveSearchModalConnector';
import ArtistGenres from 'Artist/Details/ArtistGenres';
import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal';
import Alert from 'Components/Alert';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
@ -40,7 +39,11 @@ const intermediateFontSize = parseInt(fonts.intermediateFontSize);
const lineHeight = parseFloat(fonts.lineHeight);
function getFanartUrl(images) {
return _.find(images, { coverType: 'fanart' })?.url;
const fanartImage = _.find(images, { coverType: 'fanart' });
if (fanartImage) {
// Remove protocol
return fanartImage.url.replace(/^https?:/, '');
}
}
function formatDuration(timeSpan) {
@ -192,7 +195,6 @@ class AlbumDetails extends Component {
duration,
overview,
albumType,
secondaryTypes,
statistics = {},
monitored,
releaseDate,
@ -205,7 +207,6 @@ class AlbumDetails extends Component {
isFetching,
isPopulated,
albumsError,
tracksError,
trackFilesError,
hasTrackFiles,
shortDateFormat,
@ -218,8 +219,8 @@ class AlbumDetails extends Component {
} = this.props;
const {
trackFileCount = 0,
sizeOnDisk = 0
trackFileCount,
sizeOnDisk
} = statistics;
const {
@ -398,11 +399,10 @@ class AlbumDetails extends Component {
<div className={styles.details}>
<div>
{
duration ?
!!duration &&
<span className={styles.duration}>
{formatDuration(duration)}
</span> :
null
</span>
}
<HeartRating
@ -418,18 +418,18 @@ class AlbumDetails extends Component {
<Label
className={styles.detailsLabel}
title={translate('ReleaseDate')}
size={sizes.LARGE}
>
<div>
<Icon
name={icons.CALENDAR}
size={17}
/>
<span className={styles.releaseDate}>
{moment(releaseDate).format(shortDateFormat)}
</span>
</div>
<Icon
name={icons.CALENDAR}
size={17}
/>
<span className={styles.sizeOnDisk}>
{
moment(releaseDate).format(shortDateFormat)
}
</span>
</Label>
<Tooltip
@ -438,15 +438,16 @@ class AlbumDetails extends Component {
className={styles.detailsLabel}
size={sizes.LARGE}
>
<div>
<Icon
name={icons.DRIVE}
size={17}
/>
<span className={styles.sizeOnDisk}>
{formatBytes(sizeOnDisk)}
</span>
</div>
<Icon
name={icons.DRIVE}
size={17}
/>
<span className={styles.sizeOnDisk}>
{
formatBytes(sizeOnDisk || 0)
}
</span>
</Label>
}
tooltip={
@ -462,55 +463,32 @@ class AlbumDetails extends Component {
className={styles.detailsLabel}
size={sizes.LARGE}
>
<div>
<Icon
name={monitored ? icons.MONITORED : icons.UNMONITORED}
size={17}
/>
<span className={styles.qualityProfileName}>
{monitored ? translate('Monitored') : translate('Unmonitored')}
</span>
</div>
<Icon
name={monitored ? icons.MONITORED : icons.UNMONITORED}
size={17}
/>
<span className={styles.qualityProfileName}>
{monitored ? 'Monitored' : 'Unmonitored'}
</span>
</Label>
{
albumType ?
!!albumType &&
<Label
className={styles.detailsLabel}
title={translate('Type')}
size={sizes.LARGE}
>
<div>
<Icon
name={icons.INFO}
size={17}
/>
<span className={styles.albumType}>
{albumType}
</span>
</div>
</Label> :
null
}
<Icon
name={icons.INFO}
size={17}
/>
{
secondaryTypes.length ?
<Label
className={styles.detailsLabel}
title={translate('SecondaryTypes')}
size={sizes.LARGE}
>
<div>
<Icon
name={icons.INFO}
size={17}
/>
<span className={styles.secondaryTypes}>
{secondaryTypes.join(', ')}
</span>
</div>
</Label> :
null
<span className={styles.qualityProfileName}>
{albumType}
</span>
</Label>
}
<Tooltip
@ -519,15 +497,14 @@ class AlbumDetails extends Component {
className={styles.detailsLabel}
size={sizes.LARGE}
>
<div>
<Icon
name={icons.EXTERNAL_LINK}
size={17}
/>
<span className={styles.links}>
{translate('Links')}
</span>
</div>
<Icon
name={icons.EXTERNAL_LINK}
size={17}
/>
<span className={styles.links}>
Links
</span>
</Label>
}
tooltip={
@ -553,38 +530,28 @@ class AlbumDetails extends Component {
<div className={styles.contentContainer}>
{
!isPopulated && !albumsError && !tracksError && !trackFilesError ?
<LoadingIndicator /> :
null
!isPopulated && !albumsError && !trackFilesError &&
<LoadingIndicator />
}
{
!isFetching && albumsError ?
<Alert kind={kinds.DANGER}>
{translate('AlbumsLoadError')}
</Alert> :
null
!isFetching && albumsError &&
<div>
{translate('LoadingAlbumsFailed')}
</div>
}
{
!isFetching && tracksError ?
<Alert kind={kinds.DANGER}>
{translate('TracksLoadError')}
</Alert> :
null
}
{
!isFetching && trackFilesError ?
<Alert kind={kinds.DANGER}>
{translate('TrackFilesLoadError')}
</Alert> :
null
!isFetching && trackFilesError &&
<div>
{translate('LoadingTrackFilesFailed')}
</div>
}
{
isPopulated && !!media.length &&
<div>
{
media.slice(0).map((medium) => {
return (
@ -602,14 +569,6 @@ class AlbumDetails extends Component {
</div>
}
{
isPopulated && !media.length ?
<Alert kind={kinds.WARNING}>
{translate('NoMediumInformation')}
</Alert> :
null
}
</div>
<OrganizePreviewModalConnector
@ -676,7 +635,6 @@ AlbumDetails.propTypes = {
duration: PropTypes.number,
overview: PropTypes.string,
albumType: PropTypes.string.isRequired,
secondaryTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
statistics: PropTypes.object.isRequired,
releaseDate: PropTypes.string.isRequired,
ratings: PropTypes.object.isRequired,
@ -703,8 +661,6 @@ AlbumDetails.propTypes = {
};
AlbumDetails.defaultProps = {
secondaryTypes: [],
statistics: {},
isSaving: false
};

View file

@ -70,12 +70,6 @@ function createMapStateToProps() {
isCommandExecuting(isSearchingCommand) &&
isSearchingCommand.body.albumIds.indexOf(album.id) > -1
);
const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, artistId: artist.id }));
const isRenamingArtistCommand = findCommand(commands, { name: commandNames.RENAME_ARTIST });
const isRenamingArtist = (
isCommandExecuting(isRenamingArtistCommand) &&
isRenamingArtistCommand.body.artistIds.indexOf(artist.id) > -1
);
const isFetching = tracks.isFetching || isTrackFilesFetching;
const isPopulated = tracks.isPopulated && isTrackFilesPopulated;
@ -86,8 +80,6 @@ function createMapStateToProps() {
shortDateFormat: uiSettings.shortDateFormat,
artist,
isSearching,
isRenamingFiles,
isRenamingArtist,
isFetching,
isPopulated,
tracksError,
@ -121,27 +113,8 @@ class AlbumDetailsConnector extends Component {
}
componentDidUpdate(prevProps) {
const {
id,
anyReleaseOk,
isRenamingFiles,
isRenamingArtist
} = this.props;
if (
(prevProps.isRenamingFiles && !isRenamingFiles) ||
(prevProps.isRenamingArtist && !isRenamingArtist) ||
!_.isEqual(getMonitoredReleases(prevProps), getMonitoredReleases(this.props)) ||
(prevProps.anyReleaseOk === false && anyReleaseOk === true)
) {
this.unpopulate();
this.populate();
}
// If the id has changed we need to clear the album
// files and fetch from the server.
if (prevProps.id !== id) {
if (!_.isEqual(getMonitoredReleases(prevProps), getMonitoredReleases(this.props)) ||
(prevProps.anyReleaseOk === false && this.props.anyReleaseOk === true)) {
this.unpopulate();
this.populate();
}
@ -201,8 +174,6 @@ class AlbumDetailsConnector extends Component {
AlbumDetailsConnector.propTypes = {
id: PropTypes.number,
anyReleaseOk: PropTypes.bool,
isRenamingFiles: PropTypes.bool.isRequired,
isRenamingArtist: PropTypes.bool.isRequired,
isAlbumFetching: PropTypes.bool,
isAlbumPopulated: PropTypes.bool,
foreignAlbumId: PropTypes.string.isRequired,

View file

@ -12,13 +12,16 @@ import TrackRowConnector from './TrackRowConnector';
import styles from './AlbumDetailsMedium.css';
function getMediumStatistics(tracks) {
const trackCount = tracks.length;
let trackCount = 0;
let trackFileCount = 0;
let totalTrackCount = 0;
tracks.forEach((track) => {
if (track.trackFileId) {
trackCount++;
trackFileCount++;
} else {
trackCount++;
}
totalTrackCount++;
@ -172,7 +175,7 @@ class AlbumDetailsMedium extends Component {
</Table> :
<div className={styles.noTracks}>
{translate('NoTracksInThisMedium')}
No tracks in this medium
</div>
}
<div className={styles.collapseButtonContainer}>

View file

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

View file

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

View file

@ -2,19 +2,14 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import AlbumFormats from 'Album/AlbumFormats';
import EpisodeStatusConnector from 'Album/EpisodeStatusConnector';
import IndexerFlags from 'Album/IndexerFlags';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import { tooltipPositions } from 'Helpers/Props';
import MediaInfoConnector from 'TrackFile/MediaInfoConnector';
import * as mediaInfoTypes from 'TrackFile/mediaInfoTypes';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import TrackActionsCell from './TrackActionsCell';
import styles from './TrackRow.css';
@ -33,10 +28,8 @@ class TrackRow extends Component {
title,
duration,
trackFilePath,
trackFileSize,
customFormats,
customFormatScore,
indexerFlags,
columns,
deleteTrackFile
} = this.props;
@ -146,41 +139,12 @@ class TrackRow extends Component {
customFormats.length
)}
tooltip={<AlbumFormats formats={customFormats} />}
position={tooltipPositions.LEFT}
position={tooltipPositions.BOTTOM}
/>
</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') {
return (
<TableRowCell
key={name}
className={styles.size}
>
{!!trackFileSize && formatBytes(trackFileSize)}
</TableRowCell>
);
}
if (name === 'status') {
return (
<TableRowCell
@ -228,17 +192,14 @@ TrackRow.propTypes = {
duration: PropTypes.number.isRequired,
isSaving: PropTypes.bool,
trackFilePath: PropTypes.string,
trackFileSize: PropTypes.number,
customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
indexerFlags: PropTypes.number.isRequired,
mediaInfo: PropTypes.object,
columns: PropTypes.arrayOf(PropTypes.object).isRequired
};
TrackRow.defaultProps = {
customFormats: [],
indexerFlags: 0
customFormats: []
};
export default TrackRow;

View file

@ -11,10 +11,8 @@ function createMapStateToProps() {
(id, trackFile) => {
return {
trackFilePath: trackFile ? trackFile.path : null,
trackFileSize: trackFile ? trackFile.size : null,
customFormats: trackFile ? trackFile.customFormats : [],
customFormatScore: trackFile ? trackFile.customFormatScore : 0,
indexerFlags: trackFile ? trackFile.indexerFlags : 0
customFormatScore: trackFile ? trackFile.customFormatScore : 0
};
}
);

View file

@ -35,7 +35,7 @@ class EditAlbumModalContent extends Component {
title,
artistName,
albumType,
statistics = {},
statistics,
item,
isSaving,
onInputChange,
@ -43,10 +43,6 @@ class EditAlbumModalContent extends Component {
...otherProps
} = this.props;
const {
trackFileCount = 0
} = statistics;
const {
monitored,
anyReleaseOk,
@ -100,7 +96,7 @@ class EditAlbumModalContent extends Component {
type={inputTypes.ALBUM_RELEASE_SELECT}
name="releases"
helpText={translate('ReleasesHelpText')}
isDisabled={anyReleaseOk.value && trackFileCount > 0}
isDisabled={anyReleaseOk.value && statistics.trackFileCount > 0}
albumReleases={releases}
onChange={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 (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_EXTRA_LARGE}
size={sizes.EXTRA_LARGE}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>

View file

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

View file

@ -3,7 +3,6 @@ import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
function getTooltip(title, quality, size) {
if (!title) {
@ -27,44 +26,13 @@ function getTooltip(title, quality, size) {
return title;
}
function revisionLabel(className, quality, showRevision) {
if (!showRevision) {
return;
}
if (quality.revision.isRepack) {
return (
<Label
className={className}
kind={kinds.PRIMARY}
title={translate('Repack')}
>
R
</Label>
);
}
if (quality.revision.version && quality.revision.version > 1) {
return (
<Label
className={className}
kind={kinds.PRIMARY}
title={translate('Proper')}
>
P
</Label>
);
}
}
function TrackQuality(props) {
const {
className,
title,
quality,
size,
isCutoffNotMet,
showRevision
isCutoffNotMet
} = props;
if (!quality) {
@ -72,15 +40,13 @@ function TrackQuality(props) {
}
return (
<span>
<Label
className={className}
kind={isCutoffNotMet ? kinds.INVERSE : kinds.DEFAULT}
title={getTooltip(title, quality, size)}
>
{quality.quality.name}
</Label>{revisionLabel(className, quality, showRevision)}
</span>
<Label
className={className}
kind={isCutoffNotMet ? kinds.INVERSE : kinds.DEFAULT}
title={getTooltip(title, quality, size)}
>
{quality.quality.name}
</Label>
);
}
@ -89,13 +55,11 @@ TrackQuality.propTypes = {
title: PropTypes.string,
quality: PropTypes.object.isRequired,
size: PropTypes.number,
isCutoffNotMet: PropTypes.bool,
showRevision: PropTypes.bool
isCutoffNotMet: PropTypes.bool
};
TrackQuality.defaultProps = {
title: '',
showRevision: false
title: ''
};
export default TrackQuality;

View file

@ -0,0 +1,36 @@
.pageContentBodyWrapper {
display: flex;
flex: 1 0 1px;
overflow: hidden;
}
.contentBody {
composes: contentBody from '~Components/Page/PageContentBody.css';
display: flex;
flex-direction: column;
}
.tableInnerContentBody {
composes: innerContentBody from '~Components/Page/PageContentBody.css';
display: flex;
flex-direction: column;
flex-grow: 1;
}
.contentBodyContainer {
display: flex;
flex-direction: column;
flex-grow: 1;
}
@media only screen and (max-width: $breakpointSmall) {
.pageContentBodyWrapper {
flex-basis: auto;
}
.contentBody {
flex-basis: 1px;
}
}

View file

@ -1,10 +1,10 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'labelIcon': string;
'message': string;
'modalFooter': string;
'selected': string;
'contentBody': string;
'contentBodyContainer': string;
'pageContentBodyWrapper': string;
'tableInnerContentBody': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -0,0 +1,443 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { CellMeasurer, CellMeasurerCache } from 'react-virtualized';
import NoArtist from 'Artist/NoArtist';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageJumpBar from 'Components/Page/PageJumpBar';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import VirtualTable from 'Components/Table/VirtualTable';
import VirtualTableRow from 'Components/Table/VirtualTableRow';
import { align, sortDirections } from 'Helpers/Props';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import AlbumStudioFilterModalConnector from './AlbumStudioFilterModalConnector';
import AlbumStudioFooter from './AlbumStudioFooter';
import AlbumStudioRowConnector from './AlbumStudioRowConnector';
import AlbumStudioTableHeader from './AlbumStudioTableHeader';
import styles from './AlbumStudio.css';
const columns = [
{
name: 'status',
isVisible: true
},
{
name: 'sortName',
label: () => translate('Name'),
isSortable: true,
isVisible: true
},
{
name: 'albumCount',
label: () => translate('Albums'),
isSortable: false,
isVisible: true
}
];
class AlbumStudio extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
estimatedRowSize: 100,
scroller: null,
jumpBarItems: { order: [] },
scrollIndex: null,
jumpCount: 0,
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {}
};
this.cache = new CellMeasurerCache({
defaultHeight: 100,
fixedWidth: true
});
}
componentDidMount() {
this.setSelectedState();
}
componentDidUpdate(prevProps) {
const {
isSaving,
saveError
} = this.props;
const {
scrollIndex,
jumpCount
} = this.state;
if (prevProps.isSaving && !isSaving && !saveError) {
this.onSelectAllChange({ value: false });
}
// nasty hack to fix react-virtualized jumping incorrectly
// due to variable row heights
if (scrollIndex != null && scrollIndex > 0) {
if (jumpCount === 0) {
this.setState({
scrollIndex: scrollIndex - 1,
jumpCount: 1
});
} else if (jumpCount === 1) {
this.setState({
scrollIndex: scrollIndex + 1,
jumpCount: 2
});
} else {
this.setState({
scrollIndex: null,
jumpCount: 0
});
}
}
}
//
// Control
setScrollerRef = (ref) => {
this.setState({ scroller: ref });
};
setJumpBarItems() {
const {
items,
sortKey,
sortDirection
} = this.props;
// Reset if not sorting by sortName
if (sortKey !== 'sortName') {
this.setState({ jumpBarItems: { order: [] } });
return;
}
const characters = _.reduce(items, (acc, item) => {
let char = item.sortName.charAt(0);
if (!isNaN(char)) {
char = '#';
}
if (char in acc) {
acc[char] = acc[char] + 1;
} else {
acc[char] = 1;
}
return acc;
}, {});
const order = Object.keys(characters).sort();
// Reverse if sorting descending
if (sortDirection === sortDirections.DESCENDING) {
order.reverse();
}
const jumpBarItems = {
characters,
order
};
this.setState({ jumpBarItems });
}
getSelectedIds = () => {
if (this.state.allUnselected) {
return [];
}
return getSelectedIds(this.state.selectedState);
};
setSelectedState = () => {
const {
items
} = this.props;
const {
selectedState
} = this.state;
const newSelectedState = {};
items.forEach((artist) => {
const isItemSelected = selectedState[artist.id];
if (isItemSelected) {
newSelectedState[artist.id] = isItemSelected;
} else {
newSelectedState[artist.id] = false;
}
});
const selectedCount = getSelectedIds(newSelectedState).length;
const newStateCount = Object.keys(newSelectedState).length;
let isAllSelected = false;
let isAllUnselected = false;
if (selectedCount === 0) {
isAllUnselected = true;
} else if (selectedCount === newStateCount) {
isAllSelected = true;
}
this.setState({ selectedState: newSelectedState, allSelected: isAllSelected, allUnselected: isAllUnselected });
};
estimateRowHeight = (width) => {
const {
albumCount,
items
} = this.props;
if (albumCount === undefined || albumCount === 0 || items.length === 0) {
return 100;
}
// guess 250px per album entry
// available width is total width less 186px for select, status etc
const cols = Math.max(Math.floor((width - 186) / 250), 1);
const albumsPerArtist = albumCount / items.length;
const albumRowsPerArtist = albumsPerArtist / cols;
// each row is 23px per album row plus 16px padding
return albumRowsPerArtist * 23 + 16;
};
rowRenderer = ({ key, rowIndex, parent, style }) => {
const {
items
} = this.props;
const {
selectedState
} = this.state;
const item = items[rowIndex];
return (
<CellMeasurer
key={key}
cache={this.cache}
parent={parent}
columnIndex={0}
rowIndex={rowIndex}
>
{({ registerChild }) => (
<VirtualTableRow
ref={registerChild}
style={style}
>
<AlbumStudioRowConnector
key={item.id}
artistId={item.id}
isSelected={selectedState[item.id]}
onSelectedChange={this.onSelectedChange}
/>
</VirtualTableRow>
)}
</CellMeasurer>
);
};
//
// Listeners
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
};
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
};
onSelectAllPress = () => {
this.onSelectAllChange({ value: !this.state.allSelected });
};
onUpdateSelectedPress = (changes) => {
this.props.onUpdateSelectedPress({
artistIds: this.getSelectedIds(),
...changes
});
};
onJumpBarItemPress = (jumpToCharacter) => {
const scrollIndex = getIndexOfFirstCharacter(this.props.items, jumpToCharacter);
if (scrollIndex != null) {
this.setState({ scrollIndex });
}
};
onGridRecompute = (width) => {
this.setJumpBarItems();
this.setSelectedState();
this.setState({ estimatedRowSize: this.estimateRowHeight(width) });
this.cache.clearAll();
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
totalItems,
items,
selectedFilterKey,
filters,
customFilters,
sortKey,
sortDirection,
isSaving,
saveError,
isSmallScreen,
onSortPress,
onFilterSelect
} = this.props;
const {
allSelected,
allUnselected,
estimatedRowSize,
scroller,
jumpBarItems,
scrollIndex
} = this.state;
return (
<PageContent title={translate('AlbumStudio')}>
<PageToolbar>
<PageToolbarSection />
<PageToolbarSection alignContent={align.RIGHT}>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={AlbumStudioFilterModalConnector}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<div className={styles.pageContentBodyWrapper}>
<PageContentBody
registerScroller={this.setScrollerRef}
className={styles.contentBody}
innerClassName={styles.innerContentBody}
>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>{getErrorMessage(error, 'Failed to load artist from API')}</div>
}
{
!error && isPopulated && !!items.length &&
<div className={styles.contentBodyContainer}>
<VirtualTable
items={items}
scrollIndex={scrollIndex}
columns={columns}
scroller={scroller}
isSmallScreen={isSmallScreen}
overscanRowCount={5}
rowRenderer={this.rowRenderer}
header={
<AlbumStudioTableHeader
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={this.onSelectAllChange}
/>
}
sortKey={sortKey}
sortDirection={sortDirection}
deferredMeasurementCache={this.cache}
rowHeight={this.cache.rowHeight}
estimatedRowSize={estimatedRowSize}
onRecompute={this.onGridRecompute}
/>
</div>
}
{
!error && isPopulated && !items.length &&
<NoArtist totalItems={totalItems} />
}
</PageContentBody>
{
isPopulated && !!jumpBarItems.order.length &&
<PageJumpBar
items={jumpBarItems}
onItemPress={this.onJumpBarItemPress}
/>
}
</div>
<AlbumStudioFooter
selectedCount={this.getSelectedIds().length}
isSaving={isSaving}
saveError={saveError}
onUpdateSelectedPress={this.onUpdateSelectedPress}
/>
</PageContent>
);
}
}
AlbumStudio.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
totalItems: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
albumCount: PropTypes.number.isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
isSmallScreen: PropTypes.bool.isRequired,
onSortPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onUpdateSelectedPress: PropTypes.func.isRequired
};
export default AlbumStudio;

View file

@ -0,0 +1,102 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import translate from 'Utilities/String/translate';
import styles from './AlbumStudioAlbum.css';
class AlbumStudioAlbum extends Component {
//
// Listeners
onAlbumMonitoredPress = () => {
const {
id,
monitored
} = this.props;
this.props.onAlbumMonitoredPress(id, !monitored);
};
//
// Render
render() {
const {
title,
disambiguation,
albumType,
monitored,
statistics,
isSaving
} = this.props;
const {
trackFileCount,
totalTrackCount,
percentOfTracks
} = statistics;
return (
<div className={styles.album}>
<div className={styles.info}>
<MonitorToggleButton
monitored={monitored}
isSaving={isSaving}
onPress={this.onAlbumMonitoredPress}
/>
<span>
{
disambiguation ? `${title} (${disambiguation})` : `${title}`
}
</span>
</div>
<div className={styles.albumType}>
<span>
{
`${albumType}`
}
</span>
</div>
<div
className={classNames(
styles.tracks,
percentOfTracks < 100 && monitored && styles.missingWanted,
percentOfTracks === 100 && styles.allTracks
)}
title={translate('TrackFileCounttotalTrackCountTracksDownloadedInterp', [trackFileCount, totalTrackCount])}
>
{
totalTrackCount === 0 ? '0/0' : `${trackFileCount}/${totalTrackCount}`
}
</div>
</div>
);
}
}
AlbumStudioAlbum.propTypes = {
id: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
disambiguation: PropTypes.string,
albumType: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
statistics: PropTypes.object.isRequired,
isSaving: PropTypes.bool.isRequired,
onAlbumMonitoredPress: PropTypes.func.isRequired
};
AlbumStudioAlbum.defaultProps = {
isSaving: false,
statistics: {
trackFileCount: 0,
totalTrackCount: 0,
percentOfTracks: 0
}
};
export default AlbumStudioAlbum;

View file

@ -0,0 +1,116 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearAlbums, fetchAlbums } from 'Store/Actions/albumActions';
import { saveAlbumStudio, setAlbumStudioFilter, setAlbumStudioSort } from 'Store/Actions/albumStudioActions';
import createArtistClientSideCollectionItemsSelector from 'Store/Selectors/createArtistClientSideCollectionItemsSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import AlbumStudio from './AlbumStudio';
function createAlbumFetchStateSelector() {
return createSelector(
(state) => state.albums.items.length,
(state) => state.albums.isFetching,
(state) => state.albums.isPopulated,
(length, isFetching, isPopulated) => {
const albumCount = (!isFetching && isPopulated) ? length : 0;
return {
albumCount,
isFetching,
isPopulated
};
}
);
}
function createMapStateToProps() {
return createSelector(
createAlbumFetchStateSelector(),
createArtistClientSideCollectionItemsSelector('albumStudio'),
createDimensionsSelector(),
(albums, artist, dimensionsState) => {
const isPopulated = albums.isPopulated && artist.isPopulated;
const isFetching = artist.isFetching || albums.isFetching;
return {
...artist,
isPopulated,
isFetching,
albumCount: albums.albumCount,
isSmallScreen: dimensionsState.isSmallScreen
};
}
);
}
const mapDispatchToProps = {
fetchAlbums,
clearAlbums,
setAlbumStudioSort,
setAlbumStudioFilter,
saveAlbumStudio
};
class AlbumStudioConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.populate();
}
componentWillUnmount() {
this.unpopulate();
}
//
// Control
populate = () => {
this.props.fetchAlbums();
};
unpopulate = () => {
this.props.clearAlbums();
};
//
// Listeners
onSortPress = (sortKey) => {
this.props.setAlbumStudioSort({ sortKey });
};
onFilterSelect = (selectedFilterKey) => {
this.props.setAlbumStudioFilter({ selectedFilterKey });
};
onUpdateSelectedPress = (payload) => {
this.props.saveAlbumStudio(payload);
};
//
// Render
render() {
return (
<AlbumStudio
{...this.props}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onUpdateSelectedPress={this.onUpdateSelectedPress}
/>
);
}
}
AlbumStudioConnector.propTypes = {
setAlbumStudioSort: PropTypes.func.isRequired,
setAlbumStudioFilter: PropTypes.func.isRequired,
fetchAlbums: PropTypes.func.isRequired,
clearAlbums: PropTypes.func.isRequired,
saveAlbumStudio: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AlbumStudioConnector);

View file

@ -0,0 +1,24 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import FilterModal from 'Components/Filter/FilterModal';
import { setAlbumStudioFilter } from 'Store/Actions/albumStudioActions';
function createMapStateToProps() {
return createSelector(
(state) => state.artist.items,
(state) => state.albumStudio.filterBuilderProps,
(sectionItems, filterBuilderProps) => {
return {
sectionItems,
filterBuilderProps,
customFilterType: 'albumStudio'
};
}
);
}
const mapDispatchToProps = {
dispatchSetFilter: setAlbumStudioFilter
};
export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);

View file

@ -0,0 +1,14 @@
.inputContainer {
margin-right: 20px;
}
.label {
margin-bottom: 3px;
font-weight: bold;
}
.updateSelectedButton {
composes: button from '~Components/Link/SpinnerButton.css';
height: 35px;
}

View file

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

View file

@ -0,0 +1,174 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormInputGroup from 'Components/Form/FormInputGroup';
import MonitorAlbumsSelectInput from 'Components/Form/MonitorAlbumsSelectInput';
import MonitorNewItemsSelectInput from 'Components/Form/MonitorNewItemsSelectInput';
import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter';
import { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './AlbumStudioFooter.css';
const NO_CHANGE = 'noChange';
class AlbumStudioFooter extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
monitored: NO_CHANGE,
monitor: NO_CHANGE,
monitorNewItems: NO_CHANGE
};
}
componentDidUpdate(prevProps) {
const {
isSaving,
saveError
} = this.props;
if (prevProps.isSaving && !isSaving && !saveError) {
this.setState({
monitored: NO_CHANGE,
monitor: NO_CHANGE,
monitorNewItems: NO_CHANGE
});
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.setState({ [name]: value });
};
onUpdateSelectedPress = () => {
const {
monitor,
monitored,
monitorNewItems
} = this.state;
const changes = {};
if (monitored !== NO_CHANGE) {
changes.monitored = monitored === 'monitored';
}
if (monitor !== NO_CHANGE) {
changes.monitor = monitor;
}
if (monitorNewItems !== NO_CHANGE) {
changes.monitorNewItems = monitorNewItems;
}
this.props.onUpdateSelectedPress(changes);
};
//
// Render
render() {
const {
selectedCount,
isSaving
} = this.props;
const {
monitored,
monitor,
monitorNewItems
} = this.state;
const monitoredOptions = [
{ key: NO_CHANGE, value: translate('NoChange'), disabled: true },
{ key: 'monitored', value: translate('Monitored') },
{ key: 'unmonitored', value: translate('Unmonitored') }
];
const noChanges = monitored === NO_CHANGE &&
monitor === NO_CHANGE &&
monitorNewItems === NO_CHANGE;
return (
<PageContentFooter>
<div className={styles.inputContainer}>
<div className={styles.label}>
{translate('MonitorArtist')}
</div>
<FormInputGroup
type={inputTypes.SELECT}
name="monitored"
value={monitored}
values={monitoredOptions}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<div className={styles.label}>
{translate('MonitorExistingAlbums')}
</div>
<MonitorAlbumsSelectInput
name="monitor"
value={monitor}
includeNoChange={true}
includeNoChangeDisabled={false}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<div className={styles.label}>
{translate('MonitorNewAlbums')}
</div>
<MonitorNewItemsSelectInput
name="monitorNewItems"
value={monitorNewItems}
includeNoChange={true}
includeNoChangeDisabled={false}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div>
<div className={styles.label}>
{translate('CountArtistsSelected', { selectedCount })}
</div>
<SpinnerButton
className={styles.updateSelectedButton}
kind={kinds.PRIMARY}
isSpinning={isSaving}
isDisabled={!selectedCount || noChanges}
onPress={this.onUpdateSelectedPress}
>
{translate('UpdateSelected')}
</SpinnerButton>
</div>
</PageContentFooter>
);
}
}
AlbumStudioFooter.propTypes = {
selectedCount: PropTypes.number.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
onUpdateSelectedPress: PropTypes.func.isRequired
};
export default AlbumStudioFooter;

View file

@ -0,0 +1,41 @@
.cell {
composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
display: flex;
align-items: center;
}
.selectCell {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
display: flex;
align-items: center;
}
.status {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
display: flex;
align-items: center;
padding: 0;
min-width: 60px;
}
.title {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
display: flex;
align-items: center;
flex-shrink: 0;
min-width: 110px;
}
.albums {
composes: cell;
display: flex;
flex-grow: 4;
flex-wrap: wrap;
min-width: 400px;
}

View file

@ -1,7 +1,10 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'item': string;
'albums': string;
'cell': string;
'selectCell': string;
'status': string;
'title': string;
}
export const cssExports: CssExports;

View file

@ -0,0 +1,95 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ArtistNameLink from 'Artist/ArtistNameLink';
import ArtistStatusCell from 'Artist/Index/Table/ArtistStatusCell';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
import AlbumStudioAlbum from './AlbumStudioAlbum';
import styles from './AlbumStudioRow.css';
class AlbumStudioRow extends Component {
//
// Render
render() {
const {
artistId,
status,
foreignArtistId,
artistName,
artistType,
monitored,
albums,
isSaving,
isSelected,
onSelectedChange,
onArtistMonitoredPress,
onAlbumMonitoredPress
} = this.props;
return (
<>
<VirtualTableSelectCell
className={styles.selectCell}
id={artistId}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
isDisabled={false}
/>
<ArtistStatusCell
className={styles.status}
artistType={artistType}
monitored={monitored}
status={status}
isSaving={isSaving}
onMonitoredPress={onArtistMonitoredPress}
component={VirtualTableRowCell}
/>
<VirtualTableRowCell className={styles.title}>
<ArtistNameLink
foreignArtistId={foreignArtistId}
artistName={artistName}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.albums}>
{
albums.map((album) => {
return (
<AlbumStudioAlbum
key={album.id}
{...album}
onAlbumMonitoredPress={onAlbumMonitoredPress}
/>
);
})
}
</VirtualTableRowCell>
</>
);
}
}
AlbumStudioRow.propTypes = {
artistId: PropTypes.number.isRequired,
status: PropTypes.string.isRequired,
foreignArtistId: PropTypes.string.isRequired,
artistName: PropTypes.string.isRequired,
artistType: PropTypes.string,
monitored: PropTypes.bool.isRequired,
albums: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool.isRequired,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired,
onArtistMonitoredPress: PropTypes.func.isRequired,
onAlbumMonitoredPress: PropTypes.func.isRequired
};
AlbumStudioRow.defaultProps = {
isSaving: false
};
export default AlbumStudioRow;

View file

@ -0,0 +1,94 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { toggleAlbumsMonitored } from 'Store/Actions/albumActions';
import { toggleArtistMonitored } from 'Store/Actions/artistActions';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import AlbumStudioRow from './AlbumStudioRow';
// Use a const to share the reselect cache between instances
const getAlbumMap = createSelector(
(state) => state.albums.items,
(albums) => {
return albums.reduce((acc, curr) => {
(acc[curr.artistId] = acc[curr.artistId] || []).push(curr);
return acc;
}, {});
}
);
function createMapStateToProps() {
return createSelector(
createArtistSelector(),
getAlbumMap,
(artist, albumMap) => {
const albumsInArtist = albumMap.hasOwnProperty(artist.id) ? albumMap[artist.id] : [];
const sortedAlbums = _.orderBy(albumsInArtist, 'releaseDate', 'desc');
return {
...artist,
artistId: artist.id,
artistName: artist.artistName,
monitored: artist.monitored,
status: artist.status,
isSaving: artist.isSaving,
albums: sortedAlbums
};
}
);
}
const mapDispatchToProps = {
toggleArtistMonitored,
toggleAlbumsMonitored
};
class AlbumStudioRowConnector extends Component {
//
// Listeners
onArtistMonitoredPress = () => {
const {
artistId,
monitored
} = this.props;
this.props.toggleArtistMonitored({
artistId,
monitored: !monitored
});
};
onAlbumMonitoredPress = (albumId, monitored) => {
const albumIds = [albumId];
this.props.toggleAlbumsMonitored({
albumIds,
monitored
});
};
//
// Render
render() {
return (
<AlbumStudioRow
{...this.props}
onArtistMonitoredPress={this.onArtistMonitoredPress}
onAlbumMonitoredPress={this.onAlbumMonitoredPress}
/>
);
}
}
AlbumStudioRowConnector.propTypes = {
artistId: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
toggleArtistMonitored: PropTypes.func.isRequired,
toggleAlbumsMonitored: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AlbumStudioRowConnector);

View file

@ -0,0 +1,18 @@
.status {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 60px;
padding: 0;
}
.sortName {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 110px;
}
.albumCount {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
padding: 12px;
}

View file

@ -2,6 +2,8 @@
// Please do not change this file!
interface CssExports {
'albumCount': string;
'sortName': string;
'status': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -0,0 +1,61 @@
import PropTypes from 'prop-types';
import React from 'react';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
import styles from './AlbumStudioTableHeader.css';
function AlbumStudioTableHeader(props) {
const {
columns,
allSelected,
allUnselected,
onSelectAllChange,
...otherProps
} = props;
return (
<VirtualTableHeader>
<VirtualTableSelectAllHeaderCell
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
/>
{
columns.map((column) => {
const {
name,
label,
isSortable,
isVisible
} = column;
if (!isVisible) {
return null;
}
return (
<VirtualTableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={isSortable}
{...otherProps}
>
{typeof label === 'function' ? label() : label}
</VirtualTableHeaderCell>
);
})
}
</VirtualTableHeader>
);
}
AlbumStudioTableHeader.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
allSelected: PropTypes.bool.isRequired,
allUnselected: PropTypes.bool.isRequired,
onSelectAllChange: PropTypes.func.isRequired
};
export default AlbumStudioTableHeader;

View file

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

View file

@ -5,13 +5,15 @@ import BlocklistConnector from 'Activity/Blocklist/BlocklistConnector';
import HistoryConnector from 'Activity/History/HistoryConnector';
import QueueConnector from 'Activity/Queue/QueueConnector';
import AlbumDetailsPageConnector from 'Album/Details/AlbumDetailsPageConnector';
import AlbumStudioConnector from 'AlbumStudio/AlbumStudioConnector';
import ArtistDetailsPageConnector from 'Artist/Details/ArtistDetailsPageConnector';
import ArtistIndex from 'Artist/Index/ArtistIndex';
import ArtistEditorConnector from 'Artist/Editor/ArtistEditorConnector';
import ArtistIndexConnector from 'Artist/Index/ArtistIndexConnector';
import CalendarPageConnector from 'Calendar/CalendarPageConnector';
import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import AddNewItemConnector from 'Search/AddNewItemConnector';
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
import CustomFormatSettingsConnector from 'Settings/CustomFormats/CustomFormatSettingsConnector';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
@ -29,7 +31,7 @@ import LogsTableConnector from 'System/Events/LogsTableConnector';
import Logs from 'System/Logs/Logs';
import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks';
import Updates from 'System/Updates/Updates';
import UpdatesConnector from 'System/Updates/UpdatesConnector';
import UnmappedFilesTableConnector from 'UnmappedFiles/UnmappedFilesTableConnector';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
@ -49,7 +51,7 @@ function AppRoutes(props) {
<Route
exact={true}
path="/"
component={ArtistIndex}
component={ArtistIndexConnector}
/>
{
@ -76,28 +78,12 @@ function AppRoutes(props) {
<Route
path="/artisteditor"
exact={true}
render={() => {
return (
<Redirect
to={getPathWithUrlBase('/')}
component={app}
/>
);
}}
component={ArtistEditorConnector}
/>
<Route
path="/albumstudio"
exact={true}
render={() => {
return (
<Redirect
to={getPathWithUrlBase('/')}
component={app}
/>
);
}}
component={AlbumStudioConnector}
/>
<Route
@ -184,7 +170,7 @@ function AppRoutes(props) {
<Route
path="/settings/customformats"
component={CustomFormatSettingsPage}
component={CustomFormatSettingsConnector}
/>
<Route
@ -248,7 +234,7 @@ function AppRoutes(props) {
<Route
path="/system/updates"
component={Updates}
component={UpdatesConnector}
/>
<Route

View file

@ -65,12 +65,12 @@ function AppUpdatedModalContent(props) {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('AppUpdated')}
{translate('AppUpdated', { appName: 'Lidarr' })}
</ModalHeader>
<ModalBody>
<div>
<InlineMarkdown data={translate('AppUpdatedVersion', { version })} blockClassName={styles.version} />
<InlineMarkdown data={translate('AppUpdatedVersion', { appName: 'Lidarr', version })} blockClassName={styles.version} />
</div>
{

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

@ -28,11 +28,11 @@ function ConnectionLostModal(props) {
<ModalBody>
<div>
{translate('ConnectionLostToBackend')}
{translate('ConnectionLostToBackend', { appName: 'Lidarr' })}
</div>
<div className={styles.automatic}>
{translate('ConnectionLostReconnect')}
{translate('ConnectionLostReconnect', { appName: 'Lidarr' })}
</div>
</ModalBody>
<ModalFooter>

View file

@ -1,83 +0,0 @@
import { cloneDeep } from 'lodash';
import React, { useCallback, useEffect } from 'react';
import useSelectState, { SelectState } from 'Helpers/Hooks/useSelectState';
import ModelBase from './ModelBase';
export type SelectContextAction =
| { type: 'reset' }
| { type: 'selectAll' }
| { type: 'unselectAll' }
| {
type: 'toggleSelected';
id: number;
isSelected: boolean;
shiftKey: boolean;
}
| {
type: 'removeItem';
id: number;
}
| {
type: 'updateItems';
items: ModelBase[];
};
export type SelectDispatch = (action: SelectContextAction) => void;
interface SelectProviderOptions<T extends ModelBase> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children: any;
items: Array<T>;
}
const SelectContext = React.createContext<
[SelectState, SelectDispatch] | undefined
>(cloneDeep(undefined));
export function SelectProvider<T extends ModelBase>(
props: SelectProviderOptions<T>
) {
const { items } = props;
const [state, dispatch] = useSelectState();
const dispatchWrapper = useCallback(
(action: SelectContextAction) => {
switch (action.type) {
case 'reset':
case 'removeItem':
dispatch(action);
break;
default:
dispatch({
...action,
items,
});
break;
}
},
[items, dispatch]
);
const value: [SelectState, SelectDispatch] = [state, dispatchWrapper];
useEffect(() => {
dispatch({ type: 'updateItems', items });
}, [items, dispatch]);
return (
<SelectContext.Provider value={value}>
{props.children}
</SelectContext.Provider>
);
}
export function useSelect() {
const context = React.useContext(SelectContext);
if (context === undefined) {
throw new Error('useSelect must be used within a SelectProvider');
}
return context;
}

View file

@ -1,8 +0,0 @@
import Album from 'Album/Album';
import AppSectionState, {
AppSectionDeleteState,
} from 'App/State/AppSectionState';
interface AlbumAppState extends AppSectionState<Album>, AppSectionDeleteState {}
export default AlbumAppState;

View file

@ -1,5 +1,4 @@
import SortDirection from 'Helpers/Props/SortDirection';
import { FilterBuilderProp } from './AppState';
export interface Error {
responseJSON: {
@ -21,10 +20,6 @@ export interface PagedAppSectionState {
pageSize: number;
}
export interface AppSectionFilterState<T> {
filterBuilderProps: FilterBuilderProp<T>[];
}
export interface AppSectionSchemaState<T> {
isSchemaFetching: boolean;
isSchemaPopulated: boolean;

View file

@ -1,15 +1,5 @@
import ParseAppState from 'App/State/ParseAppState';
import AlbumAppState from './AlbumAppState';
import ArtistAppState, { ArtistIndexAppState } from './ArtistAppState';
import CalendarAppState from './CalendarAppState';
import CommandAppState from './CommandAppState';
import HistoryAppState from './HistoryAppState';
import QueueAppState from './QueueAppState';
import SettingsAppState from './SettingsAppState';
import SystemAppState from './SystemAppState';
import TagsAppState from './TagsAppState';
import TrackFilesAppState from './TrackFilesAppState';
import TracksAppState from './TracksAppState';
interface FilterBuilderPropOption {
id: string;
@ -43,30 +33,9 @@ export interface CustomFilter {
filers: PropertyFilter[];
}
export interface AppSectionState {
version: string;
dimensions: {
isSmallScreen: boolean;
width: number;
height: number;
};
}
interface AppState {
albums: AlbumAppState;
app: AppSectionState;
artist: ArtistAppState;
artistIndex: ArtistIndexAppState;
calendar: CalendarAppState;
commands: CommandAppState;
history: HistoryAppState;
parse: ParseAppState;
queue: QueueAppState;
settings: SettingsAppState;
tags: TagsAppState;
trackFiles: TrackFilesAppState;
tracksSelection: TracksAppState;
system: SystemAppState;
}
export default AppState;

View file

@ -1,72 +0,0 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import Artist from 'Artist/Artist';
import Column from 'Components/Table/Column';
import SortDirection from 'Helpers/Props/SortDirection';
import { Filter, FilterBuilderProp } from './AppState';
export interface ArtistIndexAppState {
sortKey: string;
sortDirection: SortDirection;
secondarySortKey: string;
secondarySortDirection: SortDirection;
view: string;
posterOptions: {
detailedProgressBar: boolean;
size: string;
showTitle: boolean;
showMonitored: boolean;
showQualityProfile: boolean;
showNextAlbum: boolean;
showSearchAction: boolean;
};
bannerOptions: {
detailedProgressBar: boolean;
size: string;
showTitle: boolean;
showMonitored: boolean;
showQualityProfile: boolean;
showNextAlbum: boolean;
showSearchAction: boolean;
};
overviewOptions: {
detailedProgressBar: boolean;
size: string;
showMonitored: boolean;
showQualityProfile: boolean;
showLastAlbum: boolean;
showAdded: boolean;
showAlbumCount: boolean;
showPath: boolean;
showSizeOnDisk: boolean;
showSearchAction: boolean;
};
tableOptions: {
showBanners: boolean;
showSearchAction: boolean;
};
selectedFilterKey: string;
filterBuilderProps: FilterBuilderProp<Artist>[];
filters: Filter[];
columns: Column[];
}
interface ArtistAppState
extends AppSectionState<Artist>,
AppSectionDeleteState,
AppSectionSaveState {
itemMap: Record<number, number>;
deleteOptions: {
addImportListExclusion: boolean;
};
}
export default ArtistAppState;

View file

@ -1,10 +0,0 @@
import Album from 'Album/Album';
import AppSectionState, {
AppSectionFilterState,
} from 'App/State/AppSectionState';
interface CalendarAppState
extends AppSectionState<Album>,
AppSectionFilterState<Album> {}
export default CalendarAppState;

View file

@ -1,8 +0,0 @@
import { CustomFilter } from './AppState';
interface ClientSideCollectionAppState {
totalItems: number;
customFilters: CustomFilter[];
}
export default ClientSideCollectionAppState;

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,10 +0,0 @@
import AppSectionState, {
AppSectionDeleteState,
} from 'App/State/AppSectionState';
import { CustomFilter } from './AppState';
interface CustomFiltersAppState
extends AppSectionState<CustomFilter>,
AppSectionDeleteState {}
export default CustomFiltersAppState;

View file

@ -1,10 +0,0 @@
import AppSectionState, {
AppSectionFilterState,
} from 'App/State/AppSectionState';
import History from 'typings/History';
interface HistoryAppState
extends AppSectionState<History>,
AppSectionFilterState<History> {}
export default HistoryAppState;

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,27 +0,0 @@
import Queue from 'typings/Queue';
import AppSectionState, {
AppSectionFilterState,
AppSectionItemState,
Error,
} from './AppSectionState';
export interface QueueDetailsAppState extends AppSectionState<Queue> {
params: unknown;
}
export interface QueuePagedAppState
extends AppSectionState<Queue>,
AppSectionFilterState<Queue> {
isGrabbing: boolean;
grabError: Error;
isRemoving: boolean;
removeError: Error;
}
interface QueueAppState {
status: AppSectionItemState<Queue>;
details: QueueDetailsAppState;
paged: QueuePagedAppState;
}
export default QueueAppState;

View file

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

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