mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-03-20 16:44:18 -04:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97e85a908d | ||
|
|
6d91c3b62e | ||
|
|
f30207c3d1 | ||
|
|
028d2414e7 | ||
|
|
cbd7df2c91 | ||
|
|
52972e7efc | ||
|
|
8c50919499 | ||
|
|
fdc07a47b1 | ||
|
|
36225c3709 | ||
|
|
bc037ae356 | ||
|
|
77a335de30 | ||
|
|
88d56361c4 | ||
|
|
d10107739b | ||
|
|
7db7567c8e | ||
|
|
2b2b973b30 | ||
|
|
bb954a7424 | ||
|
|
640e3e5d44 | ||
|
|
1260d3c800 | ||
|
|
feeed9a7cf | ||
|
|
c8cb74a976 | ||
|
|
7193acb5ee | ||
|
|
6f1fc1686f | ||
|
|
b7407837b7 |
@@ -2,11 +2,11 @@
|
|||||||
// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet
|
// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet
|
||||||
{
|
{
|
||||||
"name": "Sonarr",
|
"name": "Sonarr",
|
||||||
"image": "mcr.microsoft.com/devcontainers/dotnet:1-8.0",
|
"image": "mcr.microsoft.com/devcontainers/dotnet:1-6.0",
|
||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/node:1": {
|
"ghcr.io/devcontainers/features/node:1": {
|
||||||
"nodeGypDependencies": true,
|
"nodeGypDependencies": true,
|
||||||
"version": "20",
|
"version": "16",
|
||||||
"nvmVersion": "latest"
|
"nvmVersion": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
188
.github/actions/build/action.yml
vendored
188
.github/actions/build/action.yml
vendored
@@ -1,188 +0,0 @@
|
|||||||
name: Build Backend
|
|
||||||
description: Builds the backend and packages it
|
|
||||||
|
|
||||||
inputs:
|
|
||||||
branch:
|
|
||||||
description: "Branch name for this build"
|
|
||||||
required: true
|
|
||||||
version:
|
|
||||||
description: "Version number to build"
|
|
||||||
required: true
|
|
||||||
framework:
|
|
||||||
description: ".net framework used for the build"
|
|
||||||
required: true
|
|
||||||
runtime:
|
|
||||||
description: "Run time to build for"
|
|
||||||
required: true
|
|
||||||
package_tests:
|
|
||||||
description: "True if tests should be packaged for later testing steps"
|
|
||||||
|
|
||||||
runs:
|
|
||||||
using: "composite"
|
|
||||||
steps:
|
|
||||||
- name: Setup .NET
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
|
|
||||||
- name: Setup Environment Variables
|
|
||||||
id: variables
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
DOTNET_VERSION=$(jq -r '.sdk.version' global.json)
|
|
||||||
|
|
||||||
echo "SDK_PATH=${{ env.DOTNET_ROOT }}/sdk/${DOTNET_VERSION}" >> "$GITHUB_ENV"
|
|
||||||
echo "SONARR_VERSION=${{ inputs.version }}" >> "$GITHUB_ENV"
|
|
||||||
echo "BRANCH=${{ inputs.branch }}" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
|
||||||
echo "NUGET_PACKAGES=D:\nuget\packages" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Enable Extra Platforms In SDK
|
|
||||||
if: ${{ inputs.runtime == 'freebsd-x64' }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
BUNDLEDVERSIONS="${SDK_PATH}/Microsoft.NETCoreSdk.BundledVersions.props"
|
|
||||||
|
|
||||||
if grep -q freebsd-x64 "$BUNDLEDVERSIONS"; then
|
|
||||||
echo "Extra platforms already enabled"
|
|
||||||
else
|
|
||||||
echo "Enabling extra platform support"
|
|
||||||
sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64/' "$BUNDLEDVERSIONS"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if grep -qv freebsd-x64 src/Directory.Build.props; then
|
|
||||||
sed -i'' -e "s^<RuntimeIdentifiers>\(.*\)</RuntimeIdentifiers>^<RuntimeIdentifiers>\1;freebsd-x64</RuntimeIdentifiers>^g" src/Directory.Build.props
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Update Version Number
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
if [ "$SONARR_VERSION" != "" ]; then
|
|
||||||
echo "Updating version info to: $SONARR_VERSION"
|
|
||||||
sed -i'' -e "s/<AssemblyVersion>[0-9.*]\+<\/AssemblyVersion>/<AssemblyVersion>$SONARR_VERSION<\/AssemblyVersion>/g" src/Directory.Build.props
|
|
||||||
sed -i'' -e "s/<AssemblyConfiguration>[\$()A-Za-z-]\+<\/AssemblyConfiguration>/<AssemblyConfiguration>${BRANCH}<\/AssemblyConfiguration>/g" src/Directory.Build.props
|
|
||||||
sed -i'' -e "s/<string>10.0.0.0<\/string>/<string>$SONARR_VERSION<\/string>/g" distribution/macOS/Sonarr.app/Contents/Info.plist
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build Backend
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
runtime="${{ inputs.runtime }}"
|
|
||||||
platform=Windows
|
|
||||||
slnFile=src/Sonarr.sln
|
|
||||||
targetingWindows=false
|
|
||||||
|
|
||||||
IFS='-' read -ra SPLIT <<< "$runtime"
|
|
||||||
|
|
||||||
if [ "${SPLIT[0]}" == "win" ]; then
|
|
||||||
platform=Windows
|
|
||||||
targetingWindows=true
|
|
||||||
else
|
|
||||||
platform=Posix
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -rf _output
|
|
||||||
rm -rf _tests
|
|
||||||
|
|
||||||
echo "Building Sonarr for $runtime, Platform: $platform"
|
|
||||||
|
|
||||||
dotnet msbuild -restore $slnFile -p:SelfContained=True -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$runtime -p:EnableWindowsTargeting=true -t:PublishAllRids
|
|
||||||
|
|
||||||
- name: Package
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
framework="${{ inputs.framework }}"
|
|
||||||
runtime="${{ inputs.runtime }}"
|
|
||||||
|
|
||||||
IFS='-' read -ra SPLIT <<< "$runtime"
|
|
||||||
|
|
||||||
case "${SPLIT[0]}" in
|
|
||||||
linux|freebsd*)
|
|
||||||
folder=_artifacts/$runtime/$framework/Sonarr
|
|
||||||
|
|
||||||
echo "Packaging files"
|
|
||||||
rm -rf $folder
|
|
||||||
mkdir -p $folder
|
|
||||||
cp -r _output/$framework/$runtime/publish/* $folder
|
|
||||||
cp -r _output/Sonarr.Update/$framework/$runtime/publish $folder/Sonarr.Update
|
|
||||||
cp LICENSE.md $folder
|
|
||||||
|
|
||||||
echo "Removing Service helpers"
|
|
||||||
rm -f $folder/ServiceUninstall.*
|
|
||||||
rm -f $folder/ServiceInstall.*
|
|
||||||
|
|
||||||
echo "Removing Sonarr.Windows"
|
|
||||||
rm $folder/Sonarr.Windows.*
|
|
||||||
|
|
||||||
echo "Adding Sonarr.Mono to UpdatePackage"
|
|
||||||
cp $folder/Sonarr.Mono.* $folder/Sonarr.Update
|
|
||||||
cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update
|
|
||||||
cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update
|
|
||||||
;;
|
|
||||||
win)
|
|
||||||
folder=_artifacts/$runtime/$framework/Sonarr
|
|
||||||
|
|
||||||
echo "Packaging files"
|
|
||||||
rm -rf $folder
|
|
||||||
mkdir -p $folder
|
|
||||||
cp -r _output/$framework/$runtime/publish/* $folder
|
|
||||||
cp -r _output/Sonarr.Update/$framework/$runtime/publish $folder/Sonarr.Update
|
|
||||||
cp LICENSE.md $folder
|
|
||||||
cp -r _output/$framework-windows/$runtime/publish/* $folder
|
|
||||||
|
|
||||||
echo "Removing Sonarr.Mono"
|
|
||||||
rm -f $folder/Sonarr.Mono.*
|
|
||||||
rm -f $folder/Mono.Posix.NETStandard.*
|
|
||||||
rm -f $folder/libMonoPosixHelper.*
|
|
||||||
|
|
||||||
echo "Adding Sonarr.Windows to UpdatePackage"
|
|
||||||
cp $folder/Sonarr.Windows.* $folder/Sonarr.Update
|
|
||||||
|
|
||||||
;;
|
|
||||||
osx)
|
|
||||||
folder=_artifacts/$runtime/$framework/Sonarr
|
|
||||||
|
|
||||||
echo "Packaging files"
|
|
||||||
rm -rf $folder
|
|
||||||
mkdir -p $folder
|
|
||||||
cp -r _output/$framework/$runtime/publish/* $folder
|
|
||||||
cp -r _output/Sonarr.Update/$framework/$runtime/publish $folder/Sonarr.Update
|
|
||||||
cp LICENSE.md $folder
|
|
||||||
|
|
||||||
echo "Removing Service helpers"
|
|
||||||
rm -f $folder/ServiceUninstall.*
|
|
||||||
rm -f $folder/ServiceInstall.*
|
|
||||||
|
|
||||||
echo "Removing Sonarr.Windows"
|
|
||||||
rm $folder/Sonarr.Windows.*
|
|
||||||
|
|
||||||
echo "Adding Sonarr.Mono to UpdatePackage"
|
|
||||||
cp $folder/Sonarr.Mono.* $folder/Sonarr.Update
|
|
||||||
cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update
|
|
||||||
cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
- name: Package Tests
|
|
||||||
if: ${{ inputs.package_tests }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
framework="${{ inputs.framework }}"
|
|
||||||
runtime="${{ inputs.runtime }}"
|
|
||||||
|
|
||||||
cp scripts/test.sh "_tests/$framework/$runtime/publish"
|
|
||||||
|
|
||||||
rm -f _tests/$framework/$runtime/*.log.config
|
|
||||||
|
|
||||||
- name: Upload Test Artifacts
|
|
||||||
if: ${{ inputs.package_tests }}
|
|
||||||
uses: ./.github/actions/publish-test-artifact
|
|
||||||
with:
|
|
||||||
framework: ${{ inputs.framework }}
|
|
||||||
runtime: ${{ inputs.runtime }}
|
|
||||||
|
|
||||||
- name: Upload Artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: build-${{ inputs.runtime }}
|
|
||||||
path: _artifacts/**/*
|
|
||||||
26
.github/actions/package/action.yml
vendored
26
.github/actions/package/action.yml
vendored
@@ -2,34 +2,34 @@ name: Package
|
|||||||
description: Packages binaries for deployment
|
description: Packages binaries for deployment
|
||||||
|
|
||||||
inputs:
|
inputs:
|
||||||
runtime:
|
platform:
|
||||||
description: "Binary runtime"
|
description: 'Binary platform'
|
||||||
required: true
|
required: true
|
||||||
framework:
|
framework:
|
||||||
description: ".net framework"
|
description: '.net framework'
|
||||||
required: true
|
required: true
|
||||||
artifact:
|
artifact:
|
||||||
description: "Binary artifact"
|
description: 'Binary artifact'
|
||||||
required: true
|
required: true
|
||||||
branch:
|
branch:
|
||||||
description: "Git branch used for this build"
|
description: 'Git branch used for this build'
|
||||||
required: true
|
required: true
|
||||||
major_version:
|
major_version:
|
||||||
description: "Sonarr major version"
|
description: 'Sonarr major version'
|
||||||
required: true
|
required: true
|
||||||
version:
|
version:
|
||||||
description: "Sonarr version"
|
description: 'Sonarr version'
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: 'composite'
|
||||||
steps:
|
steps:
|
||||||
- name: Download Artifact
|
- name: Download Artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ inputs.artifact }}
|
name: ${{ inputs.artifact }}
|
||||||
path: _output
|
path: _output
|
||||||
|
|
||||||
- name: Download UI Artifact
|
- name: Download UI Artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -49,7 +49,7 @@ runs:
|
|||||||
run: $GITHUB_ACTION_PATH/package.sh
|
run: $GITHUB_ACTION_PATH/package.sh
|
||||||
|
|
||||||
- name: Create Windows Installer (x64)
|
- name: Create Windows Installer (x64)
|
||||||
if: ${{ inputs.runtime == 'win-x64' }}
|
if: ${{ inputs.platform == 'windows' }}
|
||||||
working-directory: distribution/windows/setup
|
working-directory: distribution/windows/setup
|
||||||
shell: cmd
|
shell: cmd
|
||||||
run: |
|
run: |
|
||||||
@@ -58,7 +58,7 @@ runs:
|
|||||||
build.bat
|
build.bat
|
||||||
|
|
||||||
- name: Create Windows Installer (x86)
|
- name: Create Windows Installer (x86)
|
||||||
if: ${{ inputs.runtime == 'win-x86' }}
|
if: ${{ inputs.platform == 'windows' }}
|
||||||
working-directory: distribution/windows/setup
|
working-directory: distribution/windows/setup
|
||||||
shell: cmd
|
shell: cmd
|
||||||
run: |
|
run: |
|
||||||
@@ -69,7 +69,7 @@ runs:
|
|||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-${{ inputs.runtime }}
|
name: release_${{ inputs.platform }}
|
||||||
compression-level: 0
|
compression-level: 0
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
path: |
|
path: |
|
||||||
|
|||||||
2
.github/actions/package/package.sh
vendored
2
.github/actions/package/package.sh
vendored
@@ -3,7 +3,7 @@
|
|||||||
outputFolder=_output
|
outputFolder=_output
|
||||||
artifactsFolder=_artifacts
|
artifactsFolder=_artifacts
|
||||||
uiFolder="$outputFolder/UI"
|
uiFolder="$outputFolder/UI"
|
||||||
framework="${FRAMEWORK:=net8.0}"
|
framework="${FRAMEWORK:=net6.0}"
|
||||||
|
|
||||||
rm -rf $artifactsFolder
|
rm -rf $artifactsFolder
|
||||||
mkdir $artifactsFolder
|
mkdir $artifactsFolder
|
||||||
|
|||||||
23
.github/workflows/api_docs.yml
vendored
23
.github/workflows/api_docs.yml
vendored
@@ -1,12 +1,12 @@
|
|||||||
name: "API Docs"
|
name: 'API Docs'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 0 * * 1"
|
- cron: '0 0 * * 1'
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- v5-develop
|
- develop
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/api_docs.yml"
|
- ".github/workflows/api_docs.yml"
|
||||||
- "docs.sh"
|
- "docs.sh"
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
id: setup-dotnet
|
id: setup-dotnet
|
||||||
|
|
||||||
- name: Create openapi.json
|
- name: Create openapi.json
|
||||||
run: ./scripts/docs.sh Linux x64
|
run: ./docs.sh Linux
|
||||||
|
|
||||||
- name: Commit API Docs Change
|
- name: Commit API Docs Change
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
@@ -46,20 +46,7 @@ jobs:
|
|||||||
then
|
then
|
||||||
git commit -am 'Automated API Docs update' -m "ignore-downstream"
|
git commit -am 'Automated API Docs update' -m "ignore-downstream"
|
||||||
git push -f --set-upstream origin api-docs
|
git push -f --set-upstream origin api-docs
|
||||||
curl -X POST -H "Authorization: Bearer ${{ secrets.OPENAPI_PAT }}" -H "Accept: application/vnd.github+json" https://api.github.com/repos/sonarr/sonarr/pulls -d '{"head":"api-docs","base":"v5-develop","title":"Update API docs"}'
|
curl -X POST -H "Authorization: Bearer ${{ secrets.OPENAPI_PAT }}" -H "Accept: application/vnd.github+json" https://api.github.com/repos/sonarr/sonarr/pulls -d '{"head":"api-docs","base":"develop","title":"Update API docs"}'
|
||||||
else
|
else
|
||||||
echo "No changes since last run"
|
echo "No changes since last run"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Notify
|
|
||||||
if: failure()
|
|
||||||
uses: tsickert/discord-webhook@v6.0.0
|
|
||||||
with:
|
|
||||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
|
||||||
username: "GitHub Actions"
|
|
||||||
avatar-url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"
|
|
||||||
embed-title: "${{ github.workflow }}: Failure"
|
|
||||||
embed-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
|
||||||
embed-description: |
|
|
||||||
Failed to update API docs
|
|
||||||
embed-color: "15158332"
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ name: Build
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- v5-develop
|
- develop
|
||||||
- v5-main
|
- main
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- "src/Sonarr.Api.*/openapi.json"
|
- "src/Sonarr.Api.*/openapi.json"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- v5-develop
|
- develop
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- "src/NzbDrone.Core/Localization/Core/**"
|
- "src/NzbDrone.Core/Localization/Core/**"
|
||||||
- "src/Sonarr.Api.*/openapi.json"
|
- "src/Sonarr.Api.*/openapi.json"
|
||||||
@@ -19,79 +19,91 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FRAMEWORK: net8.0
|
FRAMEWORK: net6.0
|
||||||
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
||||||
SONARR_MAJOR_VERSION: 5
|
SONARR_MAJOR_VERSION: 4
|
||||||
VERSION: 5.0.0
|
VERSION: 4.0.17
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare:
|
backend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: windows-latest
|
||||||
outputs:
|
outputs:
|
||||||
framework: ${{ steps.variables.outputs.framework }}
|
framework: ${{ steps.variables.outputs.framework }}
|
||||||
major_version: ${{ steps.variables.outputs.major_version }}
|
major_version: ${{ steps.variables.outputs.major_version }}
|
||||||
version: ${{ steps.variables.outputs.version }}
|
version: ${{ steps.variables.outputs.version }}
|
||||||
branch: ${{ steps.variables.outputs.branch }}
|
|
||||||
steps:
|
|
||||||
- name: Setup Environment Variables
|
|
||||||
id: variables
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "framework=${{ env.FRAMEWORK }}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "major_version=${{ env.SONARR_MAJOR_VERSION }}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "version=${{ env.VERSION }}.$((${{ github.run_number }}))" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "branch=${RAW_BRANCH_NAME//\//-}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
backend:
|
|
||||||
needs: prepare
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- runtime: freebsd-x64
|
|
||||||
package_tests: false
|
|
||||||
os: ubuntu-latest
|
|
||||||
- runtime: linux-arm
|
|
||||||
package_tests: false
|
|
||||||
os: ubuntu-latest
|
|
||||||
- runtime: linux-arm64
|
|
||||||
package_tests: false
|
|
||||||
os: ubuntu-latest
|
|
||||||
- runtime: linux-musl-arm64
|
|
||||||
package_tests: false
|
|
||||||
os: ubuntu-latest
|
|
||||||
- runtime: linux-musl-x64
|
|
||||||
package_tests: false
|
|
||||||
os: ubuntu-latest
|
|
||||||
- runtime: linux-x64
|
|
||||||
package_tests: true
|
|
||||||
os: ubuntu-latest
|
|
||||||
- runtime: osx-arm64
|
|
||||||
package_tests: true
|
|
||||||
os: ubuntu-latest
|
|
||||||
- runtime: osx-x64
|
|
||||||
package_tests: false
|
|
||||||
os: ubuntu-latest
|
|
||||||
- runtime: win-x64
|
|
||||||
package_tests: true
|
|
||||||
os: ubuntu-latest
|
|
||||||
- runtime: win-x86
|
|
||||||
package_tests: false
|
|
||||||
os: ubuntu-latest
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build
|
- name: Setup .NET
|
||||||
uses: ./.github/actions/build
|
uses: actions/setup-dotnet@v4
|
||||||
|
|
||||||
|
- name: Setup Environment Variables
|
||||||
|
id: variables
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Add 800 to the build number because GitHub won't let us pick an arbitrary starting point
|
||||||
|
SONARR_VERSION="${{ env.VERSION }}.$((${{ github.run_number }}+800))"
|
||||||
|
DOTNET_VERSION=$(jq -r '.sdk.version' global.json)
|
||||||
|
|
||||||
|
echo "SDK_PATH=${{ env.DOTNET_ROOT }}/sdk/${DOTNET_VERSION}" >> "$GITHUB_ENV"
|
||||||
|
echo "SONARR_VERSION=$SONARR_VERSION" >> "$GITHUB_ENV"
|
||||||
|
echo "BRANCH=${RAW_BRANCH_NAME//\//-}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
echo "framework=${{ env.FRAMEWORK }}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "major_version=${{ env.SONARR_MAJOR_VERSION }}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=$SONARR_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Enable Extra Platforms In SDK
|
||||||
|
shell: bash
|
||||||
|
run: ./build.sh --enable-extra-platforms-in-sdk
|
||||||
|
|
||||||
|
- name: Build Backend
|
||||||
|
shell: bash
|
||||||
|
run: ./build.sh --backend --enable-extra-platforms --packages
|
||||||
|
|
||||||
|
# Test Artifacts
|
||||||
|
|
||||||
|
- name: Publish win-x64 Test Artifact
|
||||||
|
uses: ./.github/actions/publish-test-artifact
|
||||||
with:
|
with:
|
||||||
branch: ${{ needs.prepare.outputs.branch }}
|
framework: ${{ env.FRAMEWORK }}
|
||||||
version: ${{ needs.prepare.outputs.version }}
|
runtime: win-x64
|
||||||
framework: ${{ needs.prepare.outputs.framework }}
|
|
||||||
runtime: ${{ matrix.runtime }}
|
- name: Publish linux-x64 Test Artifact
|
||||||
package_tests: ${{ matrix.package_tests }}
|
uses: ./.github/actions/publish-test-artifact
|
||||||
|
with:
|
||||||
|
framework: ${{ env.FRAMEWORK }}
|
||||||
|
runtime: linux-x64
|
||||||
|
|
||||||
|
- name: Publish osx-arm64 Test Artifact
|
||||||
|
uses: ./.github/actions/publish-test-artifact
|
||||||
|
with:
|
||||||
|
framework: ${{ env.FRAMEWORK }}
|
||||||
|
runtime: osx-arm64
|
||||||
|
|
||||||
|
# Build Artifacts (grouped by OS)
|
||||||
|
|
||||||
|
- name: Publish FreeBSD Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build_freebsd
|
||||||
|
path: _artifacts/freebsd-*/**/*
|
||||||
|
- name: Publish Linux Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build_linux
|
||||||
|
path: _artifacts/linux-*/**/*
|
||||||
|
- name: Publish macOS Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build_macos
|
||||||
|
path: _artifacts/osx-*/**/*
|
||||||
|
- name: Publish Windows Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build_windows
|
||||||
|
path: _artifacts/win-*/**/*
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -166,7 +178,7 @@ jobs:
|
|||||||
use_postgres: true
|
use_postgres: true
|
||||||
|
|
||||||
integration_test:
|
integration_test:
|
||||||
needs: [prepare, backend]
|
needs: backend
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -175,18 +187,18 @@ jobs:
|
|||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
artifact: tests-linux-x64
|
artifact: tests-linux-x64
|
||||||
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory=IntegrationTest
|
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory=IntegrationTest
|
||||||
binary_artifact: build-linux-x64
|
binary_artifact: build_linux
|
||||||
binary_path: linux-x64/${{ needs.prepare.outputs.framework }}/Sonarr
|
binary_path: linux-x64/${{ needs.backend.outputs.framework }}/Sonarr
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
artifact: tests-osx-arm64
|
artifact: tests-osx-arm64
|
||||||
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory=IntegrationTest
|
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory=IntegrationTest
|
||||||
binary_artifact: build-osx-arm64
|
binary_artifact: build_macos
|
||||||
binary_path: osx-arm64/${{ needs.prepare.outputs.framework }}/Sonarr
|
binary_path: osx-arm64/${{ needs.backend.outputs.framework }}/Sonarr
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
artifact: tests-win-x64
|
artifact: tests-win-x64
|
||||||
filter: TestCategory!=ManualTest&TestCategory!=LINUX&TestCategory=IntegrationTest
|
filter: TestCategory!=ManualTest&TestCategory!=LINUX&TestCategory=IntegrationTest
|
||||||
binary_artifact: build-win-x64
|
binary_artifact: build_windows
|
||||||
binary_path: win-x64/${{ needs.prepare.outputs.framework }}/Sonarr
|
binary_path: win-x64/${{ needs.backend.outputs.framework }}/Sonarr
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
@@ -204,29 +216,20 @@ jobs:
|
|||||||
binary_path: ${{ matrix.binary_path }}
|
binary_path: ${{ matrix.binary_path }}
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
if: ${{ github.ref_name == 'v5-develop-final' || github.ref_name == 'v5-main-final' }}
|
if: ${{ github.ref_name == 'develop' || github.ref_name == 'main' }}
|
||||||
needs:
|
needs: [backend, frontend, unit_test, unit_test_postgres, integration_test]
|
||||||
[
|
|
||||||
prepare,
|
|
||||||
backend,
|
|
||||||
frontend,
|
|
||||||
unit_test,
|
|
||||||
unit_test_postgres,
|
|
||||||
integration_test,
|
|
||||||
]
|
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
uses: ./.github/workflows/deploy.yml
|
uses: ./.github/workflows/deploy.yml
|
||||||
with:
|
with:
|
||||||
framework: ${{ needs.prepare.outputs.framework }}
|
framework: ${{ needs.backend.outputs.framework }}
|
||||||
branch: ${{ github.ref_name }}
|
branch: ${{ github.ref_name }}
|
||||||
major_version: ${{ needs.prepare.outputs.major_version }}
|
major_version: ${{ needs.backend.outputs.major_version }}
|
||||||
version: ${{ needs.prepare.outputs.version }}
|
version: ${{ needs.backend.outputs.version }}
|
||||||
|
|
||||||
notify:
|
notify:
|
||||||
name: Discord Notification
|
name: Discord Notification
|
||||||
needs:
|
needs:
|
||||||
[
|
[
|
||||||
prepare,
|
|
||||||
backend,
|
backend,
|
||||||
frontend,
|
frontend,
|
||||||
unit_test,
|
unit_test,
|
||||||
@@ -234,7 +237,7 @@ jobs:
|
|||||||
integration_test,
|
integration_test,
|
||||||
deploy,
|
deploy,
|
||||||
]
|
]
|
||||||
if: ${{ !cancelled() && (github.ref_name == 'v5-develop-final' || github.ref_name == 'v5-main-final') }}
|
if: ${{ !cancelled() && (github.ref_name == 'develop' || github.ref_name == 'main') }}
|
||||||
env:
|
env:
|
||||||
STATUS: ${{ contains(needs.*.result, 'failure') && 'failure' || 'success' }}
|
STATUS: ${{ contains(needs.*.result, 'failure') && 'failure' || 'success' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -250,5 +253,5 @@ jobs:
|
|||||||
embed-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
embed-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
embed-description: |
|
embed-description: |
|
||||||
**Branch** ${{ github.ref }}
|
**Branch** ${{ github.ref }}
|
||||||
**Build** ${{ needs.prepare.outputs.version }}
|
**Build** ${{ needs.backend.outputs.version }}
|
||||||
embed-color: ${{ env.STATUS == 'success' && '3066993' || '15158332' }}
|
embed-color: ${{ env.STATUS == 'success' && '3066993' || '15158332' }}
|
||||||
6
.github/workflows/conflict_labeler.yml
vendored
6
.github/workflows/conflict_labeler.yml
vendored
@@ -7,7 +7,6 @@ on:
|
|||||||
pull_request_target:
|
pull_request_target:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
- v5-develop
|
|
||||||
types: [synchronize]
|
types: [synchronize]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -22,5 +21,6 @@ jobs:
|
|||||||
- name: Apply label
|
- name: Apply label
|
||||||
uses: eps1lon/actions-label-merge-conflict@v3
|
uses: eps1lon/actions-label-merge-conflict@v3
|
||||||
with:
|
with:
|
||||||
dirtyLabel: "merge-conflict"
|
dirtyLabel: 'merge-conflict'
|
||||||
repoToken: "${{ secrets.GITHUB_TOKEN }}"
|
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
||||||
|
|
||||||
241
.github/workflows/deploy.yml
vendored
241
.github/workflows/deploy.yml
vendored
@@ -3,20 +3,20 @@ name: Deploy
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
framework:
|
framework:
|
||||||
description: ".net framework"
|
description: '.net framework'
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
branch:
|
branch:
|
||||||
description: "Git branch used for this build"
|
description: 'Git branch used for this build'
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
major_version:
|
major_version:
|
||||||
description: "Sonarr major version"
|
description: 'Sonarr major version'
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
version:
|
version:
|
||||||
description: "Sonarr version"
|
description: 'Sonarr version'
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
secrets:
|
secrets:
|
||||||
@@ -27,42 +27,31 @@ jobs:
|
|||||||
package:
|
package:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
platform: [freebsd, linux, macos, windows]
|
||||||
include:
|
include:
|
||||||
- runtime: freebsd-x64
|
- platform: freebsd
|
||||||
os: ubuntu-latest
|
os: ubuntu-latest
|
||||||
- runtime: linux-arm
|
- platform: linux
|
||||||
os: ubuntu-latest
|
os: ubuntu-latest
|
||||||
- runtime: linux-arm64
|
- platform: macos
|
||||||
os: ubuntu-latest
|
os: ubuntu-latest
|
||||||
- runtime: linux-musl-arm64
|
- platform: windows
|
||||||
os: ubuntu-latest
|
|
||||||
- runtime: linux-musl-x64
|
|
||||||
os: ubuntu-latest
|
|
||||||
- runtime: linux-x64
|
|
||||||
os: ubuntu-latest
|
|
||||||
- runtime: osx-arm64
|
|
||||||
os: ubuntu-latest
|
|
||||||
- runtime: osx-x64
|
|
||||||
os: ubuntu-latest
|
|
||||||
- runtime: win-x64
|
|
||||||
os: windows-latest
|
|
||||||
- runtime: win-x86
|
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Package
|
- name: Package
|
||||||
uses: ./.github/actions/package
|
uses: ./.github/actions/package
|
||||||
with:
|
with:
|
||||||
framework: ${{ inputs.framework }}
|
framework: ${{ inputs.framework }}
|
||||||
runtime: ${{ matrix.runtime }}
|
platform: ${{ matrix.platform }}
|
||||||
artifact: build-${{ matrix.runtime }}
|
artifact: build_${{ matrix.platform }}
|
||||||
branch: ${{ inputs.branch }}
|
branch: ${{ inputs.branch }}
|
||||||
major_version: ${{ inputs.major_version }}
|
major_version: ${{ inputs.major_version }}
|
||||||
version: ${{ inputs.version }}
|
version: ${{ inputs.version }}
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: package
|
needs: package
|
||||||
@@ -70,102 +59,102 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download release artifacts
|
- name: Download release artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: _artifacts
|
path: _artifacts
|
||||||
pattern: release-*
|
pattern: release_*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Get Previous Release
|
- name: Get Previous Release
|
||||||
id: previous-release
|
id: previous-release
|
||||||
uses: cardinalby/git-get-release-action@v1
|
uses: cardinalby/git-get-release-action@v1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
with:
|
with:
|
||||||
latest: true
|
latest: true
|
||||||
prerelease: ${{ inputs.branch != 'main' }}
|
prerelease: ${{ inputs.branch != 'main' }}
|
||||||
|
|
||||||
- name: Generate Release Notes
|
- name: Generate Release Notes
|
||||||
id: generate-release-notes
|
id: generate-release-notes
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
github-token: ${{ github.token }}
|
github-token: ${{ github.token }}
|
||||||
result-encoding: string
|
result-encoding: string
|
||||||
script: |
|
script: |
|
||||||
const { data } = await github.rest.repos.generateReleaseNotes({
|
const { data } = await github.rest.repos.generateReleaseNotes({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
tag_name: 'v${{ inputs.version }}',
|
tag_name: 'v${{ inputs.version }}',
|
||||||
target_commitish: '${{ github.sha }}',
|
target_commitish: '${{ github.sha }}',
|
||||||
previous_tag_name: '${{ steps.previous-release.outputs.tag_name }}',
|
previous_tag_name: '${{ steps.previous-release.outputs.tag_name }}',
|
||||||
})
|
})
|
||||||
return data.body
|
return data.body
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@v1
|
||||||
with:
|
with:
|
||||||
artifacts: _artifacts/Sonarr.*
|
artifacts: _artifacts/Sonarr.*
|
||||||
commit: ${{ github.sha }}
|
commit: ${{ github.sha }}
|
||||||
generateReleaseNotes: false
|
generateReleaseNotes: false
|
||||||
body: ${{ steps.generate-release-notes.outputs.result }}
|
body: ${{ steps.generate-release-notes.outputs.result }}
|
||||||
name: ${{ inputs.version }}
|
name: ${{ inputs.version }}
|
||||||
prerelease: ${{ inputs.branch != 'main' }}
|
prerelease: ${{ inputs.branch != 'main' }}
|
||||||
skipIfReleaseExists: true
|
skipIfReleaseExists: true
|
||||||
tag: v${{ inputs.version }}
|
tag: v${{ inputs.version }}
|
||||||
|
|
||||||
- name: Publish to Services
|
- name: Publish to Services
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: _artifacts
|
working-directory: _artifacts
|
||||||
run: |
|
run: |
|
||||||
branch=${{ inputs.branch }}
|
branch=${{ inputs.branch }}
|
||||||
version=${{ inputs.version }}
|
version=${{ inputs.version }}
|
||||||
lastCommit=${{ github.sha }}
|
lastCommit=${{ github.sha }}
|
||||||
|
|
||||||
hashes="["
|
hashes="["
|
||||||
|
|
||||||
addHash() {
|
addHash() {
|
||||||
path=$1
|
path=$1
|
||||||
os=$2
|
os=$2
|
||||||
arch=$3
|
arch=$3
|
||||||
type=$4
|
type=$4
|
||||||
|
|
||||||
local hash=$(sha256sum *.$version.$path | awk '{ print $1; }')
|
local hash=$(sha256sum *.$version.$path | awk '{ print $1; }')
|
||||||
echo "{ \""Os\"": \""$os\"", \""Arch\"": \""$arch\"", \""Type\"": \""$type\"", \""Hash\"": \""$hash\"" }"
|
echo "{ \""Os\"": \""$os\"", \""Arch\"": \""$arch\"", \""Type\"": \""$type\"", \""Hash\"": \""$hash\"" }"
|
||||||
}
|
}
|
||||||
|
|
||||||
hashes="$hashes $(addHash "linux-arm.tar.gz" "linux" "arm" "archive")"
|
hashes="$hashes $(addHash "linux-arm.tar.gz" "linux" "arm" "archive")"
|
||||||
hashes="$hashes, $(addHash "linux-arm64.tar.gz" "linux" "arm64" "archive")"
|
hashes="$hashes, $(addHash "linux-arm64.tar.gz" "linux" "arm64" "archive")"
|
||||||
hashes="$hashes, $(addHash "linux-x64.tar.gz" "linux" "x64" "archive")"
|
hashes="$hashes, $(addHash "linux-x64.tar.gz" "linux" "x64" "archive")"
|
||||||
# hashes="$hashes, $(addHash "linux-x86.tar.gz" "linux" "x86" "archive")"
|
# hashes="$hashes, $(addHash "linux-x86.tar.gz" "linux" "x86" "archive")"
|
||||||
|
|
||||||
# hashes="$hashes, $(addHash "linux-musl-arm.tar.gz" "linuxmusl" "arm" "archive")"
|
# hashes="$hashes, $(addHash "linux-musl-arm.tar.gz" "linuxmusl" "arm" "archive")"
|
||||||
hashes="$hashes, $(addHash "linux-musl-arm64.tar.gz" "linuxmusl" "arm64" "archive")"
|
hashes="$hashes, $(addHash "linux-musl-arm64.tar.gz" "linuxmusl" "arm64" "archive")"
|
||||||
hashes="$hashes, $(addHash "linux-musl-x64.tar.gz" "linuxmusl" "x64" "archive")"
|
hashes="$hashes, $(addHash "linux-musl-x64.tar.gz" "linuxmusl" "x64" "archive")"
|
||||||
|
|
||||||
hashes="$hashes, $(addHash "osx-arm64.tar.gz" "osx" "arm64" "archive")"
|
hashes="$hashes, $(addHash "osx-arm64.tar.gz" "osx" "arm64" "archive")"
|
||||||
hashes="$hashes, $(addHash "osx-x64.tar.gz" "osx" "x64" "archive")"
|
hashes="$hashes, $(addHash "osx-x64.tar.gz" "osx" "x64" "archive")"
|
||||||
|
|
||||||
hashes="$hashes, $(addHash "osx-arm64-app.zip" "osx" "arm64" "installer")"
|
hashes="$hashes, $(addHash "osx-arm64-app.zip" "osx" "arm64" "installer")"
|
||||||
hashes="$hashes, $(addHash "osx-x64-app.zip" "osx" "x64" "installer")"
|
hashes="$hashes, $(addHash "osx-x64-app.zip" "osx" "x64" "installer")"
|
||||||
|
|
||||||
hashes="$hashes, $(addHash "win-x64.zip" "windows" "x64" "archive")"
|
hashes="$hashes, $(addHash "win-x64.zip" "windows" "x64" "archive")"
|
||||||
hashes="$hashes, $(addHash "win-x86.zip" "windows" "x86" "archive")"
|
hashes="$hashes, $(addHash "win-x86.zip" "windows" "x86" "archive")"
|
||||||
|
|
||||||
hashes="$hashes, $(addHash "win-x64-installer.exe" "windows" "x64" "installer")"
|
hashes="$hashes, $(addHash "win-x64-installer.exe" "windows" "x64" "installer")"
|
||||||
hashes="$hashes, $(addHash "win-x86-installer.exe" "windows" "x86" "installer")"
|
hashes="$hashes, $(addHash "win-x86-installer.exe" "windows" "x86" "installer")"
|
||||||
|
|
||||||
hashes="$hashes, $(addHash "freebsd-x64.tar.gz" "freebsd" "x64" "archive")"
|
hashes="$hashes, $(addHash "freebsd-x64.tar.gz" "freebsd" "x64" "archive")"
|
||||||
|
|
||||||
hashes="$hashes ]"
|
hashes="$hashes ]"
|
||||||
|
|
||||||
json="{\""branch\"":\""$branch\"", \""version\"":\""$version\"", \""lastCommit\"":\""$lastCommit\"", \""hashes\"":$hashes, \""gitHubRelease\"":true}"
|
json="{\""branch\"":\""$branch\"", \""version\"":\""$version\"", \""lastCommit\"":\""$lastCommit\"", \""hashes\"":$hashes, \""gitHubRelease\"":true}"
|
||||||
url="https://services.sonarr.tv/v1/update"
|
url="https://services.sonarr.tv/v1/update"
|
||||||
|
|
||||||
echo "Publishing update $version ($branch) to: $url"
|
echo "Publishing update $version ($branch) to: $url"
|
||||||
echo "$json"
|
echo "$json"
|
||||||
|
|
||||||
curl -H "Content-Type: application/json" -H "X-Api-Key: ${{ secrets.SERVICES_API_KEY }}" -X POST -d "$json" --fail-with-body $url
|
curl -H "Content-Type: application/json" -H "X-Api-Key: ${{ secrets.SERVICES_API_KEY }}" -X POST -d "$json" --fail-with-body $url
|
||||||
|
|||||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -10,7 +10,7 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"preLaunchTask": "build dotnet",
|
"preLaunchTask": "build dotnet",
|
||||||
// If you have changed target frameworks, make sure to update the program path.
|
// If you have changed target frameworks, make sure to update the program path.
|
||||||
"program": "${workspaceFolder}/_output/net8.0/Sonarr",
|
"program": "${workspaceFolder}/_output/net6.0/Sonarr",
|
||||||
"args": [],
|
"args": [],
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
|
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
|
||||||
|
|||||||
@@ -1,35 +1,32 @@
|
|||||||
# How to Contribute
|
# How to Contribute #
|
||||||
|
|
||||||
We're always looking for people to help make Sonarr even better, there are a number of ways to contribute.
|
We're always looking for people to help make Sonarr even better, there are a number of ways to contribute.
|
||||||
|
|
||||||
## Documentation
|
## Documentation ##
|
||||||
|
|
||||||
Setup guides, [FAQ](https://wiki.servarr.com/sonarr/faq), the more information we have on the [wiki](https://wiki.servarr.com/sonarr) the better.
|
Setup guides, [FAQ](https://wiki.servarr.com/sonarr/faq), the more information we have on the [wiki](https://wiki.servarr.com/sonarr) the better.
|
||||||
|
|
||||||
## Development
|
## Development ##
|
||||||
|
|
||||||
### Tools required
|
### Tools required ###
|
||||||
|
- Visual Studio 2019 or higher (https://www.visualstudio.com/vs/). The community version is free and works (https://www.visualstudio.com/downloads/).
|
||||||
- Visual Studio 2019 or higher (https://www.visualstudio.com/vs/). The community version is free and works (https://www.visualstudio.com/downloads/).
|
|
||||||
- HTML/Javascript editor of choice (VS Code/Sublime Text/Webstorm/Atom/etc)
|
- HTML/Javascript editor of choice (VS Code/Sublime Text/Webstorm/Atom/etc)
|
||||||
- [Git](https://git-scm.com/downloads)
|
- [Git](https://git-scm.com/downloads)
|
||||||
- [NodeJS](https://nodejs.org/en/download/) (Node 10.X.X or higher)
|
- [NodeJS](https://nodejs.org/en/download/) (Node 10.X.X or higher)
|
||||||
- [Yarn](https://yarnpkg.com/)
|
- [Yarn](https://yarnpkg.com/)
|
||||||
|
|
||||||
### Getting started
|
### Getting started ###
|
||||||
|
|
||||||
1. Fork Sonarr
|
1. Fork Sonarr
|
||||||
2. Clone the repository into your development machine. [_info_](https://docs.github.com/en/get-started/quickstart/fork-a-repo)
|
2. Clone the repository into your development machine. [*info*](https://docs.github.com/en/get-started/quickstart/fork-a-repo)
|
||||||
3. Install the required Node Packages `yarn install`
|
3. Install the required Node Packages `yarn install`
|
||||||
4. Start webpack to monitor your dev environment for any frontend changes that need post processing using `yarn start` command.
|
4. Start webpack to monitor your dev environment for any frontend changes that need post processing using `yarn start` command.
|
||||||
5. Build the project in Visual Studio, Setting startup project to `Sonarr.Console` and framework to `x86`
|
5. Build the project in Visual Studio, Setting startup project to `Sonarr.Console` and framework to `x86`
|
||||||
6. Debug the project in Visual Studio
|
6. Debug the project in Visual Studio
|
||||||
7. Open http://localhost:8989
|
7. Open http://localhost:8989
|
||||||
|
|
||||||
### Contributing Code
|
### Contributing Code ###
|
||||||
|
|
||||||
- If you're adding a new, already requested feature, please comment on [Github Issues](https://github.com/Sonarr/Sonarr/issues "Github Issues") so work is not duplicated (If you want to add something not already on there, please talk to us first)
|
- If you're adding a new, already requested feature, please comment on [Github Issues](https://github.com/Sonarr/Sonarr/issues "Github Issues") so work is not duplicated (If you want to add something not already on there, please talk to us first)
|
||||||
- Rebase from Sonarr's `v5-develop` branch, don't merge
|
- Rebase from Sonarr's `develop` branch, don't merge
|
||||||
- Make meaningful commits, or squash them
|
- Make meaningful commits, or squash them
|
||||||
- Feel free to make a pull request before work is complete, this will let us see where its at and make comments/suggest improvements
|
- Feel free to make a pull request before work is complete, this will let us see where its at and make comments/suggest improvements
|
||||||
- Reach out to us on our [forums](https://forums.sonarr.tv/), [subreddit](https://www.reddit.com/r/sonarr/), [discord](https://discord.gg/Ex7FmFK), or [IRC](https://web.libera.chat/?channels=#sonarr) if you have any questions
|
- Reach out to us on our [forums](https://forums.sonarr.tv/), [subreddit](https://www.reddit.com/r/sonarr/), [discord](https://discord.gg/Ex7FmFK), or [IRC](https://web.libera.chat/?channels=#sonarr) if you have any questions
|
||||||
@@ -38,9 +35,8 @@ Setup guides, [FAQ](https://wiki.servarr.com/sonarr/faq), the more information w
|
|||||||
- One feature/bug fix per pull request to keep things clean and easy to understand
|
- One feature/bug fix per pull request to keep things clean and easy to understand
|
||||||
- Use 4 spaces instead of tabs, this should be the default for VS 2019 and WebStorm
|
- Use 4 spaces instead of tabs, this should be the default for VS 2019 and WebStorm
|
||||||
|
|
||||||
### Pull Requesting
|
### Pull Requesting ###
|
||||||
|
- Only make pull requests to develop (currently `develop`), never `main`, if you make a PR to master we'll comment on it and close it
|
||||||
- Only make pull requests to the default branch (currently `v5-develop`), never `main`, if you make a PR to main we'll comment on it and close it
|
|
||||||
- You're probably going to get some comments or questions from us, they will be to ensure consistency and maintainability
|
- You're probably going to get some comments or questions from us, they will be to ensure consistency and maintainability
|
||||||
- We'll try to respond to pull requests as soon as possible, if its been a day or two, please reach out to us, we may have missed it
|
- We'll try to respond to pull requests as soon as possible, if its been a day or two, please reach out to us, we may have missed it
|
||||||
- Each PR should come from its own [feature branch](http://martinfowler.com/bliki/FeatureBranch.html) not develop in your fork, it should have a meaningful branch name (what is being added/fixed)
|
- Each PR should come from its own [feature branch](http://martinfowler.com/bliki/FeatureBranch.html) not develop in your fork, it should have a meaningful branch name (what is being added/fixed)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Sonarr is a PVR for Usenet and BitTorrent users. It can monitor multiple RSS fee
|
|||||||
- [Download/Installation](https://sonarr.tv/#downloads-v3)
|
- [Download/Installation](https://sonarr.tv/#downloads-v3)
|
||||||
- [FAQ](https://wiki.servarr.com/sonarr/faq)
|
- [FAQ](https://wiki.servarr.com/sonarr/faq)
|
||||||
- [Wiki](https://wiki.servarr.com/Sonarr)
|
- [Wiki](https://wiki.servarr.com/Sonarr)
|
||||||
- [API Documentation](https://sonarr.tv/docs/api)
|
- [v4 Beta API Documentation](https://sonarr.tv/docs/api)
|
||||||
- [Donate](https://sonarr.tv/donate)
|
- [Donate](https://sonarr.tv/donate)
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
@@ -82,4 +82,4 @@ Thank you to [<img src="https://resources.jetbrains.com/storage/products/company
|
|||||||
### Licenses
|
### Licenses
|
||||||
|
|
||||||
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
|
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
|
||||||
- Copyright 2010-2025
|
- Copyright 2010-2024
|
||||||
|
|||||||
455
build.sh
Executable file
455
build.sh
Executable file
@@ -0,0 +1,455 @@
|
|||||||
|
#! /usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
outputFolder='_output'
|
||||||
|
testPackageFolder='_tests'
|
||||||
|
artifactsFolder="_artifacts";
|
||||||
|
framework="${FRAMEWORK:=net6.0}"
|
||||||
|
|
||||||
|
ProgressStart()
|
||||||
|
{
|
||||||
|
echo "::group::$1"
|
||||||
|
echo "Start '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgressEnd()
|
||||||
|
{
|
||||||
|
echo "Finish '$1'"
|
||||||
|
echo "::endgroup::"
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateVersionNumber()
|
||||||
|
{
|
||||||
|
if [ "$SONARR_VERSION" != "" ]; then
|
||||||
|
echo "Updating version info to: $SONARR_VERSION"
|
||||||
|
sed -i'' -e "s/<AssemblyVersion>[0-9.*]\+<\/AssemblyVersion>/<AssemblyVersion>$SONARR_VERSION<\/AssemblyVersion>/g" src/Directory.Build.props
|
||||||
|
sed -i'' -e "s/<AssemblyConfiguration>[\$()A-Za-z-]\+<\/AssemblyConfiguration>/<AssemblyConfiguration>${BRANCH}<\/AssemblyConfiguration>/g" src/Directory.Build.props
|
||||||
|
sed -i'' -e "s/<string>10.0.0.0<\/string>/<string>$SONARR_VERSION<\/string>/g" distribution/macOS/Sonarr.app/Contents/Info.plist
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
EnableExtraPlatformsInSDK()
|
||||||
|
{
|
||||||
|
BUNDLEDVERSIONS="${SDK_PATH}/Microsoft.NETCoreSdk.BundledVersions.props"
|
||||||
|
if grep -q freebsd-x64 "$BUNDLEDVERSIONS"; then
|
||||||
|
echo "Extra platforms already enabled"
|
||||||
|
else
|
||||||
|
echo "Enabling extra platform support"
|
||||||
|
sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64/' "$BUNDLEDVERSIONS"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
EnableExtraPlatforms()
|
||||||
|
{
|
||||||
|
if grep -qv freebsd-x64 src/Directory.Build.props; then
|
||||||
|
sed -i'' -e "s^<RuntimeIdentifiers>\(.*\)</RuntimeIdentifiers>^<RuntimeIdentifiers>\1;freebsd-x64</RuntimeIdentifiers>^g" src/Directory.Build.props
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
LintUI()
|
||||||
|
{
|
||||||
|
ProgressStart 'ESLint'
|
||||||
|
yarn lint
|
||||||
|
ProgressEnd 'ESLint'
|
||||||
|
|
||||||
|
ProgressStart 'Stylelint'
|
||||||
|
yarn stylelint
|
||||||
|
ProgressEnd 'Stylelint'
|
||||||
|
}
|
||||||
|
|
||||||
|
Build()
|
||||||
|
{
|
||||||
|
ProgressStart 'Build'
|
||||||
|
|
||||||
|
rm -rf $outputFolder
|
||||||
|
rm -rf $testPackageFolder
|
||||||
|
|
||||||
|
slnFile=src/Sonarr.sln
|
||||||
|
|
||||||
|
if [ $os = "windows" ]; then
|
||||||
|
platform=Windows
|
||||||
|
else
|
||||||
|
platform=Posix
|
||||||
|
fi
|
||||||
|
|
||||||
|
dotnet clean $slnFile -c Debug
|
||||||
|
dotnet clean $slnFile -c Release
|
||||||
|
|
||||||
|
if [[ -z "$RID" || -z "$FRAMEWORK" ]];
|
||||||
|
then
|
||||||
|
dotnet msbuild -restore $slnFile -p:Configuration=Release -p:Platform=$platform -t:PublishAllRids
|
||||||
|
else
|
||||||
|
dotnet msbuild -restore $slnFile -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$RID -t:PublishAllRids
|
||||||
|
fi
|
||||||
|
|
||||||
|
ProgressEnd 'Build'
|
||||||
|
}
|
||||||
|
|
||||||
|
YarnInstall()
|
||||||
|
{
|
||||||
|
ProgressStart 'yarn install'
|
||||||
|
yarn install --frozen-lockfile --network-timeout 120000
|
||||||
|
ProgressEnd 'yarn install'
|
||||||
|
}
|
||||||
|
|
||||||
|
RunWebpack()
|
||||||
|
{
|
||||||
|
ProgressStart 'Running webpack'
|
||||||
|
yarn run build --env production
|
||||||
|
ProgressEnd 'Running webpack'
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageFiles()
|
||||||
|
{
|
||||||
|
local folder="$1"
|
||||||
|
local framework="$2"
|
||||||
|
local runtime="$3"
|
||||||
|
|
||||||
|
rm -rf $folder
|
||||||
|
mkdir -p $folder
|
||||||
|
cp -r $outputFolder/$framework/$runtime/publish/* $folder
|
||||||
|
cp -r $outputFolder/Sonarr.Update/$framework/$runtime/publish $folder/Sonarr.Update
|
||||||
|
|
||||||
|
if [ "$FRONTEND" = "YES" ];
|
||||||
|
then
|
||||||
|
cp -r $outputFolder/UI $folder
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Adding LICENSE"
|
||||||
|
cp LICENSE.md $folder
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageLinux()
|
||||||
|
{
|
||||||
|
local framework="$1"
|
||||||
|
local runtime="$2"
|
||||||
|
|
||||||
|
ProgressStart "Creating $runtime Package for $framework"
|
||||||
|
|
||||||
|
local folder=$artifactsFolder/$runtime/$framework/Sonarr
|
||||||
|
|
||||||
|
PackageFiles "$folder" "$framework" "$runtime"
|
||||||
|
|
||||||
|
echo "Removing Service helpers"
|
||||||
|
rm -f $folder/ServiceUninstall.*
|
||||||
|
rm -f $folder/ServiceInstall.*
|
||||||
|
|
||||||
|
echo "Removing Sonarr.Windows"
|
||||||
|
rm $folder/Sonarr.Windows.*
|
||||||
|
|
||||||
|
echo "Adding Sonarr.Mono to UpdatePackage"
|
||||||
|
cp $folder/Sonarr.Mono.* $folder/Sonarr.Update
|
||||||
|
if [ "$framework" = "$framework" ]; then
|
||||||
|
cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update
|
||||||
|
cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update
|
||||||
|
fi
|
||||||
|
|
||||||
|
ProgressEnd "Creating $runtime Package for $framework"
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageMacOS()
|
||||||
|
{
|
||||||
|
local framework="$1"
|
||||||
|
local runtime="$2"
|
||||||
|
|
||||||
|
ProgressStart "Creating $runtime Package for $framework"
|
||||||
|
|
||||||
|
local folder=$artifactsFolder/$runtime/$framework/Sonarr
|
||||||
|
|
||||||
|
PackageFiles "$folder" "$framework" "$runtime"
|
||||||
|
|
||||||
|
echo "Removing Service helpers"
|
||||||
|
rm -f $folder/ServiceUninstall.*
|
||||||
|
rm -f $folder/ServiceInstall.*
|
||||||
|
|
||||||
|
echo "Removing Sonarr.Windows"
|
||||||
|
rm $folder/Sonarr.Windows.*
|
||||||
|
|
||||||
|
echo "Adding Sonarr.Mono to UpdatePackage"
|
||||||
|
cp $folder/Sonarr.Mono.* $folder/Sonarr.Update
|
||||||
|
if [ "$framework" = "$framework" ]; then
|
||||||
|
cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update
|
||||||
|
cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update
|
||||||
|
fi
|
||||||
|
|
||||||
|
ProgressEnd "Creating $runtime Package for $framework"
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageMacOSApp()
|
||||||
|
{
|
||||||
|
local framework="$1"
|
||||||
|
local runtime="$2"
|
||||||
|
|
||||||
|
ProgressStart "Creating $runtime App Package for $framework"
|
||||||
|
|
||||||
|
local folder=$artifactsFolder/$runtime-app/$framework
|
||||||
|
|
||||||
|
rm -rf $folder
|
||||||
|
mkdir -p $folder
|
||||||
|
cp -r distribution/macOS/Sonarr.app $folder
|
||||||
|
mkdir -p $folder/Sonarr.app/Contents/MacOS
|
||||||
|
|
||||||
|
echo "Copying Binaries"
|
||||||
|
cp -r $artifactsFolder/$runtime/$framework/Sonarr/* $folder/Sonarr.app/Contents/MacOS
|
||||||
|
|
||||||
|
echo "Removing Update Folder"
|
||||||
|
rm -r $folder/Sonarr.app/Contents/MacOS/Sonarr.Update
|
||||||
|
|
||||||
|
ProgressEnd "Creating $runtime App Package for $framework"
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageWindows()
|
||||||
|
{
|
||||||
|
local framework="$1"
|
||||||
|
local runtime="$2"
|
||||||
|
|
||||||
|
ProgressStart "Creating Windows Package for $framework"
|
||||||
|
|
||||||
|
local folder=$artifactsFolder/$runtime/$framework/Sonarr
|
||||||
|
|
||||||
|
PackageFiles "$folder" "$framework" "$runtime"
|
||||||
|
cp -r $outputFolder/$framework-windows/$runtime/publish/* $folder
|
||||||
|
|
||||||
|
echo "Removing Sonarr.Mono"
|
||||||
|
rm -f $folder/Sonarr.Mono.*
|
||||||
|
rm -f $folder/Mono.Posix.NETStandard.*
|
||||||
|
rm -f $folder/libMonoPosixHelper.*
|
||||||
|
|
||||||
|
echo "Adding Sonarr.Windows to UpdatePackage"
|
||||||
|
cp $folder/Sonarr.Windows.* $folder/Sonarr.Update
|
||||||
|
|
||||||
|
ProgressEnd "Creating Windows Package for $framework"
|
||||||
|
}
|
||||||
|
|
||||||
|
Package()
|
||||||
|
{
|
||||||
|
local framework="$1"
|
||||||
|
local runtime="$2"
|
||||||
|
local SPLIT
|
||||||
|
|
||||||
|
IFS='-' read -ra SPLIT <<< "$runtime"
|
||||||
|
|
||||||
|
case "${SPLIT[0]}" in
|
||||||
|
linux|freebsd*)
|
||||||
|
PackageLinux "$framework" "$runtime"
|
||||||
|
;;
|
||||||
|
win)
|
||||||
|
PackageWindows "$framework" "$runtime"
|
||||||
|
;;
|
||||||
|
osx)
|
||||||
|
PackageMacOS "$framework" "$runtime"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageTests()
|
||||||
|
{
|
||||||
|
local framework="$1"
|
||||||
|
local runtime="$2"
|
||||||
|
|
||||||
|
ProgressStart "Creating $runtime Test Package for $framework"
|
||||||
|
|
||||||
|
cp test.sh "$testPackageFolder/$framework/$runtime/publish"
|
||||||
|
|
||||||
|
rm -f $testPackageFolder/$framework/$runtime/*.log.config
|
||||||
|
|
||||||
|
ProgressEnd "Creating $runtime Test Package for $framework"
|
||||||
|
}
|
||||||
|
|
||||||
|
UploadTestArtifacts()
|
||||||
|
{
|
||||||
|
local framework="$1"
|
||||||
|
|
||||||
|
ProgressStart 'Publishing Test Artifacts'
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
for dir in $testPackageFolder/$framework/*
|
||||||
|
do
|
||||||
|
local runtime=$(basename "$dir")
|
||||||
|
echo "##teamcity[publishArtifacts '$testPackageFolder/$framework/$runtime/publish/** => tests.$runtime.zip']"
|
||||||
|
done
|
||||||
|
|
||||||
|
ProgressEnd 'Publishing Test Artifacts'
|
||||||
|
}
|
||||||
|
|
||||||
|
UploadArtifacts()
|
||||||
|
{
|
||||||
|
local framework="$1"
|
||||||
|
|
||||||
|
ProgressStart 'Publishing Artifacts'
|
||||||
|
|
||||||
|
# Releases
|
||||||
|
for dir in $artifactsFolder/*
|
||||||
|
do
|
||||||
|
local runtime=$(basename "$dir")
|
||||||
|
|
||||||
|
echo "##teamcity[publishArtifacts '$artifactsFolder/$runtime/$framework/** => Sonarr.$BRANCH.$SONARR_VERSION.$runtime.zip']"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Debian Package / Windows installer / macOS app
|
||||||
|
echo "##teamcity[publishArtifacts 'distribution/** => distribution.zip']"
|
||||||
|
|
||||||
|
ProgressEnd 'Publishing Artifacts'
|
||||||
|
}
|
||||||
|
|
||||||
|
UploadUIArtifacts()
|
||||||
|
{
|
||||||
|
local framework="$1"
|
||||||
|
|
||||||
|
ProgressStart 'Publishing UI Artifacts'
|
||||||
|
|
||||||
|
# UI folder
|
||||||
|
echo "##teamcity[publishArtifacts '$outputFolder/UI/** => UI.zip']"
|
||||||
|
|
||||||
|
ProgressEnd 'Publishing UI Artifacts'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use mono or .net depending on OS
|
||||||
|
case "$(uname -s)" in
|
||||||
|
CYGWIN*|MINGW32*|MINGW64*|MSYS*)
|
||||||
|
# on windows, use dotnet
|
||||||
|
os="windows"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# otherwise use mono
|
||||||
|
os="posix"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
POSITIONAL=()
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo "No arguments provided, building everything"
|
||||||
|
BACKEND=YES
|
||||||
|
FRONTEND=YES
|
||||||
|
PACKAGES=YES
|
||||||
|
LINT=YES
|
||||||
|
ENABLE_EXTRA_PLATFORMS=NO
|
||||||
|
ENABLE_EXTRA_PLATFORMS_IN_SDK=NO
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]
|
||||||
|
do
|
||||||
|
key="$1"
|
||||||
|
|
||||||
|
case $key in
|
||||||
|
--backend)
|
||||||
|
BACKEND=YES
|
||||||
|
shift # past argument
|
||||||
|
;;
|
||||||
|
--enable-bsd|--enable-extra-platforms)
|
||||||
|
ENABLE_EXTRA_PLATFORMS=YES
|
||||||
|
shift # past argument
|
||||||
|
;;
|
||||||
|
--enable-extra-platforms-in-sdk)
|
||||||
|
ENABLE_EXTRA_PLATFORMS_IN_SDK=YES
|
||||||
|
shift # past argument
|
||||||
|
;;
|
||||||
|
-r|--runtime)
|
||||||
|
RID="$2"
|
||||||
|
shift # past argument
|
||||||
|
shift # past value
|
||||||
|
;;
|
||||||
|
-f|--framework)
|
||||||
|
FRAMEWORK="$2"
|
||||||
|
shift # past argument
|
||||||
|
shift # past value
|
||||||
|
;;
|
||||||
|
--frontend)
|
||||||
|
FRONTEND=YES
|
||||||
|
shift # past argument
|
||||||
|
;;
|
||||||
|
--packages)
|
||||||
|
PACKAGES=YES
|
||||||
|
shift # past argument
|
||||||
|
;;
|
||||||
|
--lint)
|
||||||
|
LINT=YES
|
||||||
|
shift # past argument
|
||||||
|
;;
|
||||||
|
--all)
|
||||||
|
BACKEND=YES
|
||||||
|
FRONTEND=YES
|
||||||
|
PACKAGES=YES
|
||||||
|
LINT=YES
|
||||||
|
shift # past argument
|
||||||
|
;;
|
||||||
|
*) # unknown option
|
||||||
|
POSITIONAL+=("$1") # save it in an array for later
|
||||||
|
shift # past argument
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
set -- "${POSITIONAL[@]}" # restore positional parameters
|
||||||
|
|
||||||
|
if [ "$ENABLE_EXTRA_PLATFORMS_IN_SDK" = "YES" ];
|
||||||
|
then
|
||||||
|
EnableExtraPlatformsInSDK
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$BACKEND" = "YES" ];
|
||||||
|
then
|
||||||
|
UpdateVersionNumber
|
||||||
|
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ];
|
||||||
|
then
|
||||||
|
EnableExtraPlatforms
|
||||||
|
fi
|
||||||
|
|
||||||
|
Build
|
||||||
|
|
||||||
|
if [[ -z "$RID" || -z "$FRAMEWORK" ]];
|
||||||
|
then
|
||||||
|
PackageTests "$framework" "win-x64"
|
||||||
|
PackageTests "$framework" "win-x86"
|
||||||
|
PackageTests "$framework" "linux-x64"
|
||||||
|
PackageTests "$framework" "linux-musl-x64"
|
||||||
|
PackageTests "$framework" "osx-x64"
|
||||||
|
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ];
|
||||||
|
then
|
||||||
|
PackageTests "$framework" "freebsd-x64"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
PackageTests "$FRAMEWORK" "$RID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
UploadTestArtifacts "$framework"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$FRONTEND" = "YES" ];
|
||||||
|
then
|
||||||
|
YarnInstall
|
||||||
|
|
||||||
|
if [ "$LINT" = "YES" ];
|
||||||
|
then
|
||||||
|
LintUI
|
||||||
|
fi
|
||||||
|
|
||||||
|
RunWebpack
|
||||||
|
UploadUIArtifacts
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$PACKAGES" = "YES" ];
|
||||||
|
then
|
||||||
|
UpdateVersionNumber
|
||||||
|
|
||||||
|
if [[ -z "$RID" || -z "$FRAMEWORK" ]];
|
||||||
|
then
|
||||||
|
Package "$framework" "win-x64"
|
||||||
|
Package "$framework" "win-x86"
|
||||||
|
Package "$framework" "linux-x64"
|
||||||
|
Package "$framework" "linux-musl-x64"
|
||||||
|
Package "$framework" "linux-arm64"
|
||||||
|
Package "$framework" "linux-musl-arm64"
|
||||||
|
Package "$framework" "linux-arm"
|
||||||
|
Package "$framework" "osx-x64"
|
||||||
|
Package "$framework" "osx-arm64"
|
||||||
|
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ];
|
||||||
|
then
|
||||||
|
Package "$framework" "freebsd-x64"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
Package "$FRAMEWORK" "$RID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
UploadArtifacts "$framework"
|
||||||
|
fi
|
||||||
113
distribution/debian/install.sh
Executable file → Normal file
113
distribution/debian/install.sh
Executable file → Normal file
@@ -6,8 +6,6 @@
|
|||||||
### Version V1.0.1 2024-01-02 - StevieTV - remove UTF8-BOM
|
### 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.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
|
### Version V1.0.3 2024-01-06 - StevieTV - exit script when it is ran from install directory
|
||||||
### Version V1.0.4 2025-04-05 - kaecyra - Allow user/group to be supplied via CLI, add unattended mode
|
|
||||||
### Version V1.0.5 2025-07-08 - bparkin1283 - use systemctl instead of service for stopping app
|
|
||||||
|
|
||||||
### Boilerplate Warning
|
### Boilerplate Warning
|
||||||
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
@@ -18,8 +16,8 @@
|
|||||||
#OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
#OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
#WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
#WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
scriptversion="1.0.4"
|
scriptversion="1.0.3"
|
||||||
scriptdate="2025-04-05"
|
scriptdate="2024-01-06"
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -51,106 +49,18 @@ if [ "$installdir" == "$(dirname -- "$( readlink -f -- "$0"; )")" ] || [ "$bindi
|
|||||||
exit
|
exit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
show_help() {
|
# Prompt User
|
||||||
cat <<EOF
|
read -r -p "What user should ${app^} run as? (Default: $app): " app_uid < /dev/tty
|
||||||
Usage: $(basename "$0") [OPTIONS]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--user <name> What user will $app run under?
|
|
||||||
User will be created if it doesn't already exist.
|
|
||||||
|
|
||||||
--group <name> What group will $app run under?
|
|
||||||
Group will be created if it doesn't already exist.
|
|
||||||
|
|
||||||
-u Unattended mode
|
|
||||||
The installer will not prompt or pause, making it suitable for automated installations.
|
|
||||||
This option requires the use of --user and --group to supply those inputs for the script.
|
|
||||||
|
|
||||||
-h, --help Show this help message and exit
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
# Default values for command-line arguments
|
|
||||||
arg_user=""
|
|
||||||
arg_group=""
|
|
||||||
arg_unattended=false
|
|
||||||
|
|
||||||
# Parse command-line arguments
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--user=*)
|
|
||||||
arg_user="${1#*=}"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--user)
|
|
||||||
if [[ -n "$2" && "$2" != -* ]]; then
|
|
||||||
arg_user="$2"
|
|
||||||
shift 2
|
|
||||||
else
|
|
||||||
echo "Error: --user requires a value." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
--group=*)
|
|
||||||
arg_group="${1#*=}"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--group)
|
|
||||||
if [[ -n "$2" && "$2" != -* ]]; then
|
|
||||||
arg_group="$2"
|
|
||||||
shift 2
|
|
||||||
else
|
|
||||||
echo "Error: --group requires a value." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
-u)
|
|
||||||
arg_unattended=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-h|--help)
|
|
||||||
show_help
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown option: $1" >&2
|
|
||||||
echo "Use --help to see valid options." >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# If unattended mode is requested, require user and group
|
|
||||||
if $arg_unattended; then
|
|
||||||
if [[ -z "$arg_user" || -z "$arg_group" ]]; then
|
|
||||||
echo "Error: --user and --group are required when using -u (unattended mode)." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Prompt User if necessary
|
|
||||||
if [ -n "$arg_user" ]; then
|
|
||||||
app_uid="$arg_user"
|
|
||||||
else
|
|
||||||
read -r -p "What user should ${app^} run as? (Default: $app): " app_uid < /dev/tty
|
|
||||||
fi
|
|
||||||
app_uid=$(echo "$app_uid" | tr -d ' ')
|
app_uid=$(echo "$app_uid" | tr -d ' ')
|
||||||
app_uid=${app_uid:-$app}
|
app_uid=${app_uid:-$app}
|
||||||
|
# Prompt Group
|
||||||
# Prompt Group if necessary
|
read -r -p "What group should ${app^} run as? (Default: media): " app_guid < /dev/tty
|
||||||
if [ -n "$arg_group" ]; then
|
|
||||||
app_guid="$arg_group"
|
|
||||||
else
|
|
||||||
read -r -p "What group should ${app^} run as? (Default: media): " app_guid < /dev/tty
|
|
||||||
fi
|
|
||||||
app_guid=$(echo "$app_guid" | tr -d ' ')
|
app_guid=$(echo "$app_guid" | tr -d ' ')
|
||||||
app_guid=${app_guid:-media}
|
app_guid=${app_guid:-media}
|
||||||
|
|
||||||
echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory"
|
echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory"
|
||||||
echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that the selected user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories"
|
echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that the selected user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories"
|
||||||
if ! $arg_unattended; then
|
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
|
||||||
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create User / Group as needed
|
# Create User / Group as needed
|
||||||
if [ "$app_guid" != "$app_uid" ]; then
|
if [ "$app_guid" != "$app_uid" ]; then
|
||||||
@@ -168,10 +78,11 @@ if ! getent group "$app_guid" | grep -qw "$app_uid"; then
|
|||||||
echo "Added User [$app_uid] to Group [$app_guid]"
|
echo "Added User [$app_uid] to Group [$app_guid]"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Stop and disable the App if running
|
# Stop the App if running
|
||||||
if [ $(systemctl is-active "$app") = "active" ]; then
|
if service --status-all | grep -Fq "$app"; then
|
||||||
systemctl disable --now -q "$app"
|
systemctl stop "$app"
|
||||||
echo "Stopped and disabled existing $app"
|
systemctl disable "$app".service
|
||||||
|
echo "Stopped existing $app"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create Appdata Directory
|
# Create Appdata Directory
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@REM SET SONARR_MAJOR_VERSION=4
|
@REM SET SONARR_MAJOR_VERSION=4
|
||||||
@REM SET SONARR_VERSION=4.0.0.5
|
@REM SET SONARR_VERSION=4.0.0.5
|
||||||
@REM SET BRANCH=develop
|
@REM SET BRANCH=develop
|
||||||
@REM SET FRAMEWORK=net8.0
|
@REM SET FRAMEWORK=net6.0
|
||||||
@REM SET RUNTIME=win-x64
|
@REM SET RUNTIME=win-x64
|
||||||
|
|
||||||
inno\ISCC.exe sonarr.iss
|
inno\ISCC.exe sonarr.iss
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ cd /data/test
|
|||||||
|
|
||||||
runTest()
|
runTest()
|
||||||
{
|
{
|
||||||
bash scripts/test.sh Linux $1
|
bash test.sh Linux $1
|
||||||
cp TestResult.xml /data/_tests_results/TestResult_$1.xml
|
cp TestResult.xml /data/_tests_results/TestResult_$1.xml
|
||||||
}
|
}
|
||||||
|
|
||||||
runTest Integration
|
runTest Integration
|
||||||
runTest Unit
|
runTest Unit
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
FRAMEWORK="net8.0"
|
FRAMEWORK="net6.0"
|
||||||
PLATFORM=$1
|
PLATFORM=$1
|
||||||
ARCHITECTURE="${2:-x64}"
|
|
||||||
|
|
||||||
if [ "$PLATFORM" = "Windows" ]; then
|
if [ "$PLATFORM" = "Windows" ]; then
|
||||||
RUNTIME="win-$ARCHITECTURE"
|
RUNTIME="win-x64"
|
||||||
elif [ "$PLATFORM" = "Linux" ]; then
|
elif [ "$PLATFORM" = "Linux" ]; then
|
||||||
RUNTIME="linux-$ARCHITECTURE"
|
RUNTIME="linux-x64"
|
||||||
elif [ "$PLATFORM" = "Mac" ]; then
|
elif [ "$PLATFORM" = "Mac" ]; then
|
||||||
RUNTIME="osx-$ARCHITECTURE"
|
RUNTIME="osx-x64"
|
||||||
else
|
else
|
||||||
echo "Platform must be provided as first argument: Windows, Linux or Mac"
|
echo "Platform must be provided as first argument: Windows, Linux or Mac"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -23,7 +22,7 @@ rm -rf $outputFolder
|
|||||||
rm -rf $testPackageFolder
|
rm -rf $testPackageFolder
|
||||||
|
|
||||||
slnFile=src/Sonarr.sln
|
slnFile=src/Sonarr.sln
|
||||||
outputFile=src/Sonarr.Api.V5/openapi.json
|
|
||||||
platform=Posix
|
platform=Posix
|
||||||
|
|
||||||
if [ "$PLATFORM" = "Windows" ]; then
|
if [ "$PLATFORM" = "Windows" ]; then
|
||||||
@@ -38,20 +37,12 @@ dotnet clean $slnFile -c Release
|
|||||||
dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids
|
dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids
|
||||||
|
|
||||||
dotnet new tool-manifest
|
dotnet new tool-manifest
|
||||||
dotnet tool install --version 8.0.0 Swashbuckle.AspNetCore.Cli
|
dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli
|
||||||
|
|
||||||
# Remove the openapi.json file so we can check if it was created
|
dotnet tool run swagger tofile --output ./src/Sonarr.Api.V3/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v3 &
|
||||||
rm $outputFile
|
|
||||||
|
|
||||||
dotnet tool run swagger tofile --output ./src/Sonarr.Api.V5/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v5 &
|
|
||||||
|
|
||||||
sleep 45
|
sleep 45
|
||||||
|
|
||||||
kill %1
|
kill %1
|
||||||
|
|
||||||
if [ ! -f $outputFile ]; then
|
|
||||||
echo "$outputFile not found, check logs for errors"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
@@ -14,6 +14,7 @@ module.exports = (env) => {
|
|||||||
const srcFolder = path.join(frontendFolder, 'src');
|
const srcFolder = path.join(frontendFolder, 'src');
|
||||||
const isProduction = !!env.production;
|
const isProduction = !!env.production;
|
||||||
const isProfiling = isProduction && !!env.profile;
|
const isProfiling = isProduction && !!env.profile;
|
||||||
|
const inlineWebWorkers = 'no-fallback';
|
||||||
|
|
||||||
const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder);
|
const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder);
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ module.exports = (env) => {
|
|||||||
|
|
||||||
output: {
|
output: {
|
||||||
path: distFolder,
|
path: distFolder,
|
||||||
publicPath: 'auto',
|
publicPath: '/',
|
||||||
filename: isProduction ? '[name]-[contenthash].js' : '[name].js',
|
filename: isProduction ? '[name]-[contenthash].js' : '[name].js',
|
||||||
sourceMapFilename: '[file].map'
|
sourceMapFilename: '[file].map'
|
||||||
},
|
},
|
||||||
@@ -159,6 +160,16 @@ module.exports = (env) => {
|
|||||||
|
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.worker\.js$/,
|
||||||
|
use: {
|
||||||
|
loader: 'worker-loader',
|
||||||
|
options: {
|
||||||
|
filename: '[name].js',
|
||||||
|
inline: inlineWebWorkers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
test: [/\.jsx?$/, /\.tsx?$/],
|
test: [/\.jsx?$/, /\.tsx?$/],
|
||||||
exclude: /(node_modules|JsLibraries)/,
|
exclude: /(node_modules|JsLibraries)/,
|
||||||
@@ -176,7 +187,7 @@ module.exports = (env) => {
|
|||||||
loose: true,
|
loose: true,
|
||||||
debug: false,
|
debug: false,
|
||||||
useBuiltIns: 'entry',
|
useBuiltIns: 'entry',
|
||||||
corejs: '3.42'
|
corejs: '3.39'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ function Blocklist() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleFilterSelect = useCallback(
|
const handleFilterSelect = useCallback(
|
||||||
(selectedFilterKey: string | number) => {
|
(selectedFilterKey: string) => {
|
||||||
dispatch(setBlocklistFilter({ selectedFilterKey }));
|
dispatch(setBlocklistFilter({ selectedFilterKey }));
|
||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
|
|||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import AppState from 'App/State/AppState';
|
import AppState from 'App/State/AppState';
|
||||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
import FilterModal from 'Components/Filter/FilterModal';
|
||||||
import { setBlocklistFilter } from 'Store/Actions/blocklistActions';
|
import { setBlocklistFilter } from 'Store/Actions/blocklistActions';
|
||||||
|
|
||||||
function createBlocklistSelector() {
|
function createBlocklistSelector() {
|
||||||
@@ -23,7 +23,9 @@ function createFilterBuilderPropsSelector() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type BlocklistFilterModalProps = FilterModalProps<History>;
|
interface BlocklistFilterModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
|
export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
|
||||||
const sectionItems = useSelector(createBlocklistSelector());
|
const sectionItems = useSelector(createBlocklistSelector());
|
||||||
@@ -41,6 +43,7 @@ export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FilterModal
|
<FilterModal
|
||||||
|
// TODO: Don't spread all the props
|
||||||
{...props}
|
{...props}
|
||||||
sectionItems={sectionItems}
|
sectionItems={sectionItems}
|
||||||
filterBuilderProps={filterBuilderProps}
|
filterBuilderProps={filterBuilderProps}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
} from 'typings/History';
|
} from 'typings/History';
|
||||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||||
import formatAge from 'Utilities/Number/formatAge';
|
import formatAge from 'Utilities/Number/formatAge';
|
||||||
import formatBytes from 'Utilities/Number/formatBytes';
|
|
||||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import styles from './HistoryDetails.css';
|
import styles from './HistoryDetails.css';
|
||||||
@@ -51,7 +50,6 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
|||||||
ageHours,
|
ageHours,
|
||||||
ageMinutes,
|
ageMinutes,
|
||||||
publishedDate,
|
publishedDate,
|
||||||
size,
|
|
||||||
} = data as GrabbedHistoryData;
|
} = data as GrabbedHistoryData;
|
||||||
|
|
||||||
const downloadClientNameInfo = downloadClientName ?? downloadClient;
|
const downloadClientNameInfo = downloadClientName ?? downloadClient;
|
||||||
@@ -162,19 +160,12 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{size ? (
|
|
||||||
<DescriptionListItem
|
|
||||||
title={translate('Size')}
|
|
||||||
data={formatBytes(size)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</DescriptionList>
|
</DescriptionList>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventType === 'downloadFailed') {
|
if (eventType === 'downloadFailed') {
|
||||||
const { message, indexer } = data as DownloadFailedHistory;
|
const { message } = data as DownloadFailedHistory;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DescriptionList>
|
<DescriptionList>
|
||||||
@@ -188,10 +179,6 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
|||||||
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
|
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{indexer ? (
|
|
||||||
<DescriptionListItem title={translate('Indexer')} data={indexer} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{message ? (
|
{message ? (
|
||||||
<DescriptionListItem title={translate('Message')} data={message} />
|
<DescriptionListItem title={translate('Message')} data={message} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -200,7 +187,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (eventType === 'downloadFolderImported') {
|
if (eventType === 'downloadFolderImported') {
|
||||||
const { customFormatScore, droppedPath, importedPath, size } =
|
const { customFormatScore, droppedPath, importedPath } =
|
||||||
data as DownloadFolderImportedHistory;
|
data as DownloadFolderImportedHistory;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -233,20 +220,12 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
|||||||
data={formatCustomFormatScore(parseInt(customFormatScore))}
|
data={formatCustomFormatScore(parseInt(customFormatScore))}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{size ? (
|
|
||||||
<DescriptionListItem
|
|
||||||
title={translate('FileSize')}
|
|
||||||
data={formatBytes(size)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</DescriptionList>
|
</DescriptionList>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventType === 'episodeFileDeleted') {
|
if (eventType === 'episodeFileDeleted') {
|
||||||
const { reason, customFormatScore, size } =
|
const { reason, customFormatScore } = data as EpisodeFileDeletedHistory;
|
||||||
data as EpisodeFileDeletedHistory;
|
|
||||||
|
|
||||||
let reasonMessage = '';
|
let reasonMessage = '';
|
||||||
|
|
||||||
@@ -276,13 +255,6 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
|||||||
data={formatCustomFormatScore(parseInt(customFormatScore))}
|
data={formatCustomFormatScore(parseInt(customFormatScore))}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{size ? (
|
|
||||||
<DescriptionListItem
|
|
||||||
title={translate('FileSize')}
|
|
||||||
data={formatBytes(size)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</DescriptionList>
|
</DescriptionList>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ function History() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleFilterSelect = useCallback(
|
const handleFilterSelect = useCallback(
|
||||||
(selectedFilterKey: string | number) => {
|
(selectedFilterKey: string) => {
|
||||||
dispatch(setHistoryFilter({ selectedFilterKey }));
|
dispatch(setHistoryFilter({ selectedFilterKey }));
|
||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
|
|||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import AppState from 'App/State/AppState';
|
import AppState from 'App/State/AppState';
|
||||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
import FilterModal from 'Components/Filter/FilterModal';
|
||||||
import { setHistoryFilter } from 'Store/Actions/historyActions';
|
import { setHistoryFilter } from 'Store/Actions/historyActions';
|
||||||
|
|
||||||
function createHistorySelector() {
|
function createHistorySelector() {
|
||||||
@@ -23,16 +23,19 @@ function createFilterBuilderPropsSelector() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type HistoryFilterModalProps = FilterModalProps<History>;
|
interface HistoryFilterModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default function HistoryFilterModal(props: HistoryFilterModalProps) {
|
export default function HistoryFilterModal(props: HistoryFilterModalProps) {
|
||||||
const sectionItems = useSelector(createHistorySelector());
|
const sectionItems = useSelector(createHistorySelector());
|
||||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
||||||
|
const customFilterType = 'history';
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const dispatchSetFilter = useCallback(
|
const dispatchSetFilter = useCallback(
|
||||||
(payload: { selectedFilterKey: string | number }) => {
|
(payload: unknown) => {
|
||||||
dispatch(setHistoryFilter(payload));
|
dispatch(setHistoryFilter(payload));
|
||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch]
|
||||||
@@ -40,10 +43,11 @@ export default function HistoryFilterModal(props: HistoryFilterModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FilterModal
|
<FilterModal
|
||||||
|
// TODO: Don't spread all the props
|
||||||
{...props}
|
{...props}
|
||||||
sectionItems={sectionItems}
|
sectionItems={sectionItems}
|
||||||
filterBuilderProps={filterBuilderProps}
|
filterBuilderProps={filterBuilderProps}
|
||||||
customFilterType="history"
|
customFilterType={customFilterType}
|
||||||
dispatchSetFilter={dispatchSetFilter}
|
dispatchSetFilter={dispatchSetFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
|
|
||||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
|
||||||
import Queue from 'typings/Queue';
|
|
||||||
|
|
||||||
interface EpisodeDetails {
|
|
||||||
episodeIds: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SeriesDetails {
|
|
||||||
seriesId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AllDetails {
|
|
||||||
all: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type QueueDetailsFilter = AllDetails | EpisodeDetails | SeriesDetails;
|
|
||||||
|
|
||||||
interface QueueDetailsProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QueueDetailsContext = createContext<Queue[] | undefined>(undefined);
|
|
||||||
|
|
||||||
export default function QueueDetailsProvider({
|
|
||||||
children,
|
|
||||||
...filter
|
|
||||||
}: QueueDetailsProps & QueueDetailsFilter) {
|
|
||||||
const { data } = useApiQuery<Queue[]>({
|
|
||||||
path: '/queue/details',
|
|
||||||
queryParams: { ...filter },
|
|
||||||
queryOptions: {
|
|
||||||
enabled: Object.keys(filter).length > 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<QueueDetailsContext.Provider value={data}>
|
|
||||||
{children}
|
|
||||||
</QueueDetailsContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useQueueItemForEpisode(episodeId: number) {
|
|
||||||
const queue = useContext(QueueDetailsContext);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
return queue?.find((item) => item.episodeIds.includes(episodeId));
|
|
||||||
}, [episodeId, queue]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useIsDownloadingEpisodes(episodeIds: number[]) {
|
|
||||||
const queue = useContext(QueueDetailsContext);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
if (!queue) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return queue.some((item) =>
|
|
||||||
item.episodeIds?.some((e) => episodeIds.includes(e))
|
|
||||||
);
|
|
||||||
}, [episodeIds, queue]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SeriesQueueDetails {
|
|
||||||
count: number;
|
|
||||||
episodesWithFiles: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useQueueDetailsForSeries(
|
|
||||||
seriesId: number,
|
|
||||||
seasonNumber?: number
|
|
||||||
) {
|
|
||||||
const queue = useContext(QueueDetailsContext);
|
|
||||||
|
|
||||||
return useMemo<SeriesQueueDetails>(() => {
|
|
||||||
if (!queue) {
|
|
||||||
return { count: 0, episodesWithFiles: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
return queue.reduce<SeriesQueueDetails>(
|
|
||||||
(acc: SeriesQueueDetails, item) => {
|
|
||||||
if (
|
|
||||||
item.trackedDownloadState === 'imported' ||
|
|
||||||
item.seriesId !== seriesId
|
|
||||||
) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seasonNumber != null && item.seasonNumber !== seasonNumber) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.count++;
|
|
||||||
|
|
||||||
if (item.episodeHasFile) {
|
|
||||||
acc.episodesWithFiles++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
count: 0,
|
|
||||||
episodesWithFiles: 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [seriesId, seasonNumber, queue]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useQueueDetails = () => {
|
|
||||||
return useContext(QueueDetailsContext) ?? [];
|
|
||||||
};
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Episode from 'Episode/Episode';
|
|
||||||
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
|
|
||||||
import Series from 'Series/Series';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
|
|
||||||
interface EpisodeCellContentProps {
|
|
||||||
episodes: Episode[];
|
|
||||||
isFullSeason: boolean;
|
|
||||||
seasonNumber?: number;
|
|
||||||
series?: Series;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EpisodeCellContent({
|
|
||||||
episodes,
|
|
||||||
isFullSeason,
|
|
||||||
seasonNumber,
|
|
||||||
series,
|
|
||||||
}: EpisodeCellContentProps) {
|
|
||||||
if (episodes.length === 0) {
|
|
||||||
return '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFullSeason && seasonNumber != null) {
|
|
||||||
return translate('SeasonNumberToken', { seasonNumber });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (episodes.length === 1) {
|
|
||||||
const episode = episodes[0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SeasonEpisodeNumber
|
|
||||||
seasonNumber={episode.seasonNumber}
|
|
||||||
episodeNumber={episode.episodeNumber}
|
|
||||||
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
|
|
||||||
seriesType={series?.seriesType}
|
|
||||||
alternateTitles={series?.alternateTitles}
|
|
||||||
sceneSeasonNumber={episode.sceneSeasonNumber}
|
|
||||||
sceneEpisodeNumber={episode.sceneEpisodeNumber}
|
|
||||||
sceneAbsoluteEpisodeNumber={episode.sceneAbsoluteEpisodeNumber}
|
|
||||||
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstEpisode = episodes[0];
|
|
||||||
const lastEpisode = episodes[episodes.length - 1];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SeasonEpisodeNumber
|
|
||||||
seasonNumber={firstEpisode.seasonNumber}
|
|
||||||
episodeNumber={firstEpisode.episodeNumber}
|
|
||||||
absoluteEpisodeNumber={firstEpisode.absoluteEpisodeNumber}
|
|
||||||
seriesType={series?.seriesType}
|
|
||||||
alternateTitles={series?.alternateTitles}
|
|
||||||
sceneSeasonNumber={firstEpisode.sceneSeasonNumber}
|
|
||||||
sceneEpisodeNumber={firstEpisode.sceneEpisodeNumber}
|
|
||||||
sceneAbsoluteEpisodeNumber={firstEpisode.sceneAbsoluteEpisodeNumber}
|
|
||||||
unverifiedSceneNumbering={firstEpisode.unverifiedSceneNumbering}
|
|
||||||
/>
|
|
||||||
{' - '}
|
|
||||||
<SeasonEpisodeNumber
|
|
||||||
seasonNumber={lastEpisode.seasonNumber}
|
|
||||||
episodeNumber={lastEpisode.episodeNumber}
|
|
||||||
absoluteEpisodeNumber={lastEpisode.absoluteEpisodeNumber}
|
|
||||||
seriesType={series?.seriesType}
|
|
||||||
alternateTitles={series?.alternateTitles}
|
|
||||||
sceneSeasonNumber={lastEpisode.sceneSeasonNumber}
|
|
||||||
sceneEpisodeNumber={lastEpisode.sceneEpisodeNumber}
|
|
||||||
sceneAbsoluteEpisodeNumber={lastEpisode.sceneAbsoluteEpisodeNumber}
|
|
||||||
unverifiedSceneNumbering={lastEpisode.unverifiedSceneNumbering}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
.multiple {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.episodeNumber {
|
|
||||||
margin-right: 8px;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Popover from 'Components/Tooltip/Popover';
|
|
||||||
import Episode from 'Episode/Episode';
|
|
||||||
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
|
||||||
import Series from 'Series/Series';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import styles from './EpisodeTitleCellContent.css';
|
|
||||||
|
|
||||||
interface EpisodeTitleCellContentProps {
|
|
||||||
episodes: Episode[];
|
|
||||||
series?: Series;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EpisodeTitleCellContent({
|
|
||||||
episodes,
|
|
||||||
series,
|
|
||||||
}: EpisodeTitleCellContentProps) {
|
|
||||||
if (episodes.length === 0 || !series) {
|
|
||||||
return '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (episodes.length === 1) {
|
|
||||||
const episode = episodes[0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EpisodeTitleLink
|
|
||||||
episodeId={episode.id}
|
|
||||||
seriesId={series.id}
|
|
||||||
episodeTitle={episode.title}
|
|
||||||
episodeEntity="episodes"
|
|
||||||
showOpenSeriesButton={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
anchor={
|
|
||||||
<span className={styles.multiple}>{translate('MultipleEpisodes')}</span>
|
|
||||||
}
|
|
||||||
title={translate('EpisodeTitles')}
|
|
||||||
body={
|
|
||||||
<>
|
|
||||||
{episodes.map((episode) => {
|
|
||||||
return (
|
|
||||||
<div key={episode.id} className={styles.row}>
|
|
||||||
<div className={styles.episodeNumber}>
|
|
||||||
{episode.episodeNumber}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EpisodeTitleLink
|
|
||||||
episodeId={episode.id}
|
|
||||||
seriesId={series.id}
|
|
||||||
episodeTitle={episode.title}
|
|
||||||
episodeEntity="episodes"
|
|
||||||
showOpenSeriesButton={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
position="right"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
import * as commandNames from 'Commands/commandNames';
|
import * as commandNames from 'Commands/commandNames';
|
||||||
import Alert from 'Components/Alert';
|
import Alert from 'Components/Alert';
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
@@ -21,15 +22,28 @@ import Table from 'Components/Table/Table';
|
|||||||
import TableBody from 'Components/Table/TableBody';
|
import TableBody from 'Components/Table/TableBody';
|
||||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||||
import TablePager from 'Components/Table/TablePager';
|
import TablePager from 'Components/Table/TablePager';
|
||||||
|
import usePaging from 'Components/Table/usePaging';
|
||||||
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
|
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
|
||||||
|
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
||||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||||
import { align, icons, kinds } from 'Helpers/Props';
|
import { align, icons, kinds } from 'Helpers/Props';
|
||||||
import { executeCommand } from 'Store/Actions/commandActions';
|
import { executeCommand } from 'Store/Actions/commandActions';
|
||||||
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
|
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
|
||||||
|
import {
|
||||||
|
clearQueue,
|
||||||
|
fetchQueue,
|
||||||
|
gotoQueuePage,
|
||||||
|
grabQueueItems,
|
||||||
|
removeQueueItems,
|
||||||
|
setQueueFilter,
|
||||||
|
setQueueSort,
|
||||||
|
setQueueTableOption,
|
||||||
|
} from 'Store/Actions/queueActions';
|
||||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||||
import { CheckInputChanged } from 'typings/inputs';
|
import { CheckInputChanged } from 'typings/inputs';
|
||||||
import { SelectStateInputProps } from 'typings/props';
|
import { SelectStateInputProps } from 'typings/props';
|
||||||
|
import QueueItem from 'typings/Queue';
|
||||||
import { TableOptionsChangePayload } from 'typings/Table';
|
import { TableOptionsChangePayload } from 'typings/Table';
|
||||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||||
import {
|
import {
|
||||||
@@ -40,51 +54,33 @@ import translate from 'Utilities/String/translate';
|
|||||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||||
import QueueFilterModal from './QueueFilterModal';
|
import QueueFilterModal from './QueueFilterModal';
|
||||||
import QueueOptions from './QueueOptions';
|
import QueueOptions from './QueueOptions';
|
||||||
import {
|
|
||||||
setQueueOption,
|
|
||||||
setQueueOptions,
|
|
||||||
useQueueOptions,
|
|
||||||
} from './queueOptionsStore';
|
|
||||||
import QueueRow from './QueueRow';
|
import QueueRow from './QueueRow';
|
||||||
import RemoveQueueItemModal from './RemoveQueueItemModal';
|
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
|
||||||
import useQueueStatus from './Status/useQueueStatus';
|
import createQueueStatusSelector from './Status/createQueueStatusSelector';
|
||||||
import useQueue, {
|
|
||||||
useFilters,
|
|
||||||
useGrabQueueItems,
|
|
||||||
useRemoveQueueItems,
|
|
||||||
} from './useQueue';
|
|
||||||
|
|
||||||
const DEFAULT_DATA = {
|
|
||||||
records: [],
|
|
||||||
totalPages: 0,
|
|
||||||
totalRecords: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
function Queue() {
|
function Queue() {
|
||||||
|
const requestCurrentPage = useCurrentPage();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
|
||||||
error,
|
|
||||||
isFetching,
|
isFetching,
|
||||||
isFetched,
|
isPopulated,
|
||||||
isLoading,
|
error,
|
||||||
|
items,
|
||||||
|
columns,
|
||||||
|
selectedFilterKey,
|
||||||
|
filters,
|
||||||
|
sortKey,
|
||||||
|
sortDirection,
|
||||||
page,
|
page,
|
||||||
goToPage,
|
pageSize,
|
||||||
refetch,
|
totalPages,
|
||||||
} = useQueue();
|
totalRecords,
|
||||||
|
isGrabbing,
|
||||||
|
isRemoving,
|
||||||
|
} = useSelector((state: AppState) => state.queue.paged);
|
||||||
|
|
||||||
const { records, totalPages = 0, totalRecords } = data ?? DEFAULT_DATA;
|
const { count } = useSelector(createQueueStatusSelector());
|
||||||
|
|
||||||
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
|
||||||
useQueueOptions();
|
|
||||||
|
|
||||||
const filters = useFilters();
|
|
||||||
|
|
||||||
const { isRemoving, removeQueueItems } = useRemoveQueueItems();
|
|
||||||
const { isGrabbing, grabQueueItems } = useGrabQueueItems();
|
|
||||||
|
|
||||||
const { count } = useQueueStatus();
|
|
||||||
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
|
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
|
||||||
useSelector(createEpisodesFetchingSelector());
|
useSelector(createEpisodesFetchingSelector());
|
||||||
const customFilters = useSelector(createCustomFiltersSelector('queue'));
|
const customFilters = useSelector(createCustomFiltersSelector('queue'));
|
||||||
@@ -104,46 +100,41 @@ function Queue() {
|
|||||||
}, [selectedState]);
|
}, [selectedState]);
|
||||||
|
|
||||||
const isPendingSelected = useMemo(() => {
|
const isPendingSelected = useMemo(() => {
|
||||||
return records.some((item) => {
|
return items.some((item) => {
|
||||||
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
|
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
|
||||||
});
|
});
|
||||||
}, [records, selectedIds]);
|
}, [items, selectedIds]);
|
||||||
|
|
||||||
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
|
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
const isRefreshing =
|
const isRefreshing =
|
||||||
isLoading || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
|
isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
|
||||||
const isAllPopulated =
|
const isAllPopulated =
|
||||||
isFetched &&
|
isPopulated &&
|
||||||
(isEpisodesPopulated ||
|
(isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId));
|
||||||
!records.length ||
|
|
||||||
records.every((e) => !e.episodeIds?.length));
|
|
||||||
const hasError = error || episodesError;
|
const hasError = error || episodesError;
|
||||||
const selectedCount = selectedIds.length;
|
const selectedCount = selectedIds.length;
|
||||||
const disableSelectedActions = selectedCount === 0;
|
const disableSelectedActions = selectedCount === 0;
|
||||||
|
|
||||||
const handleSelectAllChange = useCallback(
|
const handleSelectAllChange = useCallback(
|
||||||
({ value }: CheckInputChanged) => {
|
({ value }: CheckInputChanged) => {
|
||||||
setSelectState({
|
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
|
||||||
type: value ? 'selectAll' : 'unselectAll',
|
|
||||||
items: records,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[records, setSelectState]
|
[items, setSelectState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectedChange = useCallback(
|
const handleSelectedChange = useCallback(
|
||||||
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
||||||
setSelectState({
|
setSelectState({
|
||||||
type: 'toggleSelected',
|
type: 'toggleSelected',
|
||||||
items: records,
|
items,
|
||||||
id,
|
id,
|
||||||
isSelected: value,
|
isSelected: value,
|
||||||
shiftKey,
|
shiftKey,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[records, setSelectState]
|
[items, setSelectState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRefreshPress = useCallback(() => {
|
const handleRefreshPress = useCallback(() => {
|
||||||
@@ -159,60 +150,93 @@ function Queue() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleGrabSelectedPress = useCallback(() => {
|
const handleGrabSelectedPress = useCallback(() => {
|
||||||
grabQueueItems({ ids: selectedIds });
|
dispatch(grabQueueItems({ ids: selectedIds }));
|
||||||
}, [selectedIds, grabQueueItems]);
|
}, [selectedIds, dispatch]);
|
||||||
|
|
||||||
const handleRemoveSelectedPress = useCallback(() => {
|
const handleRemoveSelectedPress = useCallback(() => {
|
||||||
shouldBlockRefresh.current = true;
|
shouldBlockRefresh.current = true;
|
||||||
setIsConfirmRemoveModalOpen(true);
|
setIsConfirmRemoveModalOpen(true);
|
||||||
}, [setIsConfirmRemoveModalOpen]);
|
}, [setIsConfirmRemoveModalOpen]);
|
||||||
|
|
||||||
const handleRemoveSelectedConfirmed = useCallback(() => {
|
const handleRemoveSelectedConfirmed = useCallback(
|
||||||
shouldBlockRefresh.current = false;
|
(payload: RemovePressProps) => {
|
||||||
removeQueueItems({ ids: selectedIds });
|
shouldBlockRefresh.current = false;
|
||||||
setIsConfirmRemoveModalOpen(false);
|
dispatch(removeQueueItems({ ids: selectedIds, ...payload }));
|
||||||
}, [selectedIds, setIsConfirmRemoveModalOpen, removeQueueItems]);
|
setIsConfirmRemoveModalOpen(false);
|
||||||
|
},
|
||||||
|
[selectedIds, setIsConfirmRemoveModalOpen, dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
const handleConfirmRemoveModalClose = useCallback(() => {
|
const handleConfirmRemoveModalClose = useCallback(() => {
|
||||||
shouldBlockRefresh.current = false;
|
shouldBlockRefresh.current = false;
|
||||||
setIsConfirmRemoveModalOpen(false);
|
setIsConfirmRemoveModalOpen(false);
|
||||||
}, [setIsConfirmRemoveModalOpen]);
|
}, [setIsConfirmRemoveModalOpen]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleFirstPagePress,
|
||||||
|
handlePreviousPagePress,
|
||||||
|
handleNextPagePress,
|
||||||
|
handleLastPagePress,
|
||||||
|
handlePageSelect,
|
||||||
|
} = usePaging({
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
gotoPage: gotoQueuePage,
|
||||||
|
});
|
||||||
|
|
||||||
const handleFilterSelect = useCallback(
|
const handleFilterSelect = useCallback(
|
||||||
(selectedFilterKey: string | number) => {
|
(selectedFilterKey: string) => {
|
||||||
setQueueOption('selectedFilterKey', selectedFilterKey);
|
dispatch(setQueueFilter({ selectedFilterKey }));
|
||||||
},
|
},
|
||||||
[]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSortPress = useCallback((sortKey: string) => {
|
const handleSortPress = useCallback(
|
||||||
setQueueOption('sortKey', sortKey);
|
(sortKey: string) => {
|
||||||
}, []);
|
dispatch(setQueueSort({ sortKey }));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTableOptionChange = useCallback(
|
const handleTableOptionChange = useCallback(
|
||||||
(payload: TableOptionsChangePayload) => {
|
(payload: TableOptionsChangePayload) => {
|
||||||
setQueueOptions(payload);
|
dispatch(setQueueTableOption(payload));
|
||||||
|
|
||||||
if (payload.pageSize) {
|
if (payload.pageSize) {
|
||||||
goToPage(1);
|
dispatch(gotoQueuePage({ page: 1 }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[goToPage]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const episodeIds = selectUniqueIds(records, 'episodeIds');
|
if (requestCurrentPage) {
|
||||||
|
dispatch(fetchQueue());
|
||||||
|
} else {
|
||||||
|
dispatch(gotoQueuePage({ page: 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dispatch(clearQueue());
|
||||||
|
};
|
||||||
|
}, [requestCurrentPage, dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const episodeIds = selectUniqueIds<QueueItem, number | undefined>(
|
||||||
|
items,
|
||||||
|
'episodeId'
|
||||||
|
);
|
||||||
|
|
||||||
if (episodeIds.length) {
|
if (episodeIds.length) {
|
||||||
dispatch(fetchEpisodes({ episodeIds }));
|
dispatch(fetchEpisodes({ episodeIds }));
|
||||||
} else {
|
} else {
|
||||||
dispatch(clearEpisodes());
|
dispatch(clearEpisodes());
|
||||||
}
|
}
|
||||||
}, [records, dispatch]);
|
}, [items, dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const repopulate = () => {
|
const repopulate = () => {
|
||||||
refetch();
|
dispatch(fetchQueue());
|
||||||
};
|
};
|
||||||
|
|
||||||
registerPagePopulator(repopulate);
|
registerPagePopulator(repopulate);
|
||||||
@@ -220,7 +244,7 @@ function Queue() {
|
|||||||
return () => {
|
return () => {
|
||||||
unregisterPagePopulator(repopulate);
|
unregisterPagePopulator(repopulate);
|
||||||
};
|
};
|
||||||
}, [refetch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
if (!shouldBlockRefresh.current) {
|
if (!shouldBlockRefresh.current) {
|
||||||
currentQueue.current = (
|
currentQueue.current = (
|
||||||
@@ -231,7 +255,7 @@ function Queue() {
|
|||||||
<Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert>
|
<Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isAllPopulated && !hasError && !records.length ? (
|
{isAllPopulated && !hasError && !items.length ? (
|
||||||
<Alert kind={kinds.INFO}>
|
<Alert kind={kinds.INFO}>
|
||||||
{selectedFilterKey !== 'all' && count > 0
|
{selectedFilterKey !== 'all' && count > 0
|
||||||
? translate('QueueFilterHasNoItems')
|
? translate('QueueFilterHasNoItems')
|
||||||
@@ -239,7 +263,7 @@ function Queue() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isAllPopulated && !hasError && !!records.length ? (
|
{isAllPopulated && !hasError && !!items.length ? (
|
||||||
<div>
|
<div>
|
||||||
<Table
|
<Table
|
||||||
selectAll={true}
|
selectAll={true}
|
||||||
@@ -255,10 +279,11 @@ function Queue() {
|
|||||||
onSortPress={handleSortPress}
|
onSortPress={handleSortPress}
|
||||||
>
|
>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{records.map((item) => {
|
{items.map((item) => {
|
||||||
return (
|
return (
|
||||||
<QueueRow
|
<QueueRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
episodeId={item.episodeId}
|
||||||
isSelected={selectedState[item.id]}
|
isSelected={selectedState[item.id]}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
{...item}
|
{...item}
|
||||||
@@ -277,7 +302,11 @@ function Queue() {
|
|||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
totalRecords={totalRecords}
|
totalRecords={totalRecords}
|
||||||
isFetching={isFetching}
|
isFetching={isFetching}
|
||||||
onPageSelect={goToPage}
|
onFirstPagePress={handleFirstPagePress}
|
||||||
|
onPreviousPagePress={handlePreviousPagePress}
|
||||||
|
onNextPagePress={handleNextPagePress}
|
||||||
|
onLastPagePress={handleLastPagePress}
|
||||||
|
onPageSelect={handlePageSelect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -348,7 +377,7 @@ function Queue() {
|
|||||||
canChangeCategory={
|
canChangeCategory={
|
||||||
isConfirmRemoveModalOpen &&
|
isConfirmRemoveModalOpen &&
|
||||||
selectedIds.every((id) => {
|
selectedIds.every((id) => {
|
||||||
const item = records.find((i) => i.id === id);
|
const item = items.find((i) => i.id === id);
|
||||||
|
|
||||||
return !!(item && item.downloadClientHasPostImportCategory);
|
return !!(item && item.downloadClientHasPostImportCategory);
|
||||||
})
|
})
|
||||||
@@ -356,7 +385,7 @@ function Queue() {
|
|||||||
canIgnore={
|
canIgnore={
|
||||||
isConfirmRemoveModalOpen &&
|
isConfirmRemoveModalOpen &&
|
||||||
selectedIds.every((id) => {
|
selectedIds.every((id) => {
|
||||||
const item = records.find((i) => i.id === id);
|
const item = items.find((i) => i.id === id);
|
||||||
|
|
||||||
return !!(item && item.seriesId && item.episodeId);
|
return !!(item && item.seriesId && item.episodeId);
|
||||||
})
|
})
|
||||||
@@ -364,7 +393,7 @@ function Queue() {
|
|||||||
isPending={
|
isPending={
|
||||||
isConfirmRemoveModalOpen &&
|
isConfirmRemoveModalOpen &&
|
||||||
selectedIds.every((id) => {
|
selectedIds.every((id) => {
|
||||||
const item = records.find((i) => i.id === id);
|
const item = items.find((i) => i.id === id);
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import styles from './QueueDetails.css';
|
|||||||
interface QueueDetailsProps {
|
interface QueueDetailsProps {
|
||||||
title: string;
|
title: string;
|
||||||
size: number;
|
size: number;
|
||||||
sizeLeft: number;
|
sizeleft: number;
|
||||||
estimatedCompletionTime?: string;
|
estimatedCompletionTime?: string;
|
||||||
status: string;
|
status: string;
|
||||||
trackedDownloadState?: QueueTrackedDownloadState;
|
trackedDownloadState?: QueueTrackedDownloadState;
|
||||||
@@ -28,7 +28,7 @@ function QueueDetails(props: QueueDetailsProps) {
|
|||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
size,
|
size,
|
||||||
sizeLeft,
|
sizeleft,
|
||||||
status,
|
status,
|
||||||
trackedDownloadState = 'downloading',
|
trackedDownloadState = 'downloading',
|
||||||
trackedDownloadStatus = 'ok',
|
trackedDownloadStatus = 'ok',
|
||||||
@@ -37,7 +37,7 @@ function QueueDetails(props: QueueDetailsProps) {
|
|||||||
progressBar,
|
progressBar,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const progress = 100 - (sizeLeft / size) * 100;
|
const progress = 100 - (sizeleft / size) * 100;
|
||||||
const isDownloading = status === 'downloading';
|
const isDownloading = status === 'downloading';
|
||||||
const isPaused = status === 'paused';
|
const isPaused = status === 'paused';
|
||||||
const hasWarning = trackedDownloadStatus === 'warning';
|
const hasWarning = trackedDownloadStatus === 'warning';
|
||||||
@@ -61,7 +61,7 @@ function QueueDetails(props: QueueDetailsProps) {
|
|||||||
anchor={progressBar!}
|
anchor={progressBar!}
|
||||||
title={`${state} - ${progress.toFixed(1)}%`}
|
title={`${state} - ${progress.toFixed(1)}%`}
|
||||||
body={<div>{title}</div>}
|
body={<div>{title}</div>}
|
||||||
position="bottom-start"
|
position={tooltipPositions.LEFT}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,52 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { setQueueOption } from './queueOptionsStore';
|
import { createSelector } from 'reselect';
|
||||||
import useQueue, { FILTER_BUILDER } from './useQueue';
|
import AppState from 'App/State/AppState';
|
||||||
|
import FilterModal from 'Components/Filter/FilterModal';
|
||||||
|
import { setQueueFilter } from 'Store/Actions/queueActions';
|
||||||
|
|
||||||
type QueueFilterModalProps = FilterModalProps<History>;
|
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) {
|
export default function QueueFilterModal(props: QueueFilterModalProps) {
|
||||||
const { data } = useQueue();
|
const sectionItems = useSelector(createQueueSelector());
|
||||||
|
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
||||||
const customFilterType = 'queue';
|
const customFilterType = 'queue';
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const dispatchSetFilter = useCallback(
|
const dispatchSetFilter = useCallback(
|
||||||
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
|
(payload: unknown) => {
|
||||||
setQueueOption('selectedFilterKey', selectedFilterKey);
|
dispatch(setQueueFilter(payload));
|
||||||
},
|
},
|
||||||
[]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilterModal
|
<FilterModal
|
||||||
|
// TODO: Don't spread all the props
|
||||||
{...props}
|
{...props}
|
||||||
sectionItems={data?.records ?? []}
|
sectionItems={sectionItems}
|
||||||
filterBuilderProps={FILTER_BUILDER}
|
filterBuilderProps={filterBuilderProps}
|
||||||
customFilterType={customFilterType}
|
customFilterType={customFilterType}
|
||||||
dispatchSetFilter={dispatchSetFilter}
|
dispatchSetFilter={dispatchSetFilter}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,30 +1,33 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
import FormGroup from 'Components/Form/FormGroup';
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
import FormLabel from 'Components/Form/FormLabel';
|
import FormLabel from 'Components/Form/FormLabel';
|
||||||
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
|
|
||||||
import { inputTypes } from 'Helpers/Props';
|
import { inputTypes } from 'Helpers/Props';
|
||||||
|
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
|
||||||
|
import { CheckInputChanged } from 'typings/inputs';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import {
|
|
||||||
QueueOptions as QueueOptionsType,
|
|
||||||
setQueueOption,
|
|
||||||
useQueueOption,
|
|
||||||
} from './queueOptionsStore';
|
|
||||||
import useQueue from './useQueue';
|
|
||||||
|
|
||||||
function QueueOptions() {
|
function QueueOptions() {
|
||||||
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
|
const dispatch = useDispatch();
|
||||||
const { goToPage } = useQueue();
|
const { includeUnknownSeriesItems } = useSelector(
|
||||||
|
(state: AppState) => state.queue.options
|
||||||
|
);
|
||||||
|
|
||||||
const handleOptionChange = useCallback(
|
const handleOptionChange = useCallback(
|
||||||
({ name, value }: OptionChanged<QueueOptionsType>) => {
|
({ name, value }: CheckInputChanged) => {
|
||||||
setQueueOption(name, value);
|
dispatch(
|
||||||
|
setQueueOption({
|
||||||
|
[name]: value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
if (name === 'includeUnknownSeriesItems') {
|
if (name === 'includeUnknownSeriesItems') {
|
||||||
goToPage(1);
|
dispatch(gotoQueuePage({ page: 1 }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[goToPage]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +39,6 @@ function QueueOptions() {
|
|||||||
name="includeUnknownSeriesItems"
|
name="includeUnknownSeriesItems"
|
||||||
value={includeUnknownSeriesItems}
|
value={includeUnknownSeriesItems}
|
||||||
helpText={translate('ShowUnknownSeriesItemsHelpText')}
|
helpText={translate('ShowUnknownSeriesItemsHelpText')}
|
||||||
// @ts-expect-error - The typing for inputs needs more work
|
|
||||||
onChange={handleOptionChange}
|
onChange={handleOptionChange}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
|
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
|
||||||
|
import { Error } from 'App/State/AppSectionState';
|
||||||
import IconButton from 'Components/Link/IconButton';
|
import IconButton from 'Components/Link/IconButton';
|
||||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||||
import ProgressBar from 'Components/ProgressBar';
|
import ProgressBar from 'Components/ProgressBar';
|
||||||
@@ -14,13 +15,16 @@ import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
|||||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||||
import useEpisodes from 'Episode/useEpisodes';
|
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
||||||
|
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
|
||||||
|
import useEpisode from 'Episode/useEpisode';
|
||||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||||
import Language from 'Language/Language';
|
import Language from 'Language/Language';
|
||||||
import { QualityModel } from 'Quality/Quality';
|
import { QualityModel } from 'Quality/Quality';
|
||||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||||
import useSeries from 'Series/useSeries';
|
import useSeries from 'Series/useSeries';
|
||||||
|
import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||||
import CustomFormat from 'typings/CustomFormat';
|
import CustomFormat from 'typings/CustomFormat';
|
||||||
import { SelectStateInputProps } from 'typings/props';
|
import { SelectStateInputProps } from 'typings/props';
|
||||||
@@ -32,18 +36,15 @@ import {
|
|||||||
import formatBytes from 'Utilities/Number/formatBytes';
|
import formatBytes from 'Utilities/Number/formatBytes';
|
||||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import EpisodeCellContent from './EpisodeCellContent';
|
|
||||||
import EpisodeTitleCellContent from './EpisodeTitleCellContent';
|
|
||||||
import QueueStatusCell from './QueueStatusCell';
|
import QueueStatusCell from './QueueStatusCell';
|
||||||
import RemoveQueueItemModal from './RemoveQueueItemModal';
|
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
|
||||||
import TimeLeftCell from './TimeLeftCell';
|
import TimeleftCell from './TimeleftCell';
|
||||||
import { useGrabQueueItem, useRemoveQueueItem } from './useQueue';
|
|
||||||
import styles from './QueueRow.css';
|
import styles from './QueueRow.css';
|
||||||
|
|
||||||
interface QueueRowProps {
|
interface QueueRowProps {
|
||||||
id: number;
|
id: number;
|
||||||
seriesId?: number;
|
seriesId?: number;
|
||||||
episodeIds: number[];
|
episodeId?: number;
|
||||||
downloadId?: string;
|
downloadId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -57,16 +58,16 @@ interface QueueRowProps {
|
|||||||
customFormatScore: number;
|
customFormatScore: number;
|
||||||
protocol: DownloadProtocol;
|
protocol: DownloadProtocol;
|
||||||
indexer?: string;
|
indexer?: string;
|
||||||
isFullSeason: boolean;
|
|
||||||
seasonNumbers: number[];
|
|
||||||
outputPath?: string;
|
outputPath?: string;
|
||||||
downloadClient?: string;
|
downloadClient?: string;
|
||||||
downloadClientHasPostImportCategory?: boolean;
|
downloadClientHasPostImportCategory?: boolean;
|
||||||
estimatedCompletionTime?: string;
|
estimatedCompletionTime?: string;
|
||||||
added?: string;
|
added?: string;
|
||||||
timeLeft?: string;
|
timeleft?: string;
|
||||||
size: number;
|
size: number;
|
||||||
sizeLeft: number;
|
sizeleft: number;
|
||||||
|
isGrabbing?: boolean;
|
||||||
|
grabError?: Error;
|
||||||
isRemoving?: boolean;
|
isRemoving?: boolean;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
columns: Column[];
|
columns: Column[];
|
||||||
@@ -78,7 +79,7 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
seriesId,
|
seriesId,
|
||||||
episodeIds,
|
episodeId,
|
||||||
downloadId,
|
downloadId,
|
||||||
title,
|
title,
|
||||||
status,
|
status,
|
||||||
@@ -96,25 +97,25 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
downloadClient,
|
downloadClient,
|
||||||
downloadClientHasPostImportCategory,
|
downloadClientHasPostImportCategory,
|
||||||
estimatedCompletionTime,
|
estimatedCompletionTime,
|
||||||
isFullSeason,
|
|
||||||
seasonNumbers,
|
|
||||||
added,
|
added,
|
||||||
timeLeft,
|
timeleft,
|
||||||
size,
|
size,
|
||||||
sizeLeft,
|
sizeleft,
|
||||||
|
isGrabbing = false,
|
||||||
|
grabError,
|
||||||
|
isRemoving = false,
|
||||||
isSelected,
|
isSelected,
|
||||||
columns,
|
columns,
|
||||||
onSelectedChange,
|
onSelectedChange,
|
||||||
onQueueRowModalOpenOrClose,
|
onQueueRowModalOpenOrClose,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
const series = useSeries(seriesId);
|
const series = useSeries(seriesId);
|
||||||
const episodes = useEpisodes(episodeIds, 'episodes');
|
const episode = useEpisode(episodeId, 'episodes');
|
||||||
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
|
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
|
||||||
createUISettingsSelector()
|
createUISettingsSelector()
|
||||||
);
|
);
|
||||||
const { removeQueueItem, isRemoving } = useRemoveQueueItem(id);
|
|
||||||
const { grabQueueItem, isGrabbing, grabError } = useGrabQueueItem(id);
|
|
||||||
|
|
||||||
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
|
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@@ -123,8 +124,8 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
const handleGrabPress = useCallback(() => {
|
const handleGrabPress = useCallback(() => {
|
||||||
grabQueueItem();
|
dispatch(grabQueueItem({ id }));
|
||||||
}, [grabQueueItem]);
|
}, [id, dispatch]);
|
||||||
|
|
||||||
const handleInteractiveImportPress = useCallback(() => {
|
const handleInteractiveImportPress = useCallback(() => {
|
||||||
onQueueRowModalOpenOrClose(true);
|
onQueueRowModalOpenOrClose(true);
|
||||||
@@ -141,22 +142,21 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
setIsRemoveQueueItemModalOpen(true);
|
setIsRemoveQueueItemModalOpen(true);
|
||||||
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
||||||
|
|
||||||
const handleRemoveQueueItemModalConfirmed = useCallback(() => {
|
const handleRemoveQueueItemModalConfirmed = useCallback(
|
||||||
onQueueRowModalOpenOrClose(false);
|
(payload: RemovePressProps) => {
|
||||||
removeQueueItem();
|
onQueueRowModalOpenOrClose(false);
|
||||||
setIsRemoveQueueItemModalOpen(false);
|
dispatch(removeQueueItem({ id, ...payload }));
|
||||||
}, [
|
setIsRemoveQueueItemModalOpen(false);
|
||||||
setIsRemoveQueueItemModalOpen,
|
},
|
||||||
removeQueueItem,
|
[id, setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose, dispatch]
|
||||||
onQueueRowModalOpenOrClose,
|
);
|
||||||
]);
|
|
||||||
|
|
||||||
const handleRemoveQueueItemModalClose = useCallback(() => {
|
const handleRemoveQueueItemModalClose = useCallback(() => {
|
||||||
onQueueRowModalOpenOrClose(false);
|
onQueueRowModalOpenOrClose(false);
|
||||||
setIsRemoveQueueItemModalOpen(false);
|
setIsRemoveQueueItemModalOpen(false);
|
||||||
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
||||||
|
|
||||||
const progress = 100 - (sizeLeft / size) * 100;
|
const progress = 100 - (sizeleft / size) * 100;
|
||||||
const showInteractiveImport =
|
const showInteractiveImport =
|
||||||
status === 'completed' && trackedDownloadStatus === 'warning';
|
status === 'completed' && trackedDownloadStatus === 'warning';
|
||||||
const isPending =
|
const isPending =
|
||||||
@@ -209,12 +209,23 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
if (name === 'episode') {
|
if (name === 'episode') {
|
||||||
return (
|
return (
|
||||||
<TableRowCell key={name}>
|
<TableRowCell key={name}>
|
||||||
<EpisodeCellContent
|
{episode ? (
|
||||||
episodes={episodes}
|
<SeasonEpisodeNumber
|
||||||
isFullSeason={isFullSeason}
|
seasonNumber={episode.seasonNumber}
|
||||||
seasonNumber={seasonNumbers[0]}
|
episodeNumber={episode.episodeNumber}
|
||||||
series={series}
|
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
|
||||||
/>
|
seriesType={series?.seriesType}
|
||||||
|
alternateTitles={series?.alternateTitles}
|
||||||
|
sceneSeasonNumber={episode.sceneSeasonNumber}
|
||||||
|
sceneEpisodeNumber={episode.sceneEpisodeNumber}
|
||||||
|
sceneAbsoluteEpisodeNumber={
|
||||||
|
episode.sceneAbsoluteEpisodeNumber
|
||||||
|
}
|
||||||
|
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
</TableRowCell>
|
</TableRowCell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -222,37 +233,27 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
if (name === 'episodes.title') {
|
if (name === 'episodes.title') {
|
||||||
return (
|
return (
|
||||||
<TableRowCell key={name}>
|
<TableRowCell key={name}>
|
||||||
<EpisodeTitleCellContent episodes={episodes} series={series} />
|
{series && episode ? (
|
||||||
|
<EpisodeTitleLink
|
||||||
|
episodeId={episode.id}
|
||||||
|
seriesId={series.id}
|
||||||
|
episodeTitle={episode.title}
|
||||||
|
episodeEntity="episodes"
|
||||||
|
showOpenSeriesButton={true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
</TableRowCell>
|
</TableRowCell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'episodes.airDateUtc') {
|
if (name === 'episodes.airDateUtc') {
|
||||||
if (episodes.length === 0) {
|
if (episode) {
|
||||||
return <TableRowCell key={name}>-</TableRowCell>;
|
return <RelativeDateCell key={name} date={episode.airDateUtc} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (episodes.length === 1) {
|
return <TableRowCell key={name}>-</TableRowCell>;
|
||||||
return (
|
|
||||||
<RelativeDateCell key={name} date={episodes[0].airDateUtc} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRowCell key={name}>
|
|
||||||
<RelativeDateCell
|
|
||||||
key={name}
|
|
||||||
component="span"
|
|
||||||
date={episodes[0].airDateUtc}
|
|
||||||
/>
|
|
||||||
{' - '}
|
|
||||||
<RelativeDateCell
|
|
||||||
key={name}
|
|
||||||
component="span"
|
|
||||||
date={episodes[episodes.length - 1].airDateUtc}
|
|
||||||
/>
|
|
||||||
</TableRowCell>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'languages') {
|
if (name === 'languages') {
|
||||||
@@ -324,13 +325,13 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
|
|
||||||
if (name === 'estimatedCompletionTime') {
|
if (name === 'estimatedCompletionTime') {
|
||||||
return (
|
return (
|
||||||
<TimeLeftCell
|
<TimeleftCell
|
||||||
key={name}
|
key={name}
|
||||||
status={status}
|
status={status}
|
||||||
estimatedCompletionTime={estimatedCompletionTime}
|
estimatedCompletionTime={estimatedCompletionTime}
|
||||||
timeLeft={timeLeft}
|
timeleft={timeleft}
|
||||||
size={size}
|
size={size}
|
||||||
sizeLeft={sizeLeft}
|
sizeleft={sizeleft}
|
||||||
showRelativeDates={showRelativeDates}
|
showRelativeDates={showRelativeDates}
|
||||||
shortDateFormat={shortDateFormat}
|
shortDateFormat={shortDateFormat}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Icon, { IconKind } from 'Components/Icon';
|
import Icon, { IconProps } from 'Components/Icon';
|
||||||
import Popover from 'Components/Tooltip/Popover';
|
import Popover from 'Components/Tooltip/Popover';
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
import { TooltipPosition } from 'Helpers/Props/tooltipPositions';
|
import { TooltipPosition } from 'Helpers/Props/tooltipPositions';
|
||||||
@@ -61,7 +61,7 @@ function QueueStatus(props: QueueStatusProps) {
|
|||||||
|
|
||||||
// status === 'downloading'
|
// status === 'downloading'
|
||||||
let iconName = icons.DOWNLOADING;
|
let iconName = icons.DOWNLOADING;
|
||||||
let iconKind: IconKind = kinds.DEFAULT;
|
let iconKind: IconProps['kind'] = kinds.DEFAULT;
|
||||||
let title = translate('Downloading');
|
let title = translate('Downloading');
|
||||||
|
|
||||||
if (status === 'paused') {
|
if (status === 'paused') {
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import FormGroup from 'Components/Form/FormGroup';
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
import FormLabel from 'Components/Form/FormLabel';
|
import FormLabel from 'Components/Form/FormLabel';
|
||||||
import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput';
|
|
||||||
import Button from 'Components/Link/Button';
|
import Button from 'Components/Link/Button';
|
||||||
import Modal from 'Components/Modal/Modal';
|
import Modal from 'Components/Modal/Modal';
|
||||||
import ModalBody from 'Components/Modal/ModalBody';
|
import ModalBody from 'Components/Modal/ModalBody';
|
||||||
import ModalContent from 'Components/Modal/ModalContent';
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
|
|
||||||
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import {
|
|
||||||
QueueOptions,
|
|
||||||
setQueueOption,
|
|
||||||
useQueueOption,
|
|
||||||
} from './queueOptionsStore';
|
|
||||||
import styles from './RemoveQueueItemModal.css';
|
import styles from './RemoveQueueItemModal.css';
|
||||||
|
|
||||||
|
export interface RemovePressProps {
|
||||||
|
remove: boolean;
|
||||||
|
changeCategory: boolean;
|
||||||
|
blocklist: boolean;
|
||||||
|
skipRedownload: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface RemoveQueueItemModalProps {
|
interface RemoveQueueItemModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
sourceTitle?: string;
|
sourceTitle?: string;
|
||||||
@@ -26,10 +26,16 @@ interface RemoveQueueItemModalProps {
|
|||||||
canIgnore: boolean;
|
canIgnore: boolean;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
selectedCount?: number;
|
selectedCount?: number;
|
||||||
onRemovePress(): void;
|
onRemovePress(props: RemovePressProps): void;
|
||||||
onModalClose: () => void;
|
onModalClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore';
|
||||||
|
type BlocklistMethod =
|
||||||
|
| 'doNotBlocklist'
|
||||||
|
| 'blocklistAndSearch'
|
||||||
|
| 'blocklistOnly';
|
||||||
|
|
||||||
function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||||
const {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -43,7 +49,11 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const multipleSelected = selectedCount && selectedCount > 1;
|
const multipleSelected = selectedCount && selectedCount > 1;
|
||||||
const { removalMethod, blocklistMethod } = useQueueOption('removalOptions');
|
|
||||||
|
const [removalMethod, setRemovalMethod] =
|
||||||
|
useState<RemovalMethod>('removeFromClient');
|
||||||
|
const [blocklistMethod, setBlocklistMethod] =
|
||||||
|
useState<BlocklistMethod>('doNotBlocklist');
|
||||||
|
|
||||||
const { title, message } = useMemo(() => {
|
const { title, message } = useMemo(() => {
|
||||||
if (!selectedCount) {
|
if (!selectedCount) {
|
||||||
@@ -69,7 +79,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
|||||||
}, [sourceTitle, selectedCount]);
|
}, [sourceTitle, selectedCount]);
|
||||||
|
|
||||||
const removalMethodOptions = useMemo(() => {
|
const removalMethodOptions = useMemo(() => {
|
||||||
const options: EnhancedSelectInputValue<string>[] = [
|
return [
|
||||||
{
|
{
|
||||||
key: 'removeFromClient',
|
key: 'removeFromClient',
|
||||||
value: translate('RemoveFromDownloadClient'),
|
value: translate('RemoveFromDownloadClient'),
|
||||||
@@ -96,12 +106,10 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
|||||||
: translate('IgnoreDownloadHint'),
|
: translate('IgnoreDownloadHint'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return options;
|
|
||||||
}, [canChangeCategory, canIgnore, multipleSelected]);
|
}, [canChangeCategory, canIgnore, multipleSelected]);
|
||||||
|
|
||||||
const blocklistMethodOptions = useMemo(() => {
|
const blocklistMethodOptions = useMemo(() => {
|
||||||
const options: EnhancedSelectInputValue<string>[] = [
|
return [
|
||||||
{
|
{
|
||||||
key: 'doNotBlocklist',
|
key: 'doNotBlocklist',
|
||||||
value: translate('DoNotBlocklist'),
|
value: translate('DoNotBlocklist'),
|
||||||
@@ -123,28 +131,46 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
|||||||
: translate('BlocklistOnlyHint'),
|
: translate('BlocklistOnlyHint'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return options;
|
|
||||||
}, [isPending, multipleSelected]);
|
}, [isPending, multipleSelected]);
|
||||||
|
|
||||||
const handleRemovalOptionInputChange = useCallback(
|
const handleRemovalMethodChange = useCallback(
|
||||||
({ name, value }: OptionChanged<QueueOptions['removalOptions']>) => {
|
({ value }: { value: RemovalMethod }) => {
|
||||||
setQueueOption('removalOptions', {
|
setRemovalMethod(value);
|
||||||
removalMethod,
|
|
||||||
blocklistMethod,
|
|
||||||
[name]: value,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[removalMethod, blocklistMethod]
|
[setRemovalMethod]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBlocklistMethodChange = useCallback(
|
||||||
|
({ value }: { value: BlocklistMethod }) => {
|
||||||
|
setBlocklistMethod(value);
|
||||||
|
},
|
||||||
|
[setBlocklistMethod]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConfirmRemove = useCallback(() => {
|
const handleConfirmRemove = useCallback(() => {
|
||||||
onRemovePress();
|
onRemovePress({
|
||||||
}, [onRemovePress]);
|
remove: removalMethod === 'removeFromClient',
|
||||||
|
changeCategory: removalMethod === 'changeCategory',
|
||||||
|
blocklist: blocklistMethod !== 'doNotBlocklist',
|
||||||
|
skipRedownload: blocklistMethod === 'blocklistOnly',
|
||||||
|
});
|
||||||
|
|
||||||
|
setRemovalMethod('removeFromClient');
|
||||||
|
setBlocklistMethod('doNotBlocklist');
|
||||||
|
}, [
|
||||||
|
removalMethod,
|
||||||
|
blocklistMethod,
|
||||||
|
setRemovalMethod,
|
||||||
|
setBlocklistMethod,
|
||||||
|
onRemovePress,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleModalClose = useCallback(() => {
|
const handleModalClose = useCallback(() => {
|
||||||
|
setRemovalMethod('removeFromClient');
|
||||||
|
setBlocklistMethod('doNotBlocklist');
|
||||||
|
|
||||||
onModalClose();
|
onModalClose();
|
||||||
}, [onModalClose]);
|
}, [setRemovalMethod, setBlocklistMethod, onModalClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={handleModalClose}>
|
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={handleModalClose}>
|
||||||
@@ -167,8 +193,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
|||||||
helpTextWarning={translate(
|
helpTextWarning={translate(
|
||||||
'RemoveQueueItemRemovalMethodHelpTextWarning'
|
'RemoveQueueItemRemovalMethodHelpTextWarning'
|
||||||
)}
|
)}
|
||||||
// @ts-expect-error - The typing for inputs needs more work
|
onChange={handleRemovalMethodChange}
|
||||||
onChange={handleRemovalOptionInputChange}
|
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
)}
|
)}
|
||||||
@@ -186,8 +211,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
|||||||
value={blocklistMethod}
|
value={blocklistMethod}
|
||||||
values={blocklistMethodOptions}
|
values={blocklistMethodOptions}
|
||||||
helpText={translate('BlocklistReleaseHelpText')}
|
helpText={translate('BlocklistReleaseHelpText')}
|
||||||
// @ts-expect-error - The typing for inputs needs more work
|
onChange={handleBlocklistMethodChange}
|
||||||
onChange={handleRemovalOptionInputChange}
|
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|||||||
@@ -1,9 +1,33 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
|
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
|
||||||
import useQueueStatus from './useQueueStatus';
|
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||||
|
import { fetchQueueStatus } from 'Store/Actions/queueActions';
|
||||||
|
import createQueueStatusSelector from './createQueueStatusSelector';
|
||||||
|
|
||||||
function QueueStatus() {
|
function QueueStatus() {
|
||||||
const { errors, warnings, count } = useQueueStatus();
|
const dispatch = useDispatch();
|
||||||
|
const { isConnected, isReconnecting } = useSelector(
|
||||||
|
(state: AppState) => state.app
|
||||||
|
);
|
||||||
|
const { isPopulated, count, errors, warnings } = useSelector(
|
||||||
|
createQueueStatusSelector()
|
||||||
|
);
|
||||||
|
|
||||||
|
const wasReconnecting = usePrevious(isReconnecting);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPopulated) {
|
||||||
|
dispatch(fetchQueueStatus());
|
||||||
|
}
|
||||||
|
}, [isPopulated, dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isConnected && wasReconnecting) {
|
||||||
|
dispatch(fetchQueueStatus());
|
||||||
|
}
|
||||||
|
}, [isConnected, wasReconnecting, dispatch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageSidebarStatus count={count} errors={errors} warnings={warnings} />
|
<PageSidebarStatus count={count} errors={errors} warnings={warnings} />
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
|
||||||
|
function createQueueStatusSelector() {
|
||||||
|
return createSelector(
|
||||||
|
(state: AppState) => state.queue.status.isPopulated,
|
||||||
|
(state: AppState) => state.queue.status.item,
|
||||||
|
(state: AppState) => state.queue.options.includeUnknownSeriesItems,
|
||||||
|
(isPopulated, status, includeUnknownSeriesItems) => {
|
||||||
|
const {
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
unknownErrors,
|
||||||
|
unknownWarnings,
|
||||||
|
count,
|
||||||
|
totalCount,
|
||||||
|
} = status;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...status,
|
||||||
|
isPopulated,
|
||||||
|
count: includeUnknownSeriesItems ? totalCount : count,
|
||||||
|
errors: includeUnknownSeriesItems ? errors || unknownErrors : errors,
|
||||||
|
warnings: includeUnknownSeriesItems
|
||||||
|
? warnings || unknownWarnings
|
||||||
|
: warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createQueueStatusSelector;
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
|
||||||
import { useQueueOption } from '../queueOptionsStore';
|
|
||||||
|
|
||||||
export interface QueueStatus {
|
|
||||||
totalCount: number;
|
|
||||||
count: number;
|
|
||||||
unknownCount: number;
|
|
||||||
errors: boolean;
|
|
||||||
warnings: boolean;
|
|
||||||
unknownErrors: boolean;
|
|
||||||
unknownWarnings: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function useQueueStatus() {
|
|
||||||
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
|
|
||||||
|
|
||||||
const { data } = useApiQuery<QueueStatus>({
|
|
||||||
path: '/queue/status',
|
|
||||||
queryParams: {
|
|
||||||
includeUnknownSeriesItems,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return {
|
|
||||||
count: 0,
|
|
||||||
errors: false,
|
|
||||||
warnings: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
errors,
|
|
||||||
warnings,
|
|
||||||
unknownErrors,
|
|
||||||
unknownWarnings,
|
|
||||||
count,
|
|
||||||
totalCount,
|
|
||||||
} = data;
|
|
||||||
|
|
||||||
if (includeUnknownSeriesItems) {
|
|
||||||
return {
|
|
||||||
count: totalCount,
|
|
||||||
errors: errors || unknownErrors,
|
|
||||||
warnings: warnings || unknownWarnings,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
count,
|
|
||||||
errors,
|
|
||||||
warnings,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.timeLeft {
|
.timeleft {
|
||||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||||
|
|
||||||
width: 100px;
|
width: 100px;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// This file is automatically generated.
|
// This file is automatically generated.
|
||||||
// Please do not change this file!
|
// Please do not change this file!
|
||||||
interface CssExports {
|
interface CssExports {
|
||||||
'row': string;
|
'timeleft': string;
|
||||||
}
|
}
|
||||||
export const cssExports: CssExports;
|
export const cssExports: CssExports;
|
||||||
export default cssExports;
|
export default cssExports;
|
||||||
@@ -8,26 +8,26 @@ import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
|
|||||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||||
import formatBytes from 'Utilities/Number/formatBytes';
|
import formatBytes from 'Utilities/Number/formatBytes';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import styles from './TimeLeftCell.css';
|
import styles from './TimeleftCell.css';
|
||||||
|
|
||||||
interface TimeLeftCellProps {
|
interface TimeleftCellProps {
|
||||||
estimatedCompletionTime?: string;
|
estimatedCompletionTime?: string;
|
||||||
timeLeft?: string;
|
timeleft?: string;
|
||||||
status: string;
|
status: string;
|
||||||
size: number;
|
size: number;
|
||||||
sizeLeft: number;
|
sizeleft: number;
|
||||||
showRelativeDates: boolean;
|
showRelativeDates: boolean;
|
||||||
shortDateFormat: string;
|
shortDateFormat: string;
|
||||||
timeFormat: string;
|
timeFormat: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimeLeftCell(props: TimeLeftCellProps) {
|
function TimeleftCell(props: TimeleftCellProps) {
|
||||||
const {
|
const {
|
||||||
estimatedCompletionTime,
|
estimatedCompletionTime,
|
||||||
timeLeft,
|
timeleft,
|
||||||
status,
|
status,
|
||||||
size,
|
size,
|
||||||
sizeLeft,
|
sizeleft,
|
||||||
showRelativeDates,
|
showRelativeDates,
|
||||||
shortDateFormat,
|
shortDateFormat,
|
||||||
timeFormat,
|
timeFormat,
|
||||||
@@ -44,7 +44,7 @@ function TimeLeftCell(props: TimeLeftCellProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRowCell className={styles.timeLeft}>
|
<TableRowCell className={styles.timeleft}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
anchor={<Icon name={icons.INFO} />}
|
anchor={<Icon name={icons.INFO} />}
|
||||||
tooltip={translate('DelayingDownloadUntil', { date, time })}
|
tooltip={translate('DelayingDownloadUntil', { date, time })}
|
||||||
@@ -66,7 +66,7 @@ function TimeLeftCell(props: TimeLeftCellProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRowCell className={styles.timeLeft}>
|
<TableRowCell className={styles.timeleft}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
anchor={<Icon name={icons.INFO} />}
|
anchor={<Icon name={icons.INFO} />}
|
||||||
tooltip={translate('RetryingDownloadOn', { date, time })}
|
tooltip={translate('RetryingDownloadOn', { date, time })}
|
||||||
@@ -77,21 +77,21 @@ function TimeLeftCell(props: TimeLeftCellProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!timeLeft || status === 'completed' || status === 'failed') {
|
if (!timeleft || status === 'completed' || status === 'failed') {
|
||||||
return <TableRowCell className={styles.timeLeft}>-</TableRowCell>;
|
return <TableRowCell className={styles.timeleft}>-</TableRowCell>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalSize = formatBytes(size);
|
const totalSize = formatBytes(size);
|
||||||
const remainingSize = formatBytes(sizeLeft);
|
const remainingSize = formatBytes(sizeleft);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRowCell
|
<TableRowCell
|
||||||
className={styles.timeLeft}
|
className={styles.timeleft}
|
||||||
title={`${remainingSize} / ${totalSize}`}
|
title={`${remainingSize} / ${totalSize}`}
|
||||||
>
|
>
|
||||||
{formatTimeSpan(timeLeft)}
|
{formatTimeSpan(timeleft)}
|
||||||
</TableRowCell>
|
</TableRowCell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TimeLeftCell;
|
export default TimeleftCell;
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import Column from 'Components/Table/Column';
|
|
||||||
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
|
|
||||||
import { icons } from 'Helpers/Props';
|
|
||||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
|
|
||||||
interface QueueRemovalOptions {
|
|
||||||
removalMethod: 'changeCategory' | 'ignore' | 'removeFromClient';
|
|
||||||
blocklistMethod: 'blocklistAndSearch' | 'blocklistOnly' | 'doNotBlocklist';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueueOptions {
|
|
||||||
includeUnknownSeriesItems: boolean;
|
|
||||||
pageSize: number;
|
|
||||||
selectedFilterKey: string | number;
|
|
||||||
sortKey: string;
|
|
||||||
sortDirection: SortDirection;
|
|
||||||
columns: Column[];
|
|
||||||
removalOptions: QueueRemovalOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { useOptions, useOption, setOptions, setOption } =
|
|
||||||
createOptionsStore<QueueOptions>('queue_options', () => {
|
|
||||||
return {
|
|
||||||
includeUnknownSeriesItems: true,
|
|
||||||
pageSize: 20,
|
|
||||||
selectedFilterKey: 'all',
|
|
||||||
sortKey: 'time',
|
|
||||||
sortDirection: 'descending',
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
name: 'status',
|
|
||||||
label: '',
|
|
||||||
columnLabel: () => translate('Status'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true,
|
|
||||||
isModifiable: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'series.sortTitle',
|
|
||||||
label: () => translate('Series'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'episode',
|
|
||||||
label: () => translate('EpisodeMaybePlural'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'episodes.title',
|
|
||||||
label: () => translate('EpisodeTitleMaybePlural'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'episodes.airDateUtc',
|
|
||||||
label: () => translate('EpisodeAirDate'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'languages',
|
|
||||||
label: () => translate('Languages'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'quality',
|
|
||||||
label: () => translate('Quality'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'customFormats',
|
|
||||||
label: () => translate('Formats'),
|
|
||||||
isSortable: false,
|
|
||||||
isVisible: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'customFormatScore',
|
|
||||||
columnLabel: () => translate('CustomFormatScore'),
|
|
||||||
label: React.createElement(Icon, {
|
|
||||||
name: icons.SCORE,
|
|
||||||
title: () => translate('CustomFormatScore'),
|
|
||||||
}),
|
|
||||||
isVisible: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'protocol',
|
|
||||||
label: () => translate('Protocol'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'indexer',
|
|
||||||
label: () => translate('Indexer'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'downloadClient',
|
|
||||||
label: () => translate('DownloadClient'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'title',
|
|
||||||
label: () => translate('ReleaseTitle'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'size',
|
|
||||||
label: () => translate('Size'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'outputPath',
|
|
||||||
label: () => translate('OutputPath'),
|
|
||||||
isSortable: false,
|
|
||||||
isVisible: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'estimatedCompletionTime',
|
|
||||||
label: () => translate('TimeLeft'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'added',
|
|
||||||
label: () => translate('Added'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'progress',
|
|
||||||
label: () => translate('Progress'),
|
|
||||||
isSortable: true,
|
|
||||||
isVisible: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'actions',
|
|
||||||
label: '',
|
|
||||||
columnLabel: () => translate('Actions'),
|
|
||||||
isVisible: true,
|
|
||||||
isModifiable: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
removalOptions: {
|
|
||||||
removalMethod: 'removeFromClient',
|
|
||||||
blocklistMethod: 'doNotBlocklist',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useQueueOptions = useOptions;
|
|
||||||
export const setQueueOptions = setOptions;
|
|
||||||
export const useQueueOption = useOption;
|
|
||||||
export const setQueueOption = setOption;
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
|
|
||||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
|
||||||
import usePage from 'Helpers/Hooks/usePage';
|
|
||||||
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
|
||||||
import { filterBuilderValueTypes } from 'Helpers/Props';
|
|
||||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
|
||||||
import Queue from 'typings/Queue';
|
|
||||||
import getQueryString from 'Utilities/Fetch/getQueryString';
|
|
||||||
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import { useQueueOptions } from './queueOptionsStore';
|
|
||||||
|
|
||||||
interface BulkQueueData {
|
|
||||||
ids: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FILTERS: Filter[] = [
|
|
||||||
{
|
|
||||||
key: 'all',
|
|
||||||
label: () => translate('All'),
|
|
||||||
filters: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const FILTER_BUILDER: FilterBuilderProp<Queue>[] = [
|
|
||||||
{
|
|
||||||
name: 'seriesIds',
|
|
||||||
label: () => translate('Series'),
|
|
||||||
type: 'equal',
|
|
||||||
valueType: filterBuilderValueTypes.SERIES,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'quality',
|
|
||||||
label: () => translate('Quality'),
|
|
||||||
type: 'equal',
|
|
||||||
valueType: filterBuilderValueTypes.QUALITY,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'languages',
|
|
||||||
label: () => translate('Languages'),
|
|
||||||
type: 'contains',
|
|
||||||
valueType: filterBuilderValueTypes.LANGUAGE,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'protocol',
|
|
||||||
label: () => translate('Protocol'),
|
|
||||||
type: 'equal',
|
|
||||||
valueType: filterBuilderValueTypes.PROTOCOL,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'status',
|
|
||||||
label: () => translate('Status'),
|
|
||||||
type: 'equal',
|
|
||||||
valueType: filterBuilderValueTypes.QUEUE_STATUS,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const useQueue = () => {
|
|
||||||
const { page, goToPage } = usePage('queue');
|
|
||||||
const {
|
|
||||||
includeUnknownSeriesItems,
|
|
||||||
pageSize,
|
|
||||||
selectedFilterKey,
|
|
||||||
sortKey,
|
|
||||||
sortDirection,
|
|
||||||
} = useQueueOptions();
|
|
||||||
const customFilters = useSelector(
|
|
||||||
createCustomFiltersSelector('queue')
|
|
||||||
) as CustomFilter[];
|
|
||||||
|
|
||||||
const filters = useMemo(() => {
|
|
||||||
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
|
|
||||||
}, [selectedFilterKey, customFilters]);
|
|
||||||
|
|
||||||
const { refetch, ...query } = usePagedApiQuery<Queue>({
|
|
||||||
path: '/queue',
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
filters,
|
|
||||||
queryParams: {
|
|
||||||
includeUnknownSeriesItems,
|
|
||||||
},
|
|
||||||
sortKey,
|
|
||||||
sortDirection,
|
|
||||||
queryOptions: {
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleGoToPage = useCallback(
|
|
||||||
(page: number) => {
|
|
||||||
goToPage(page);
|
|
||||||
},
|
|
||||||
[goToPage]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...query,
|
|
||||||
goToPage: handleGoToPage,
|
|
||||||
page,
|
|
||||||
refetch,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useQueue;
|
|
||||||
|
|
||||||
export const useFilters = () => {
|
|
||||||
return FILTERS;
|
|
||||||
};
|
|
||||||
|
|
||||||
const useRemovalOptions = () => {
|
|
||||||
const { removalOptions } = useQueueOptions();
|
|
||||||
|
|
||||||
return {
|
|
||||||
remove: removalOptions.removalMethod === 'removeFromClient',
|
|
||||||
changeCategory: removalOptions.removalMethod === 'changeCategory',
|
|
||||||
blocklist: removalOptions.blocklistMethod !== 'doNotBlocklist',
|
|
||||||
skipRedownload: removalOptions.blocklistMethod === 'blocklistOnly',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useRemoveQueueItem = (id: number) => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const removalOptions = useRemovalOptions();
|
|
||||||
|
|
||||||
const { mutate, isPending } = useApiMutation<unknown, void>({
|
|
||||||
path: `/queue/${id}${getQueryString(removalOptions)}`,
|
|
||||||
method: 'DELETE',
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
removeQueueItem: mutate,
|
|
||||||
isRemoving: isPending,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useRemoveQueueItems = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const removalOptions = useRemovalOptions();
|
|
||||||
|
|
||||||
const { mutate, isPending } = useApiMutation<unknown, BulkQueueData>({
|
|
||||||
path: `/queue/bulk${getQueryString(removalOptions)}`,
|
|
||||||
method: 'DELETE',
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
removeQueueItems: mutate,
|
|
||||||
isRemoving: isPending,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useGrabQueueItem = (id: number) => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [grabError, setGrabError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { mutate, isPending } = useApiMutation<unknown, void>({
|
|
||||||
path: `/queue/grab/${id}`,
|
|
||||||
method: 'POST',
|
|
||||||
mutationOptions: {
|
|
||||||
onMutate: () => {
|
|
||||||
setGrabError(null);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
setGrabError('Error grabbing queue item');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
grabQueueItem: mutate,
|
|
||||||
isGrabbing: isPending,
|
|
||||||
grabError,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useGrabQueueItems = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Explicitly define the types for the mutation so we can pass in no arguments to mutate as expected.
|
|
||||||
const { mutate, isPending } = useApiMutation<unknown, BulkQueueData>({
|
|
||||||
path: '/queue/grab/bulk',
|
|
||||||
method: 'POST',
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
grabQueueItems: mutate,
|
|
||||||
isGrabbing: isPending,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
215
frontend/src/AddSeries/AddNewSeries/AddNewSeries.js
Normal file
215
frontend/src/AddSeries/AddNewSeries/AddNewSeries.js
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import Alert from 'Components/Alert';
|
||||||
|
import TextInput from 'Components/Form/TextInput';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import PageContent from 'Components/Page/PageContent';
|
||||||
|
import PageContentBody from 'Components/Page/PageContentBody';
|
||||||
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
|
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import AddNewSeriesSearchResultConnector from './AddNewSeriesSearchResultConnector';
|
||||||
|
import styles from './AddNewSeries.css';
|
||||||
|
|
||||||
|
class AddNewSeries extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
term: props.term || '',
|
||||||
|
isFetching: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const term = this.state.term;
|
||||||
|
|
||||||
|
if (term) {
|
||||||
|
this.props.onSeriesLookupChange(term);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
const {
|
||||||
|
term,
|
||||||
|
isFetching
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
if (term && term !== prevProps.term) {
|
||||||
|
this.setState({
|
||||||
|
term,
|
||||||
|
isFetching: true
|
||||||
|
});
|
||||||
|
this.props.onSeriesLookupChange(term);
|
||||||
|
} else if (isFetching !== prevProps.isFetching) {
|
||||||
|
this.setState({
|
||||||
|
isFetching
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onSearchInputChange = ({ value }) => {
|
||||||
|
const hasValue = !!value.trim();
|
||||||
|
|
||||||
|
this.setState({ term: value, isFetching: hasValue }, () => {
|
||||||
|
if (hasValue) {
|
||||||
|
this.props.onSeriesLookupChange(value);
|
||||||
|
} else {
|
||||||
|
this.props.onClearSeriesLookup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onClearSeriesLookupPress = () => {
|
||||||
|
this.setState({ term: '' });
|
||||||
|
this.props.onClearSeriesLookup();
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
error,
|
||||||
|
items,
|
||||||
|
hasExistingSeries
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const term = this.state.term;
|
||||||
|
const isFetching = this.state.isFetching;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContent title={translate('AddNewSeries')}>
|
||||||
|
<PageContentBody>
|
||||||
|
<div className={styles.searchContainer}>
|
||||||
|
<div className={styles.searchIconContainer}>
|
||||||
|
<Icon
|
||||||
|
name={icons.SEARCH}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
className={styles.searchInput}
|
||||||
|
name="seriesLookup"
|
||||||
|
value={term}
|
||||||
|
placeholder="eg. Breaking Bad, tvdb:####"
|
||||||
|
autoFocus={true}
|
||||||
|
onChange={this.onSearchInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className={styles.clearLookupButton}
|
||||||
|
onPress={this.onClearSeriesLookupPress}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={icons.REMOVE}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
isFetching &&
|
||||||
|
<LoadingIndicator />
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isFetching && !!error ?
|
||||||
|
<div className={styles.message}>
|
||||||
|
<div className={styles.helpText}>
|
||||||
|
{translate('AddNewSeriesError')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
|
||||||
|
</div> : null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isFetching && !error && !!items.length &&
|
||||||
|
<div className={styles.searchResults}>
|
||||||
|
{
|
||||||
|
items.map((item) => {
|
||||||
|
return (
|
||||||
|
<AddNewSeriesSearchResultConnector
|
||||||
|
key={item.tvdbId}
|
||||||
|
{...item}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isFetching && !error && !items.length && !!term &&
|
||||||
|
<div className={styles.message}>
|
||||||
|
<div className={styles.noResults}>{translate('CouldNotFindResults', { term })}</div>
|
||||||
|
<div>{translate('SearchByTvdbId')}</div>
|
||||||
|
<div>
|
||||||
|
<Link to="https://wiki.servarr.com/sonarr/faq#why-cant-i-add-a-new-series-when-i-know-the-tvdb-id">
|
||||||
|
{translate('WhyCantIFindMyShow')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
term ?
|
||||||
|
null :
|
||||||
|
<div className={styles.message}>
|
||||||
|
<div className={styles.helpText}>
|
||||||
|
{translate('AddNewSeriesHelpText')}
|
||||||
|
</div>
|
||||||
|
<div>{translate('SearchByTvdbId')}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!term && !hasExistingSeries ?
|
||||||
|
<div className={styles.message}>
|
||||||
|
<div className={styles.noSeriesText}>
|
||||||
|
{translate('NoSeriesHaveBeenAdded')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
to="/add/import"
|
||||||
|
kind={kinds.PRIMARY}
|
||||||
|
>
|
||||||
|
{translate('ImportExistingSeries')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
<div />
|
||||||
|
</PageContentBody>
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddNewSeries.propTypes = {
|
||||||
|
term: PropTypes.string,
|
||||||
|
isFetching: PropTypes.bool.isRequired,
|
||||||
|
error: PropTypes.object,
|
||||||
|
isAdding: PropTypes.bool.isRequired,
|
||||||
|
addError: PropTypes.object,
|
||||||
|
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
hasExistingSeries: PropTypes.bool.isRequired,
|
||||||
|
onSeriesLookupChange: PropTypes.func.isRequired,
|
||||||
|
onClearSeriesLookup: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddNewSeries;
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import Alert from 'Components/Alert';
|
|
||||||
import TextInput from 'Components/Form/TextInput';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import Button from 'Components/Link/Button';
|
|
||||||
import Link from 'Components/Link/Link';
|
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
|
||||||
import PageContent from 'Components/Page/PageContent';
|
|
||||||
import PageContentBody from 'Components/Page/PageContentBody';
|
|
||||||
import useDebounce from 'Helpers/Hooks/useDebounce';
|
|
||||||
import useQueryParams from 'Helpers/Hooks/useQueryParams';
|
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
|
||||||
import { InputChanged } from 'typings/inputs';
|
|
||||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import AddNewSeriesSearchResult from './AddNewSeriesSearchResult';
|
|
||||||
import { useLookupSeries } from './useAddSeries';
|
|
||||||
import styles from './AddNewSeries.css';
|
|
||||||
|
|
||||||
function AddNewSeries() {
|
|
||||||
const { term: initialTerm = '' } = useQueryParams<{ term: string }>();
|
|
||||||
|
|
||||||
const seriesCount = useSelector(
|
|
||||||
(state: AppState) => state.series.items.length
|
|
||||||
);
|
|
||||||
|
|
||||||
const [term, setTerm] = useState(initialTerm);
|
|
||||||
const [isFetching, setIsFetching] = useState(false);
|
|
||||||
const query = useDebounce(term, term ? 300 : 0);
|
|
||||||
|
|
||||||
const handleSearchInputChange = useCallback(
|
|
||||||
({ value }: InputChanged<string>) => {
|
|
||||||
setTerm(value);
|
|
||||||
setIsFetching(!!value.trim());
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClearSeriesLookupPress = useCallback(() => {
|
|
||||||
setTerm('');
|
|
||||||
setIsFetching(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
|
||||||
isFetching: isFetchingApi,
|
|
||||||
error,
|
|
||||||
data = [],
|
|
||||||
} = useLookupSeries(query);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsFetching(isFetchingApi);
|
|
||||||
}, [isFetchingApi]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTerm(initialTerm);
|
|
||||||
}, [initialTerm]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContent title={translate('AddNewSeries')}>
|
|
||||||
<PageContentBody>
|
|
||||||
<div className={styles.searchContainer}>
|
|
||||||
<div className={styles.searchIconContainer}>
|
|
||||||
<Icon name={icons.SEARCH} size={20} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
className={styles.searchInput}
|
|
||||||
name="seriesLookup"
|
|
||||||
value={term}
|
|
||||||
placeholder="eg. Breaking Bad, tvdb:####"
|
|
||||||
autoFocus={true}
|
|
||||||
onChange={handleSearchInputChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className={styles.clearLookupButton}
|
|
||||||
onPress={handleClearSeriesLookupPress}
|
|
||||||
>
|
|
||||||
<Icon name={icons.REMOVE} size={20} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isFetching ? <LoadingIndicator /> : null}
|
|
||||||
|
|
||||||
{!isFetching && !!error ? (
|
|
||||||
<div className={styles.message}>
|
|
||||||
<div className={styles.helpText}>
|
|
||||||
{translate('AddNewSeriesError')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isFetching && !error && !!data.length ? (
|
|
||||||
<div className={styles.searchResults}>
|
|
||||||
{data.map((item) => {
|
|
||||||
return (
|
|
||||||
<AddNewSeriesSearchResult key={item.tvdbId} series={item} />
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isFetching && !error && !data.length && term ? (
|
|
||||||
<div className={styles.message}>
|
|
||||||
<div className={styles.noResults}>
|
|
||||||
{translate('CouldNotFindResults', { term })}
|
|
||||||
</div>
|
|
||||||
<div>{translate('SearchByTvdbId')}</div>
|
|
||||||
<div>
|
|
||||||
<Link to="https://wiki.servarr.com/sonarr/faq#why-cant-i-add-a-new-series-when-i-know-the-tvdb-id">
|
|
||||||
{translate('WhyCantIFindMyShow')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{term ? null : (
|
|
||||||
<div className={styles.message}>
|
|
||||||
<div className={styles.helpText}>
|
|
||||||
{translate('AddNewSeriesHelpText')}
|
|
||||||
</div>
|
|
||||||
<div>{translate('SearchByTvdbId')}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!term && !seriesCount ? (
|
|
||||||
<div className={styles.message}>
|
|
||||||
<div className={styles.noSeriesText}>
|
|
||||||
{translate('NoSeriesHaveBeenAdded')}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button to="/add/import" kind={kinds.PRIMARY}>
|
|
||||||
{translate('ImportExistingSeries')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div />
|
|
||||||
</PageContentBody>
|
|
||||||
</PageContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AddNewSeries;
|
|
||||||
104
frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js
Normal file
104
frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { clearAddSeries, lookupSeries } from 'Store/Actions/addSeriesActions';
|
||||||
|
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||||
|
import parseUrl from 'Utilities/String/parseUrl';
|
||||||
|
import AddNewSeries from './AddNewSeries';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state) => state.addSeries,
|
||||||
|
(state) => state.series.items.length,
|
||||||
|
(state) => state.router.location,
|
||||||
|
(addSeries, existingSeriesCount, location) => {
|
||||||
|
const { params } = parseUrl(location.search);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...addSeries,
|
||||||
|
term: params.term,
|
||||||
|
hasExistingSeries: existingSeriesCount > 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
lookupSeries,
|
||||||
|
clearAddSeries,
|
||||||
|
fetchRootFolders
|
||||||
|
};
|
||||||
|
|
||||||
|
class AddNewSeriesConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this._seriesLookupTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.fetchRootFolders();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this._seriesLookupTimeout) {
|
||||||
|
clearTimeout(this._seriesLookupTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.clearAddSeries();
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onSeriesLookupChange = (term) => {
|
||||||
|
if (this._seriesLookupTimeout) {
|
||||||
|
clearTimeout(this._seriesLookupTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (term.trim() === '') {
|
||||||
|
this.props.clearAddSeries();
|
||||||
|
} else {
|
||||||
|
this._seriesLookupTimeout = setTimeout(() => {
|
||||||
|
this.props.lookupSeries({ term });
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onClearSeriesLookup = () => {
|
||||||
|
this.props.clearAddSeries();
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
term,
|
||||||
|
...otherProps
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AddNewSeries
|
||||||
|
term={term}
|
||||||
|
{...otherProps}
|
||||||
|
onSeriesLookupChange={this.onSeriesLookupChange}
|
||||||
|
onClearSeriesLookup={this.onClearSeriesLookup}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddNewSeriesConnector.propTypes = {
|
||||||
|
term: PropTypes.string,
|
||||||
|
lookupSeries: PropTypes.func.isRequired,
|
||||||
|
clearAddSeries: PropTypes.func.isRequired,
|
||||||
|
fetchRootFolders: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesConnector);
|
||||||
31
frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.js
Normal file
31
frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React from 'react';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import AddNewSeriesModalContentConnector from './AddNewSeriesModalContentConnector';
|
||||||
|
|
||||||
|
function AddNewSeriesModal(props) {
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
onModalClose,
|
||||||
|
...otherProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
>
|
||||||
|
<AddNewSeriesModalContentConnector
|
||||||
|
{...otherProps}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddNewSeriesModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddNewSeriesModal;
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Modal from 'Components/Modal/Modal';
|
|
||||||
import AddNewSeriesModalContent, {
|
|
||||||
AddNewSeriesModalContentProps,
|
|
||||||
} from './AddNewSeriesModalContent';
|
|
||||||
|
|
||||||
interface AddNewSeriesModalProps extends AddNewSeriesModalContentProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddNewSeriesModal({
|
|
||||||
isOpen,
|
|
||||||
onModalClose,
|
|
||||||
...otherProps
|
|
||||||
}: AddNewSeriesModalProps) {
|
|
||||||
return (
|
|
||||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
|
||||||
<AddNewSeriesModalContent {...otherProps} onModalClose={onModalClose} />
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AddNewSeriesModal;
|
|
||||||
300
frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js
Normal file
300
frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
|
||||||
|
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
|
||||||
|
import CheckInput from 'Components/Form/CheckInput';
|
||||||
|
import Form from 'Components/Form/Form';
|
||||||
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
|
import FormLabel from 'Components/Form/FormLabel';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||||
|
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 Popover from 'Components/Tooltip/Popover';
|
||||||
|
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||||
|
import SeriesPoster from 'Series/SeriesPoster';
|
||||||
|
import * as seriesTypes from 'Utilities/Series/seriesTypes';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import styles from './AddNewSeriesModalContent.css';
|
||||||
|
|
||||||
|
class AddNewSeriesModalContent extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
seriesType: props.initialSeriesType === seriesTypes.STANDARD ?
|
||||||
|
props.seriesType.value :
|
||||||
|
props.initialSeriesType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (this.props.seriesType.value !== prevProps.seriesType.value) {
|
||||||
|
this.setState({ seriesType: this.props.seriesType.value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onQualityProfileIdChange = ({ value }) => {
|
||||||
|
this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) });
|
||||||
|
};
|
||||||
|
|
||||||
|
onAddSeriesPress = () => {
|
||||||
|
const {
|
||||||
|
seriesType
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
this.props.onAddSeriesPress(
|
||||||
|
seriesType
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
overview,
|
||||||
|
images,
|
||||||
|
isAdding,
|
||||||
|
rootFolderPath,
|
||||||
|
monitor,
|
||||||
|
qualityProfileId,
|
||||||
|
seriesType,
|
||||||
|
seasonFolder,
|
||||||
|
searchForMissingEpisodes,
|
||||||
|
searchForCutoffUnmetEpisodes,
|
||||||
|
folder,
|
||||||
|
tags,
|
||||||
|
isSmallScreen,
|
||||||
|
isWindows,
|
||||||
|
onModalClose,
|
||||||
|
onInputChange,
|
||||||
|
...otherProps
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
<ModalHeader>
|
||||||
|
{title}
|
||||||
|
|
||||||
|
{
|
||||||
|
!title.contains(year) && !!year &&
|
||||||
|
<span className={styles.year}>({year})</span>
|
||||||
|
}
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
<div className={styles.container}>
|
||||||
|
{
|
||||||
|
isSmallScreen ?
|
||||||
|
null :
|
||||||
|
<div className={styles.poster}>
|
||||||
|
<SeriesPoster
|
||||||
|
className={styles.poster}
|
||||||
|
images={images}
|
||||||
|
size={250}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className={styles.info}>
|
||||||
|
{
|
||||||
|
overview ?
|
||||||
|
<div className={styles.overview}>
|
||||||
|
{overview}
|
||||||
|
</div> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
<Form {...otherProps}>
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('RootFolder')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.ROOT_FOLDER_SELECT}
|
||||||
|
name="rootFolderPath"
|
||||||
|
valueOptions={{
|
||||||
|
seriesFolder: folder,
|
||||||
|
isWindows
|
||||||
|
}}
|
||||||
|
selectedValueOptions={{
|
||||||
|
seriesFolder: folder,
|
||||||
|
isWindows
|
||||||
|
}}
|
||||||
|
helpText={translate('AddNewSeriesRootFolderHelpText', { folder })}
|
||||||
|
onChange={onInputChange}
|
||||||
|
{...rootFolderPath}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>
|
||||||
|
{translate('Monitor')}
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
anchor={
|
||||||
|
<Icon
|
||||||
|
className={styles.labelIcon}
|
||||||
|
name={icons.INFO}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={translate('MonitoringOptions')}
|
||||||
|
body={<SeriesMonitoringOptionsPopoverContent />}
|
||||||
|
position={tooltipPositions.RIGHT}
|
||||||
|
/>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.MONITOR_EPISODES_SELECT}
|
||||||
|
name="monitor"
|
||||||
|
onChange={onInputChange}
|
||||||
|
{...monitor}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('QualityProfile')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||||
|
name="qualityProfileId"
|
||||||
|
onChange={this.onQualityProfileIdChange}
|
||||||
|
{...qualityProfileId}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>
|
||||||
|
{translate('SeriesType')}
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
anchor={
|
||||||
|
<Icon
|
||||||
|
className={styles.labelIcon}
|
||||||
|
name={icons.INFO}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={translate('SeriesTypes')}
|
||||||
|
body={<SeriesTypePopoverContent />}
|
||||||
|
position={tooltipPositions.RIGHT}
|
||||||
|
/>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.SERIES_TYPE_SELECT}
|
||||||
|
name="seriesType"
|
||||||
|
onChange={onInputChange}
|
||||||
|
{...seriesType}
|
||||||
|
value={this.state.seriesType}
|
||||||
|
helpText={translate('SeriesTypesHelpText')}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('SeasonFolder')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="seasonFolder"
|
||||||
|
onChange={onInputChange}
|
||||||
|
{...seasonFolder}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('Tags')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.TAG}
|
||||||
|
name="tags"
|
||||||
|
onChange={onInputChange}
|
||||||
|
{...tags}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter className={styles.modalFooter}>
|
||||||
|
<div>
|
||||||
|
<label className={styles.searchLabelContainer}>
|
||||||
|
<span className={styles.searchLabel}>
|
||||||
|
{translate('AddNewSeriesSearchForMissingEpisodes')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<CheckInput
|
||||||
|
containerClassName={styles.searchInputContainer}
|
||||||
|
className={styles.searchInput}
|
||||||
|
name="searchForMissingEpisodes"
|
||||||
|
onChange={onInputChange}
|
||||||
|
{...searchForMissingEpisodes}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={styles.searchLabelContainer}>
|
||||||
|
<span className={styles.searchLabel}>
|
||||||
|
{translate('AddNewSeriesSearchForCutoffUnmetEpisodes')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<CheckInput
|
||||||
|
containerClassName={styles.searchInputContainer}
|
||||||
|
className={styles.searchInput}
|
||||||
|
name="searchForCutoffUnmetEpisodes"
|
||||||
|
onChange={onInputChange}
|
||||||
|
{...searchForCutoffUnmetEpisodes}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SpinnerButton
|
||||||
|
className={styles.addButton}
|
||||||
|
kind={kinds.SUCCESS}
|
||||||
|
isSpinning={isAdding}
|
||||||
|
onPress={this.onAddSeriesPress}
|
||||||
|
>
|
||||||
|
{translate('AddSeriesWithTitle', { title })}
|
||||||
|
</SpinnerButton>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddNewSeriesModalContent.propTypes = {
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
year: PropTypes.number.isRequired,
|
||||||
|
overview: PropTypes.string,
|
||||||
|
initialSeriesType: PropTypes.string.isRequired,
|
||||||
|
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
isAdding: PropTypes.bool.isRequired,
|
||||||
|
addError: PropTypes.object,
|
||||||
|
rootFolderPath: PropTypes.object,
|
||||||
|
monitor: PropTypes.object.isRequired,
|
||||||
|
qualityProfileId: PropTypes.object,
|
||||||
|
seriesType: PropTypes.object.isRequired,
|
||||||
|
seasonFolder: PropTypes.object.isRequired,
|
||||||
|
searchForMissingEpisodes: PropTypes.object.isRequired,
|
||||||
|
searchForCutoffUnmetEpisodes: PropTypes.object.isRequired,
|
||||||
|
folder: PropTypes.string.isRequired,
|
||||||
|
tags: PropTypes.object.isRequired,
|
||||||
|
isSmallScreen: PropTypes.bool.isRequired,
|
||||||
|
isWindows: PropTypes.bool.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired,
|
||||||
|
onInputChange: PropTypes.func.isRequired,
|
||||||
|
onAddSeriesPress: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddNewSeriesModalContent;
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import AddSeries from 'AddSeries/AddSeries';
|
|
||||||
import {
|
|
||||||
AddSeriesOptions,
|
|
||||||
setAddSeriesOption,
|
|
||||||
useAddSeriesOptions,
|
|
||||||
} from 'AddSeries/addSeriesOptionsStore';
|
|
||||||
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
|
|
||||||
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
|
|
||||||
import CheckInput from 'Components/Form/CheckInput';
|
|
||||||
import Form from 'Components/Form/Form';
|
|
||||||
import FormGroup from 'Components/Form/FormGroup';
|
|
||||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
|
||||||
import FormLabel from 'Components/Form/FormLabel';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
|
||||||
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 Popover from 'Components/Tooltip/Popover';
|
|
||||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
|
||||||
import { SeriesType } from 'Series/Series';
|
|
||||||
import SeriesPoster from 'Series/SeriesPoster';
|
|
||||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
|
||||||
import selectSettings from 'Store/Selectors/selectSettings';
|
|
||||||
import useIsWindows from 'System/useIsWindows';
|
|
||||||
import { InputChanged } from 'typings/inputs';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import { useAddSeries } from './useAddSeries';
|
|
||||||
import styles from './AddNewSeriesModalContent.css';
|
|
||||||
|
|
||||||
export interface AddNewSeriesModalContentProps {
|
|
||||||
series: AddSeries;
|
|
||||||
initialSeriesType: SeriesType;
|
|
||||||
onModalClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddNewSeriesModalContent({
|
|
||||||
series,
|
|
||||||
initialSeriesType,
|
|
||||||
onModalClose,
|
|
||||||
}: AddNewSeriesModalContentProps) {
|
|
||||||
const { title, year, overview, images, folder } = series;
|
|
||||||
const options = useAddSeriesOptions();
|
|
||||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
|
||||||
const isWindows = useIsWindows();
|
|
||||||
|
|
||||||
const { isAdding, addError, addSeries } = useAddSeries();
|
|
||||||
|
|
||||||
const { settings, validationErrors, validationWarnings } = useMemo(() => {
|
|
||||||
return selectSettings(options, {}, addError);
|
|
||||||
}, [options, addError]);
|
|
||||||
|
|
||||||
const [seriesType, setSeriesType] = useState<SeriesType>(
|
|
||||||
initialSeriesType === 'standard'
|
|
||||||
? settings.seriesType.value
|
|
||||||
: initialSeriesType
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
monitor,
|
|
||||||
qualityProfileId,
|
|
||||||
rootFolderPath,
|
|
||||||
searchForCutoffUnmetEpisodes,
|
|
||||||
searchForMissingEpisodes,
|
|
||||||
seasonFolder,
|
|
||||||
seriesType: seriesTypeSetting,
|
|
||||||
tags,
|
|
||||||
} = settings;
|
|
||||||
|
|
||||||
const handleInputChange = useCallback(
|
|
||||||
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
|
|
||||||
setAddSeriesOption(name as keyof AddSeriesOptions, value);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleQualityProfileIdChange = useCallback(
|
|
||||||
({ value }: InputChanged<string | number>) => {
|
|
||||||
setAddSeriesOption('qualityProfileId', value as number);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddSeriesPress = useCallback(() => {
|
|
||||||
addSeries({
|
|
||||||
...series,
|
|
||||||
rootFolderPath: rootFolderPath.value,
|
|
||||||
monitor: monitor.value,
|
|
||||||
qualityProfileId: qualityProfileId.value,
|
|
||||||
seriesType,
|
|
||||||
seasonFolder: seasonFolder.value,
|
|
||||||
searchForMissingEpisodes: searchForMissingEpisodes.value,
|
|
||||||
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
|
|
||||||
tags: tags.value,
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
series,
|
|
||||||
seriesType,
|
|
||||||
rootFolderPath,
|
|
||||||
monitor,
|
|
||||||
qualityProfileId,
|
|
||||||
seasonFolder,
|
|
||||||
searchForMissingEpisodes,
|
|
||||||
searchForCutoffUnmetEpisodes,
|
|
||||||
tags,
|
|
||||||
addSeries,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSeriesType(seriesTypeSetting.value);
|
|
||||||
}, [seriesTypeSetting]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalContent onModalClose={onModalClose}>
|
|
||||||
<ModalHeader>
|
|
||||||
{title}
|
|
||||||
|
|
||||||
{!title.includes(String(year)) && year ? (
|
|
||||||
<span className={styles.year}>({year})</span>
|
|
||||||
) : null}
|
|
||||||
</ModalHeader>
|
|
||||||
|
|
||||||
<ModalBody>
|
|
||||||
<div className={styles.container}>
|
|
||||||
{isSmallScreen ? null : (
|
|
||||||
<div className={styles.poster}>
|
|
||||||
<SeriesPoster
|
|
||||||
className={styles.poster}
|
|
||||||
images={images}
|
|
||||||
size={250}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.info}>
|
|
||||||
{overview ? (
|
|
||||||
<div className={styles.overview}>{overview}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Form
|
|
||||||
validationErrors={validationErrors}
|
|
||||||
validationWarnings={validationWarnings}
|
|
||||||
>
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('RootFolder')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.ROOT_FOLDER_SELECT}
|
|
||||||
name="rootFolderPath"
|
|
||||||
valueOptions={{
|
|
||||||
seriesFolder: folder,
|
|
||||||
isWindows,
|
|
||||||
}}
|
|
||||||
selectedValueOptions={{
|
|
||||||
seriesFolder: folder,
|
|
||||||
isWindows,
|
|
||||||
}}
|
|
||||||
helpText={translate('AddNewSeriesRootFolderHelpText', {
|
|
||||||
folder,
|
|
||||||
})}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
{...rootFolderPath}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>
|
|
||||||
{translate('Monitor')}
|
|
||||||
|
|
||||||
<Popover
|
|
||||||
anchor={
|
|
||||||
<Icon className={styles.labelIcon} name={icons.INFO} />
|
|
||||||
}
|
|
||||||
title={translate('MonitoringOptions')}
|
|
||||||
body={<SeriesMonitoringOptionsPopoverContent />}
|
|
||||||
position={tooltipPositions.RIGHT}
|
|
||||||
/>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.MONITOR_EPISODES_SELECT}
|
|
||||||
name="monitor"
|
|
||||||
onChange={handleInputChange}
|
|
||||||
{...monitor}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('QualityProfile')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
|
||||||
name="qualityProfileId"
|
|
||||||
onChange={handleQualityProfileIdChange}
|
|
||||||
{...qualityProfileId}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>
|
|
||||||
{translate('SeriesType')}
|
|
||||||
|
|
||||||
<Popover
|
|
||||||
anchor={
|
|
||||||
<Icon className={styles.labelIcon} name={icons.INFO} />
|
|
||||||
}
|
|
||||||
title={translate('SeriesTypes')}
|
|
||||||
body={<SeriesTypePopoverContent />}
|
|
||||||
position={tooltipPositions.RIGHT}
|
|
||||||
/>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.SERIES_TYPE_SELECT}
|
|
||||||
name="seriesType"
|
|
||||||
onChange={handleInputChange}
|
|
||||||
{...seriesTypeSetting}
|
|
||||||
value={seriesType}
|
|
||||||
helpText={translate('SeriesTypesHelpText')}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('SeasonFolder')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.CHECK}
|
|
||||||
name="seasonFolder"
|
|
||||||
onChange={handleInputChange}
|
|
||||||
{...seasonFolder}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('Tags')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.TAG}
|
|
||||||
name="tags"
|
|
||||||
onChange={handleInputChange}
|
|
||||||
{...tags}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ModalBody>
|
|
||||||
|
|
||||||
<ModalFooter className={styles.modalFooter}>
|
|
||||||
<div>
|
|
||||||
<label className={styles.searchLabelContainer}>
|
|
||||||
<span className={styles.searchLabel}>
|
|
||||||
{translate('AddNewSeriesSearchForMissingEpisodes')}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<CheckInput
|
|
||||||
containerClassName={styles.searchInputContainer}
|
|
||||||
className={styles.searchInput}
|
|
||||||
name="searchForMissingEpisodes"
|
|
||||||
onChange={handleInputChange}
|
|
||||||
{...searchForMissingEpisodes}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className={styles.searchLabelContainer}>
|
|
||||||
<span className={styles.searchLabel}>
|
|
||||||
{translate('AddNewSeriesSearchForCutoffUnmetEpisodes')}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<CheckInput
|
|
||||||
containerClassName={styles.searchInputContainer}
|
|
||||||
className={styles.searchInput}
|
|
||||||
name="searchForCutoffUnmetEpisodes"
|
|
||||||
onChange={handleInputChange}
|
|
||||||
{...searchForCutoffUnmetEpisodes}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SpinnerButton
|
|
||||||
className={styles.addButton}
|
|
||||||
kind={kinds.SUCCESS}
|
|
||||||
isSpinning={isAdding}
|
|
||||||
onPress={handleAddSeriesPress}
|
|
||||||
>
|
|
||||||
{translate('AddSeriesWithTitle', { title })}
|
|
||||||
</SpinnerButton>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AddNewSeriesModalContent;
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { addSeries, setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
|
||||||
|
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||||
|
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||||
|
import selectSettings from 'Store/Selectors/selectSettings';
|
||||||
|
import AddNewSeriesModalContent from './AddNewSeriesModalContent';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state) => state.addSeries,
|
||||||
|
createDimensionsSelector(),
|
||||||
|
createSystemStatusSelector(),
|
||||||
|
(addSeriesState, dimensions, systemStatus) => {
|
||||||
|
const {
|
||||||
|
isAdding,
|
||||||
|
addError,
|
||||||
|
defaults
|
||||||
|
} = addSeriesState;
|
||||||
|
|
||||||
|
const {
|
||||||
|
settings,
|
||||||
|
validationErrors,
|
||||||
|
validationWarnings
|
||||||
|
} = selectSettings(defaults, {}, addError);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAdding,
|
||||||
|
addError,
|
||||||
|
isSmallScreen: dimensions.isSmallScreen,
|
||||||
|
validationErrors,
|
||||||
|
validationWarnings,
|
||||||
|
isWindows: systemStatus.isWindows,
|
||||||
|
...settings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
setAddSeriesDefault,
|
||||||
|
addSeries
|
||||||
|
};
|
||||||
|
|
||||||
|
class AddNewSeriesModalContentConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onInputChange = ({ name, value }) => {
|
||||||
|
this.props.setAddSeriesDefault({ [name]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
onAddSeriesPress = (seriesType) => {
|
||||||
|
const {
|
||||||
|
tvdbId,
|
||||||
|
rootFolderPath,
|
||||||
|
monitor,
|
||||||
|
qualityProfileId,
|
||||||
|
seasonFolder,
|
||||||
|
searchForMissingEpisodes,
|
||||||
|
searchForCutoffUnmetEpisodes,
|
||||||
|
tags
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
this.props.addSeries({
|
||||||
|
tvdbId,
|
||||||
|
rootFolderPath: rootFolderPath.value,
|
||||||
|
monitor: monitor.value,
|
||||||
|
qualityProfileId: qualityProfileId.value,
|
||||||
|
seriesType,
|
||||||
|
seasonFolder: seasonFolder.value,
|
||||||
|
searchForMissingEpisodes: searchForMissingEpisodes.value,
|
||||||
|
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
|
||||||
|
tags: tags.value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<AddNewSeriesModalContent
|
||||||
|
{...this.props}
|
||||||
|
onInputChange={this.onInputChange}
|
||||||
|
onAddSeriesPress={this.onAddSeriesPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddNewSeriesModalContentConnector.propTypes = {
|
||||||
|
tvdbId: PropTypes.number.isRequired,
|
||||||
|
rootFolderPath: PropTypes.object,
|
||||||
|
monitor: PropTypes.object.isRequired,
|
||||||
|
qualityProfileId: PropTypes.object,
|
||||||
|
seriesType: PropTypes.object.isRequired,
|
||||||
|
seasonFolder: PropTypes.object.isRequired,
|
||||||
|
searchForMissingEpisodes: PropTypes.object.isRequired,
|
||||||
|
searchForCutoffUnmetEpisodes: PropTypes.object.isRequired,
|
||||||
|
tags: PropTypes.object.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired,
|
||||||
|
setAddSeriesDefault: PropTypes.func.isRequired,
|
||||||
|
addSeries: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesModalContentConnector);
|
||||||
276
frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js
Normal file
276
frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import HeartRating from 'Components/HeartRating';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import Label from 'Components/Label';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import MetadataAttribution from 'Components/MetadataAttribution';
|
||||||
|
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||||
|
import SeriesGenres from 'Series/SeriesGenres';
|
||||||
|
import SeriesPoster from 'Series/SeriesPoster';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import AddNewSeriesModal from './AddNewSeriesModal';
|
||||||
|
import styles from './AddNewSeriesSearchResult.css';
|
||||||
|
|
||||||
|
class AddNewSeriesSearchResult extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
isNewAddSeriesModalOpen: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (!prevProps.isExistingSeries && this.props.isExistingSeries) {
|
||||||
|
this.onAddSeriesModalClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onPress = () => {
|
||||||
|
this.setState({ isNewAddSeriesModalOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
onAddSeriesModalClose = () => {
|
||||||
|
this.setState({ isNewAddSeriesModalOpen: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
onTVDBLinkPress = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
tvdbId,
|
||||||
|
title,
|
||||||
|
titleSlug,
|
||||||
|
year,
|
||||||
|
network,
|
||||||
|
originalLanguage,
|
||||||
|
genres,
|
||||||
|
status,
|
||||||
|
overview,
|
||||||
|
statistics,
|
||||||
|
ratings,
|
||||||
|
folder,
|
||||||
|
seriesType,
|
||||||
|
images,
|
||||||
|
isExistingSeries,
|
||||||
|
isSmallScreen
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const seasonCount = statistics.seasonCount;
|
||||||
|
|
||||||
|
const {
|
||||||
|
isNewAddSeriesModalOpen
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
const linkProps = isExistingSeries ? { to: `/series/${titleSlug}` } : { onPress: this.onPress };
|
||||||
|
let seasons = translate('OneSeason');
|
||||||
|
|
||||||
|
if (seasonCount > 1) {
|
||||||
|
seasons = translate('CountSeasons', { count: seasonCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.searchResult}>
|
||||||
|
<Link
|
||||||
|
className={styles.underlay}
|
||||||
|
{...linkProps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.overlay}>
|
||||||
|
{
|
||||||
|
isSmallScreen ?
|
||||||
|
null :
|
||||||
|
<SeriesPoster
|
||||||
|
className={styles.poster}
|
||||||
|
images={images}
|
||||||
|
size={250}
|
||||||
|
overflow={true}
|
||||||
|
lazy={false}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.titleRow}>
|
||||||
|
<div className={styles.titleContainer}>
|
||||||
|
<div className={styles.title}>
|
||||||
|
{title}
|
||||||
|
|
||||||
|
{
|
||||||
|
!title.contains(year) && year ?
|
||||||
|
<span className={styles.year}>
|
||||||
|
({year})
|
||||||
|
</span> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.icons}>
|
||||||
|
{
|
||||||
|
isExistingSeries ?
|
||||||
|
<Icon
|
||||||
|
className={styles.alreadyExistsIcon}
|
||||||
|
name={icons.CHECK_CIRCLE}
|
||||||
|
size={36}
|
||||||
|
title={translate('AlreadyInYourLibrary')}
|
||||||
|
/> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
className={styles.tvdbLink}
|
||||||
|
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}
|
||||||
|
onPress={this.onTVDBLinkPress}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={styles.tvdbLinkIcon}
|
||||||
|
name={icons.EXTERNAL_LINK}
|
||||||
|
size={28}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label size={sizes.LARGE}>
|
||||||
|
<HeartRating
|
||||||
|
rating={ratings.value}
|
||||||
|
votes={ratings.votes}
|
||||||
|
iconSize={13}
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{
|
||||||
|
originalLanguage?.name ?
|
||||||
|
<Label size={sizes.LARGE}>
|
||||||
|
<Icon
|
||||||
|
name={icons.LANGUAGE}
|
||||||
|
size={13}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className={styles.originalLanguageName}>
|
||||||
|
{originalLanguage.name}
|
||||||
|
</span>
|
||||||
|
</Label> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
network ?
|
||||||
|
<Label size={sizes.LARGE}>
|
||||||
|
<Icon
|
||||||
|
name={icons.NETWORK}
|
||||||
|
size={13}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className={styles.network}>
|
||||||
|
{network}
|
||||||
|
</span>
|
||||||
|
</Label> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
genres.length > 0 ?
|
||||||
|
<Label size={sizes.LARGE}>
|
||||||
|
<Icon
|
||||||
|
name={icons.GENRE}
|
||||||
|
size={13}
|
||||||
|
/>
|
||||||
|
<SeriesGenres className={styles.genres} genres={genres} />
|
||||||
|
</Label> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
seasonCount ?
|
||||||
|
<Label size={sizes.LARGE}>
|
||||||
|
{seasons}
|
||||||
|
</Label> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
status === 'ended' ?
|
||||||
|
<Label
|
||||||
|
kind={kinds.DANGER}
|
||||||
|
size={sizes.LARGE}
|
||||||
|
>
|
||||||
|
{translate('Ended')}
|
||||||
|
</Label> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
status === 'upcoming' ?
|
||||||
|
<Label
|
||||||
|
kind={kinds.INFO}
|
||||||
|
size={sizes.LARGE}
|
||||||
|
>
|
||||||
|
{translate('Upcoming')}
|
||||||
|
</Label> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.overview}>
|
||||||
|
{overview}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MetadataAttribution />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddNewSeriesModal
|
||||||
|
isOpen={isNewAddSeriesModalOpen && !isExistingSeries}
|
||||||
|
tvdbId={tvdbId}
|
||||||
|
title={title}
|
||||||
|
year={year}
|
||||||
|
overview={overview}
|
||||||
|
folder={folder}
|
||||||
|
initialSeriesType={seriesType}
|
||||||
|
images={images}
|
||||||
|
onModalClose={this.onAddSeriesModalClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddNewSeriesSearchResult.propTypes = {
|
||||||
|
tvdbId: PropTypes.number.isRequired,
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
titleSlug: PropTypes.string.isRequired,
|
||||||
|
year: PropTypes.number.isRequired,
|
||||||
|
network: PropTypes.string,
|
||||||
|
originalLanguage: PropTypes.object,
|
||||||
|
genres: PropTypes.arrayOf(PropTypes.string),
|
||||||
|
status: PropTypes.string.isRequired,
|
||||||
|
overview: PropTypes.string,
|
||||||
|
statistics: PropTypes.object.isRequired,
|
||||||
|
ratings: PropTypes.object.isRequired,
|
||||||
|
folder: PropTypes.string.isRequired,
|
||||||
|
seriesType: PropTypes.string.isRequired,
|
||||||
|
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
isExistingSeries: PropTypes.bool.isRequired,
|
||||||
|
isSmallScreen: PropTypes.bool.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
AddNewSeriesSearchResult.defaultProps = {
|
||||||
|
genres: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddNewSeriesSearchResult;
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import AddSeries from 'AddSeries/AddSeries';
|
|
||||||
import HeartRating from 'Components/HeartRating';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import Label from 'Components/Label';
|
|
||||||
import Link from 'Components/Link/Link';
|
|
||||||
import MetadataAttribution from 'Components/MetadataAttribution';
|
|
||||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
|
||||||
import { Statistics } from 'Series/Series';
|
|
||||||
import SeriesGenres from 'Series/SeriesGenres';
|
|
||||||
import SeriesPoster from 'Series/SeriesPoster';
|
|
||||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
|
||||||
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import AddNewSeriesModal from './AddNewSeriesModal';
|
|
||||||
import styles from './AddNewSeriesSearchResult.css';
|
|
||||||
|
|
||||||
interface AddNewSeriesSearchResultProps {
|
|
||||||
series: AddSeries;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
|
|
||||||
const {
|
|
||||||
tvdbId,
|
|
||||||
titleSlug,
|
|
||||||
title,
|
|
||||||
year,
|
|
||||||
network,
|
|
||||||
originalLanguage,
|
|
||||||
genres = [],
|
|
||||||
status,
|
|
||||||
statistics = {} as Statistics,
|
|
||||||
ratings,
|
|
||||||
overview,
|
|
||||||
seriesType,
|
|
||||||
images,
|
|
||||||
} = series;
|
|
||||||
|
|
||||||
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
|
|
||||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
|
||||||
const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const seasonCount = statistics.seasonCount;
|
|
||||||
const handlePress = useCallback(() => {
|
|
||||||
setIsNewAddSeriesModalOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAddSeriesModalClose = useCallback(() => {
|
|
||||||
setIsNewAddSeriesModalOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTvdbLinkPress = useCallback((event: React.SyntheticEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const linkProps = isExistingSeries
|
|
||||||
? { to: `/series/${titleSlug}` }
|
|
||||||
: { onPress: handlePress };
|
|
||||||
let seasons = translate('OneSeason');
|
|
||||||
|
|
||||||
if (seasonCount > 1) {
|
|
||||||
seasons = translate('CountSeasons', { count: seasonCount });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.searchResult}>
|
|
||||||
<Link className={styles.underlay} {...linkProps} />
|
|
||||||
|
|
||||||
<div className={styles.overlay}>
|
|
||||||
{isSmallScreen ? null : (
|
|
||||||
<SeriesPoster
|
|
||||||
className={styles.poster}
|
|
||||||
images={images}
|
|
||||||
size={250}
|
|
||||||
overflow={true}
|
|
||||||
lazy={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.content}>
|
|
||||||
<div className={styles.titleRow}>
|
|
||||||
<div className={styles.titleContainer}>
|
|
||||||
<div className={styles.title}>
|
|
||||||
{title}
|
|
||||||
|
|
||||||
{!title.includes(String(year)) && year ? (
|
|
||||||
<span className={styles.year}>({year})</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.icons}>
|
|
||||||
{isExistingSeries ? (
|
|
||||||
<Icon
|
|
||||||
className={styles.alreadyExistsIcon}
|
|
||||||
name={icons.CHECK_CIRCLE}
|
|
||||||
size={36}
|
|
||||||
title={translate('AlreadyInYourLibrary')}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Link
|
|
||||||
className={styles.tvdbLink}
|
|
||||||
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}
|
|
||||||
onPress={handleTvdbLinkPress}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className={styles.tvdbLinkIcon}
|
|
||||||
name={icons.EXTERNAL_LINK}
|
|
||||||
size={28}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label size={sizes.LARGE}>
|
|
||||||
<HeartRating
|
|
||||||
rating={ratings.value}
|
|
||||||
votes={ratings.votes}
|
|
||||||
iconSize={13}
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
{originalLanguage?.name ? (
|
|
||||||
<Label size={sizes.LARGE}>
|
|
||||||
<Icon name={icons.LANGUAGE} size={13} />
|
|
||||||
|
|
||||||
<span className={styles.originalLanguageName}>
|
|
||||||
{originalLanguage.name}
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{network ? (
|
|
||||||
<Label size={sizes.LARGE}>
|
|
||||||
<Icon name={icons.NETWORK} size={13} />
|
|
||||||
|
|
||||||
<span className={styles.network}>{network}</span>
|
|
||||||
</Label>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{genres.length > 0 ? (
|
|
||||||
<Label size={sizes.LARGE}>
|
|
||||||
<Icon name={icons.GENRE} size={13} />
|
|
||||||
<SeriesGenres className={styles.genres} genres={genres} />
|
|
||||||
</Label>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{seasonCount ? <Label size={sizes.LARGE}>{seasons}</Label> : null}
|
|
||||||
|
|
||||||
{status === 'ended' ? (
|
|
||||||
<Label kind={kinds.DANGER} size={sizes.LARGE}>
|
|
||||||
{translate('Ended')}
|
|
||||||
</Label>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{status === 'upcoming' ? (
|
|
||||||
<Label kind={kinds.INFO} size={sizes.LARGE}>
|
|
||||||
{translate('Upcoming')}
|
|
||||||
</Label>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.overview}>{overview}</div>
|
|
||||||
|
|
||||||
<MetadataAttribution />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AddNewSeriesModal
|
|
||||||
isOpen={isNewAddSeriesModalOpen && !isExistingSeries}
|
|
||||||
series={series}
|
|
||||||
initialSeriesType={seriesType}
|
|
||||||
onModalClose={handleAddSeriesModalClose}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AddNewSeriesSearchResult;
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||||
|
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
|
||||||
|
import AddNewSeriesSearchResult from './AddNewSeriesSearchResult';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
createExistingSeriesSelector(),
|
||||||
|
createDimensionsSelector(),
|
||||||
|
(isExistingSeries, dimensions) => {
|
||||||
|
return {
|
||||||
|
isExistingSeries,
|
||||||
|
isSmallScreen: dimensions.isSmallScreen
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps)(AddNewSeriesSearchResult);
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
import AddSeries from 'AddSeries/AddSeries';
|
|
||||||
import { AddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
|
|
||||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
|
||||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
|
||||||
import Series from 'Series/Series';
|
|
||||||
import { updateItem } from 'Store/Actions/baseActions';
|
|
||||||
|
|
||||||
type AddSeriesPayload = AddSeries & AddSeriesOptions;
|
|
||||||
|
|
||||||
export const useLookupSeries = (query: string) => {
|
|
||||||
return useApiQuery<AddSeries[]>({
|
|
||||||
path: '/series/lookup',
|
|
||||||
queryParams: {
|
|
||||||
term: query,
|
|
||||||
},
|
|
||||||
queryOptions: {
|
|
||||||
enabled: !!query,
|
|
||||||
// Disable refetch on window focus to prevent refetching when the user switch tabs
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useAddSeries = () => {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const onAddSuccess = useCallback(
|
|
||||||
(data: Series) => {
|
|
||||||
dispatch(updateItem({ section: 'series', ...data }));
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { isPending, error, mutate } = useApiMutation<Series, AddSeriesPayload>(
|
|
||||||
{
|
|
||||||
path: '/series',
|
|
||||||
method: 'POST',
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: onAddSuccess,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isAdding: isPending,
|
|
||||||
addError: error,
|
|
||||||
addSeries: mutate,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import Series from 'Series/Series';
|
|
||||||
|
|
||||||
interface AddSeries extends Series {
|
|
||||||
folder: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AddSeries;
|
|
||||||
179
frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js
Normal file
179
frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { reduce } from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import Alert from 'Components/Alert';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import PageContent from 'Components/Page/PageContent';
|
||||||
|
import PageContentBody from 'Components/Page/PageContentBody';
|
||||||
|
import { kinds } from 'Helpers/Props';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import selectAll from 'Utilities/Table/selectAll';
|
||||||
|
import toggleSelected from 'Utilities/Table/toggleSelected';
|
||||||
|
import ImportSeriesFooterConnector from './ImportSeriesFooterConnector';
|
||||||
|
import ImportSeriesTableConnector from './ImportSeriesTableConnector';
|
||||||
|
|
||||||
|
class ImportSeries extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.scrollerRef = React.createRef();
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
allSelected: false,
|
||||||
|
allUnselected: false,
|
||||||
|
lastToggled: null,
|
||||||
|
selectedState: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
getSelectedIds = () => {
|
||||||
|
return reduce(
|
||||||
|
this.state.selectedState,
|
||||||
|
(result, value, id) => {
|
||||||
|
if (value) {
|
||||||
|
result.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
onSelectAllChange = ({ value }) => {
|
||||||
|
// Only select non-dupes
|
||||||
|
this.setState(selectAll(this.state.selectedState, value));
|
||||||
|
};
|
||||||
|
|
||||||
|
onSelectedChange = ({ id, value, shiftKey = false }) => {
|
||||||
|
this.setState((state) => {
|
||||||
|
return toggleSelected(state, this.props.items, id, value, shiftKey);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onRemoveSelectedStateItem = (id) => {
|
||||||
|
this.setState((state) => {
|
||||||
|
const selectedState = Object.assign({}, state.selectedState);
|
||||||
|
delete selectedState[id];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selectedState
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onInputChange = ({ name, value }) => {
|
||||||
|
this.props.onInputChange(this.getSelectedIds(), name, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
onImportPress = () => {
|
||||||
|
this.props.onImportPress(this.getSelectedIds());
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
rootFolderId,
|
||||||
|
path,
|
||||||
|
rootFoldersFetching,
|
||||||
|
rootFoldersPopulated,
|
||||||
|
rootFoldersError,
|
||||||
|
unmappedFolders
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
allSelected,
|
||||||
|
allUnselected,
|
||||||
|
selectedState
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContent title={translate('ImportSeries')}>
|
||||||
|
<PageContentBody ref={this.scrollerRef} >
|
||||||
|
{
|
||||||
|
rootFoldersFetching ? <LoadingIndicator /> : null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!rootFoldersFetching && !!rootFoldersError ?
|
||||||
|
<Alert kind={kinds.DANGER}>
|
||||||
|
{translate('RootFoldersLoadError')}
|
||||||
|
</Alert> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!rootFoldersError &&
|
||||||
|
!rootFoldersFetching &&
|
||||||
|
rootFoldersPopulated &&
|
||||||
|
!unmappedFolders.length ?
|
||||||
|
<Alert kind={kinds.INFO}>
|
||||||
|
{translate('AllSeriesInRootFolderHaveBeenImported', { path })}
|
||||||
|
</Alert> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!rootFoldersError &&
|
||||||
|
!rootFoldersFetching &&
|
||||||
|
rootFoldersPopulated &&
|
||||||
|
!!unmappedFolders.length &&
|
||||||
|
this.scrollerRef.current ?
|
||||||
|
<ImportSeriesTableConnector
|
||||||
|
rootFolderId={rootFolderId}
|
||||||
|
unmappedFolders={unmappedFolders}
|
||||||
|
allSelected={allSelected}
|
||||||
|
allUnselected={allUnselected}
|
||||||
|
selectedState={selectedState}
|
||||||
|
scroller={this.scrollerRef.current}
|
||||||
|
onSelectAllChange={this.onSelectAllChange}
|
||||||
|
onSelectedChange={this.onSelectedChange}
|
||||||
|
onRemoveSelectedStateItem={this.onRemoveSelectedStateItem}
|
||||||
|
/> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</PageContentBody>
|
||||||
|
|
||||||
|
{
|
||||||
|
!rootFoldersError &&
|
||||||
|
!rootFoldersFetching &&
|
||||||
|
!!unmappedFolders.length ?
|
||||||
|
<ImportSeriesFooterConnector
|
||||||
|
selectedIds={this.getSelectedIds()}
|
||||||
|
onInputChange={this.onInputChange}
|
||||||
|
onImportPress={this.onImportPress}
|
||||||
|
/> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportSeries.propTypes = {
|
||||||
|
rootFolderId: PropTypes.number.isRequired,
|
||||||
|
path: PropTypes.string,
|
||||||
|
rootFoldersFetching: PropTypes.bool.isRequired,
|
||||||
|
rootFoldersPopulated: PropTypes.bool.isRequired,
|
||||||
|
rootFoldersError: PropTypes.object,
|
||||||
|
unmappedFolders: PropTypes.arrayOf(PropTypes.object),
|
||||||
|
items: PropTypes.arrayOf(PropTypes.object),
|
||||||
|
onInputChange: PropTypes.func.isRequired,
|
||||||
|
onImportPress: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
ImportSeries.defaultProps = {
|
||||||
|
unmappedFolders: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportSeries;
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useRef } from 'react';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import { useParams } from 'react-router';
|
|
||||||
import {
|
|
||||||
setAddSeriesOption,
|
|
||||||
useAddSeriesOption,
|
|
||||||
} from 'AddSeries/addSeriesOptionsStore';
|
|
||||||
import { SelectProvider } from 'App/SelectContext';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import Alert from 'Components/Alert';
|
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
|
||||||
import PageContent from 'Components/Page/PageContent';
|
|
||||||
import PageContentBody from 'Components/Page/PageContentBody';
|
|
||||||
import { kinds } from 'Helpers/Props';
|
|
||||||
import { clearImportSeries } from 'Store/Actions/importSeriesActions';
|
|
||||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import ImportSeriesFooter from './ImportSeriesFooter';
|
|
||||||
import ImportSeriesTable from './ImportSeriesTable';
|
|
||||||
|
|
||||||
function ImportSeries() {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const { rootFolderId: rootFolderIdString } = useParams<{
|
|
||||||
rootFolderId: string;
|
|
||||||
}>();
|
|
||||||
const rootFolderId = parseInt(rootFolderIdString);
|
|
||||||
|
|
||||||
const {
|
|
||||||
isFetching: rootFoldersFetching,
|
|
||||||
isPopulated: rootFoldersPopulated,
|
|
||||||
error: rootFoldersError,
|
|
||||||
items: rootFolders,
|
|
||||||
} = useSelector((state: AppState) => state.rootFolders);
|
|
||||||
|
|
||||||
const { path, unmappedFolders } = useMemo(() => {
|
|
||||||
const rootFolder = rootFolders.find((r) => r.id === rootFolderId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
path: rootFolder?.path ?? '',
|
|
||||||
unmappedFolders:
|
|
||||||
rootFolder?.unmappedFolders.map((unmappedFolders) => {
|
|
||||||
return {
|
|
||||||
...unmappedFolders,
|
|
||||||
id: unmappedFolders.name,
|
|
||||||
};
|
|
||||||
}) ?? [],
|
|
||||||
};
|
|
||||||
}, [rootFolders, rootFolderId]);
|
|
||||||
|
|
||||||
const qualityProfiles = useSelector(
|
|
||||||
(state: AppState) => state.settings.qualityProfiles.items
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultQualityProfileId = useAddSeriesOption('qualityProfileId');
|
|
||||||
|
|
||||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const items = useMemo(() => {
|
|
||||||
return unmappedFolders.map((unmappedFolder) => {
|
|
||||||
return {
|
|
||||||
...unmappedFolder,
|
|
||||||
id: unmappedFolder.name,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [unmappedFolders]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchRootFolders({ id: rootFolderId, timeout: false }));
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
dispatch(clearImportSeries());
|
|
||||||
};
|
|
||||||
}, [rootFolderId, dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!defaultQualityProfileId ||
|
|
||||||
!qualityProfiles.some((p) => p.id === defaultQualityProfileId)
|
|
||||||
) {
|
|
||||||
setAddSeriesOption('qualityProfileId', qualityProfiles[0].id);
|
|
||||||
}
|
|
||||||
}, [defaultQualityProfileId, qualityProfiles, dispatch]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SelectProvider items={items}>
|
|
||||||
<PageContent title={translate('ImportSeries')}>
|
|
||||||
<PageContentBody ref={scrollerRef}>
|
|
||||||
{rootFoldersFetching ? <LoadingIndicator /> : null}
|
|
||||||
|
|
||||||
{!rootFoldersFetching && !!rootFoldersError ? (
|
|
||||||
<Alert kind={kinds.DANGER}>
|
|
||||||
{translate('RootFoldersLoadError')}
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!rootFoldersError &&
|
|
||||||
!rootFoldersFetching &&
|
|
||||||
rootFoldersPopulated &&
|
|
||||||
!unmappedFolders.length ? (
|
|
||||||
<Alert kind={kinds.INFO}>
|
|
||||||
{translate('AllSeriesInRootFolderHaveBeenImported', { path })}
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!rootFoldersError &&
|
|
||||||
!rootFoldersFetching &&
|
|
||||||
rootFoldersPopulated &&
|
|
||||||
!!unmappedFolders.length &&
|
|
||||||
scrollerRef.current ? (
|
|
||||||
<ImportSeriesTable
|
|
||||||
unmappedFolders={unmappedFolders}
|
|
||||||
scrollerRef={scrollerRef}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</PageContentBody>
|
|
||||||
|
|
||||||
{!rootFoldersError &&
|
|
||||||
!rootFoldersFetching &&
|
|
||||||
!!unmappedFolders.length ? (
|
|
||||||
<ImportSeriesFooter />
|
|
||||||
) : null}
|
|
||||||
</PageContent>
|
|
||||||
</SelectProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ImportSeries;
|
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape';
|
||||||
|
import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
|
||||||
|
import { clearImportSeries, importSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
|
||||||
|
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||||
|
import ImportSeries from './ImportSeries';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state, { match }) => match,
|
||||||
|
(state) => state.rootFolders,
|
||||||
|
(state) => state.addSeries,
|
||||||
|
(state) => state.importSeries,
|
||||||
|
(state) => state.settings.qualityProfiles,
|
||||||
|
(
|
||||||
|
match,
|
||||||
|
rootFolders,
|
||||||
|
addSeries,
|
||||||
|
importSeriesState,
|
||||||
|
qualityProfiles
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
isFetching: rootFoldersFetching,
|
||||||
|
isPopulated: rootFoldersPopulated,
|
||||||
|
error: rootFoldersError,
|
||||||
|
items
|
||||||
|
} = rootFolders;
|
||||||
|
|
||||||
|
const rootFolderId = parseInt(match.params.rootFolderId);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
rootFolderId,
|
||||||
|
rootFoldersFetching,
|
||||||
|
rootFoldersPopulated,
|
||||||
|
rootFoldersError,
|
||||||
|
qualityProfiles: qualityProfiles.items,
|
||||||
|
defaultQualityProfileId: addSeries.defaults.qualityProfileId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (items.length) {
|
||||||
|
const rootFolder = _.find(items, { id: rootFolderId });
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
...rootFolder,
|
||||||
|
items: importSeriesState.items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
dispatchSetImportSeriesValue: setImportSeriesValue,
|
||||||
|
dispatchImportSeries: importSeries,
|
||||||
|
dispatchClearImportSeries: clearImportSeries,
|
||||||
|
dispatchFetchRootFolders: fetchRootFolders,
|
||||||
|
dispatchSetAddSeriesDefault: setAddSeriesDefault
|
||||||
|
};
|
||||||
|
|
||||||
|
class ImportSeriesConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const {
|
||||||
|
rootFolderId,
|
||||||
|
qualityProfiles,
|
||||||
|
defaultQualityProfileId,
|
||||||
|
dispatchFetchRootFolders,
|
||||||
|
dispatchSetAddSeriesDefault
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
dispatchFetchRootFolders({ id: rootFolderId, timeout: false });
|
||||||
|
|
||||||
|
let setDefaults = false;
|
||||||
|
const setDefaultPayload = {};
|
||||||
|
|
||||||
|
if (
|
||||||
|
!defaultQualityProfileId ||
|
||||||
|
!qualityProfiles.some((p) => p.id === defaultQualityProfileId)
|
||||||
|
) {
|
||||||
|
setDefaults = true;
|
||||||
|
setDefaultPayload.qualityProfileId = qualityProfiles[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setDefaults) {
|
||||||
|
dispatchSetAddSeriesDefault(setDefaultPayload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.props.dispatchClearImportSeries();
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onInputChange = (ids, name, value) => {
|
||||||
|
this.props.dispatchSetAddSeriesDefault({ [name]: value });
|
||||||
|
|
||||||
|
ids.forEach((id) => {
|
||||||
|
this.props.dispatchSetImportSeriesValue({
|
||||||
|
id,
|
||||||
|
[name]: value
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onImportPress = (ids) => {
|
||||||
|
this.props.dispatchImportSeries({ ids });
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<ImportSeries
|
||||||
|
{...this.props}
|
||||||
|
onInputChange={this.onInputChange}
|
||||||
|
onImportPress={this.onImportPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeMatchShape = createRouteMatchShape({
|
||||||
|
rootFolderId: PropTypes.string.isRequired
|
||||||
|
});
|
||||||
|
|
||||||
|
ImportSeriesConnector.propTypes = {
|
||||||
|
match: routeMatchShape.isRequired,
|
||||||
|
rootFolderId: PropTypes.number.isRequired,
|
||||||
|
rootFoldersFetching: PropTypes.bool.isRequired,
|
||||||
|
rootFoldersPopulated: PropTypes.bool.isRequired,
|
||||||
|
qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
defaultQualityProfileId: PropTypes.number.isRequired,
|
||||||
|
dispatchSetImportSeriesValue: PropTypes.func.isRequired,
|
||||||
|
dispatchImportSeries: PropTypes.func.isRequired,
|
||||||
|
dispatchClearImportSeries: PropTypes.func.isRequired,
|
||||||
|
dispatchFetchRootFolders: PropTypes.func.isRequired,
|
||||||
|
dispatchSetAddSeriesDefault: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesConnector);
|
||||||
300
frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.js
Normal file
300
frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.js
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import CheckInput from 'Components/Form/CheckInput';
|
||||||
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import PageContentFooter from 'Components/Page/PageContentFooter';
|
||||||
|
import Popover from 'Components/Tooltip/Popover';
|
||||||
|
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import styles from './ImportSeriesFooter.css';
|
||||||
|
|
||||||
|
const MIXED = 'mixed';
|
||||||
|
|
||||||
|
class ImportSeriesFooter extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
const {
|
||||||
|
defaultMonitor,
|
||||||
|
defaultQualityProfileId,
|
||||||
|
defaultSeasonFolder,
|
||||||
|
defaultSeriesType
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
monitor: defaultMonitor,
|
||||||
|
qualityProfileId: defaultQualityProfileId,
|
||||||
|
seriesType: defaultSeriesType,
|
||||||
|
seasonFolder: defaultSeasonFolder
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
const {
|
||||||
|
defaultMonitor,
|
||||||
|
defaultQualityProfileId,
|
||||||
|
defaultSeriesType,
|
||||||
|
defaultSeasonFolder,
|
||||||
|
isMonitorMixed,
|
||||||
|
isQualityProfileIdMixed,
|
||||||
|
isSeriesTypeMixed,
|
||||||
|
isSeasonFolderMixed
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
monitor,
|
||||||
|
qualityProfileId,
|
||||||
|
seriesType,
|
||||||
|
seasonFolder
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
const newState = {};
|
||||||
|
|
||||||
|
if (isMonitorMixed && monitor !== MIXED) {
|
||||||
|
newState.monitor = MIXED;
|
||||||
|
} else if (!isMonitorMixed && monitor !== defaultMonitor) {
|
||||||
|
newState.monitor = defaultMonitor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isQualityProfileIdMixed && qualityProfileId !== MIXED) {
|
||||||
|
newState.qualityProfileId = MIXED;
|
||||||
|
} else if (!isQualityProfileIdMixed && qualityProfileId !== defaultQualityProfileId) {
|
||||||
|
newState.qualityProfileId = defaultQualityProfileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSeriesTypeMixed && seriesType !== MIXED) {
|
||||||
|
newState.seriesType = MIXED;
|
||||||
|
} else if (!isSeriesTypeMixed && seriesType !== defaultSeriesType) {
|
||||||
|
newState.seriesType = defaultSeriesType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSeasonFolderMixed && seasonFolder != null) {
|
||||||
|
newState.seasonFolder = null;
|
||||||
|
} else if (!isSeasonFolderMixed && seasonFolder !== defaultSeasonFolder) {
|
||||||
|
newState.seasonFolder = defaultSeasonFolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_.isEmpty(newState)) {
|
||||||
|
this.setState(newState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onInputChange = ({ name, value }) => {
|
||||||
|
this.setState({ [name]: value });
|
||||||
|
this.props.onInputChange({ name, value });
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
selectedCount,
|
||||||
|
isImporting,
|
||||||
|
isLookingUpSeries,
|
||||||
|
isMonitorMixed,
|
||||||
|
isQualityProfileIdMixed,
|
||||||
|
isSeriesTypeMixed,
|
||||||
|
hasUnsearchedItems,
|
||||||
|
importError,
|
||||||
|
onImportPress,
|
||||||
|
onLookupPress,
|
||||||
|
onCancelLookupPress
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
monitor,
|
||||||
|
qualityProfileId,
|
||||||
|
seriesType,
|
||||||
|
seasonFolder
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContentFooter>
|
||||||
|
<div className={styles.inputContainer}>
|
||||||
|
<div className={styles.label}>
|
||||||
|
{translate('Monitor')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.MONITOR_EPISODES_SELECT}
|
||||||
|
name="monitor"
|
||||||
|
value={monitor}
|
||||||
|
isDisabled={!selectedCount}
|
||||||
|
includeMixed={isMonitorMixed}
|
||||||
|
onChange={this.onInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.inputContainer}>
|
||||||
|
<div className={styles.label}>
|
||||||
|
{translate('QualityProfile')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||||
|
name="qualityProfileId"
|
||||||
|
value={qualityProfileId}
|
||||||
|
isDisabled={!selectedCount}
|
||||||
|
includeMixed={isQualityProfileIdMixed}
|
||||||
|
onChange={this.onInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.inputContainer}>
|
||||||
|
<div className={styles.label}>
|
||||||
|
{translate('SeriesType')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.SERIES_TYPE_SELECT}
|
||||||
|
name="seriesType"
|
||||||
|
value={seriesType}
|
||||||
|
isDisabled={!selectedCount}
|
||||||
|
includeMixed={isSeriesTypeMixed}
|
||||||
|
onChange={this.onInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.inputContainer}>
|
||||||
|
<div className={styles.label}>
|
||||||
|
{translate('SeasonFolder')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CheckInput
|
||||||
|
name="seasonFolder"
|
||||||
|
value={seasonFolder}
|
||||||
|
isDisabled={!selectedCount}
|
||||||
|
onChange={this.onInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className={styles.label}>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.importButtonContainer}>
|
||||||
|
<SpinnerButton
|
||||||
|
className={styles.importButton}
|
||||||
|
kind={kinds.PRIMARY}
|
||||||
|
isSpinning={isImporting}
|
||||||
|
isDisabled={!selectedCount || isLookingUpSeries}
|
||||||
|
onPress={onImportPress}
|
||||||
|
>
|
||||||
|
{translate('ImportCountSeries', { selectedCount })}
|
||||||
|
</SpinnerButton>
|
||||||
|
|
||||||
|
{
|
||||||
|
isLookingUpSeries ?
|
||||||
|
<Button
|
||||||
|
className={styles.loadingButton}
|
||||||
|
kind={kinds.WARNING}
|
||||||
|
onPress={onCancelLookupPress}
|
||||||
|
>
|
||||||
|
{translate('CancelProcessing')}
|
||||||
|
</Button> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
hasUnsearchedItems ?
|
||||||
|
<Button
|
||||||
|
className={styles.loadingButton}
|
||||||
|
kind={kinds.SUCCESS}
|
||||||
|
onPress={onLookupPress}
|
||||||
|
>
|
||||||
|
{translate('StartProcessing')}
|
||||||
|
</Button> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
isLookingUpSeries ?
|
||||||
|
<LoadingIndicator
|
||||||
|
className={styles.loading}
|
||||||
|
size={24}
|
||||||
|
/> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
isLookingUpSeries ?
|
||||||
|
translate('ProcessingFolders') :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
importError ?
|
||||||
|
<Popover
|
||||||
|
anchor={
|
||||||
|
<Icon
|
||||||
|
className={styles.importError}
|
||||||
|
name={icons.WARNING}
|
||||||
|
kind={kinds.WARNING}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={translate('ImportErrors')}
|
||||||
|
body={
|
||||||
|
<ul>
|
||||||
|
{
|
||||||
|
Array.isArray(importError.responseJSON) ?
|
||||||
|
importError.responseJSON.map((error, index) => {
|
||||||
|
return (
|
||||||
|
<li key={index}>
|
||||||
|
{error.errorMessage}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}) :
|
||||||
|
<li>
|
||||||
|
{
|
||||||
|
JSON.stringify(importError.responseJSON)
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
position={tooltipPositions.RIGHT}
|
||||||
|
/> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContentFooter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportSeriesFooter.propTypes = {
|
||||||
|
selectedCount: PropTypes.number.isRequired,
|
||||||
|
isImporting: PropTypes.bool.isRequired,
|
||||||
|
isLookingUpSeries: PropTypes.bool.isRequired,
|
||||||
|
defaultMonitor: PropTypes.string.isRequired,
|
||||||
|
defaultQualityProfileId: PropTypes.number,
|
||||||
|
defaultSeriesType: PropTypes.string.isRequired,
|
||||||
|
defaultSeasonFolder: PropTypes.bool.isRequired,
|
||||||
|
isMonitorMixed: PropTypes.bool.isRequired,
|
||||||
|
isQualityProfileIdMixed: PropTypes.bool.isRequired,
|
||||||
|
isSeriesTypeMixed: PropTypes.bool.isRequired,
|
||||||
|
isSeasonFolderMixed: PropTypes.bool.isRequired,
|
||||||
|
hasUnsearchedItems: PropTypes.bool.isRequired,
|
||||||
|
importError: PropTypes.object,
|
||||||
|
onInputChange: PropTypes.func.isRequired,
|
||||||
|
onImportPress: PropTypes.func.isRequired,
|
||||||
|
onLookupPress: PropTypes.func.isRequired,
|
||||||
|
onCancelLookupPress: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportSeriesFooter;
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import {
|
|
||||||
AddSeriesOptions,
|
|
||||||
setAddSeriesOption,
|
|
||||||
useAddSeriesOptions,
|
|
||||||
} from 'AddSeries/addSeriesOptionsStore';
|
|
||||||
import { useSelect } from 'App/SelectContext';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import CheckInput from 'Components/Form/CheckInput';
|
|
||||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import Button from 'Components/Link/Button';
|
|
||||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
|
||||||
import PageContentFooter from 'Components/Page/PageContentFooter';
|
|
||||||
import Popover from 'Components/Tooltip/Popover';
|
|
||||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
|
||||||
import { SeriesMonitor, SeriesType } from 'Series/Series';
|
|
||||||
import {
|
|
||||||
cancelLookupSeries,
|
|
||||||
importSeries,
|
|
||||||
lookupUnsearchedSeries,
|
|
||||||
setImportSeriesValue,
|
|
||||||
} from 'Store/Actions/importSeriesActions';
|
|
||||||
import { InputChanged } from 'typings/inputs';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
|
||||||
import styles from './ImportSeriesFooter.css';
|
|
||||||
|
|
||||||
type MixedType = 'mixed';
|
|
||||||
|
|
||||||
function ImportSeriesFooter() {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const {
|
|
||||||
monitor: defaultMonitor,
|
|
||||||
qualityProfileId: defaultQualityProfileId,
|
|
||||||
seriesType: defaultSeriesType,
|
|
||||||
seasonFolder: defaultSeasonFolder,
|
|
||||||
} = useAddSeriesOptions();
|
|
||||||
|
|
||||||
const { isLookingUpSeries, isImporting, items, importError } = useSelector(
|
|
||||||
(state: AppState) => state.importSeries
|
|
||||||
);
|
|
||||||
|
|
||||||
const [monitor, setMonitor] = useState<SeriesMonitor | MixedType>(
|
|
||||||
defaultMonitor
|
|
||||||
);
|
|
||||||
const [qualityProfileId, setQualityProfileId] = useState<number | MixedType>(
|
|
||||||
defaultQualityProfileId
|
|
||||||
);
|
|
||||||
const [seriesType, setSeriesType] = useState<SeriesType | MixedType>(
|
|
||||||
defaultSeriesType
|
|
||||||
);
|
|
||||||
const [seasonFolder, setSeasonFolder] = useState<boolean | MixedType>(
|
|
||||||
defaultSeasonFolder
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectState] = useSelect();
|
|
||||||
|
|
||||||
const selectedIds = useMemo(() => {
|
|
||||||
return getSelectedIds(selectState.selectedState, (id) => id);
|
|
||||||
}, [selectState.selectedState]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
hasUnsearchedItems,
|
|
||||||
isMonitorMixed,
|
|
||||||
isQualityProfileIdMixed,
|
|
||||||
isSeriesTypeMixed,
|
|
||||||
isSeasonFolderMixed,
|
|
||||||
} = useMemo(() => {
|
|
||||||
let isMonitorMixed = false;
|
|
||||||
let isQualityProfileIdMixed = false;
|
|
||||||
let isSeriesTypeMixed = false;
|
|
||||||
let isSeasonFolderMixed = false;
|
|
||||||
let hasUnsearchedItems = false;
|
|
||||||
|
|
||||||
items.forEach((item) => {
|
|
||||||
if (item.monitor !== defaultMonitor) {
|
|
||||||
isMonitorMixed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.qualityProfileId !== defaultQualityProfileId) {
|
|
||||||
isQualityProfileIdMixed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.seriesType !== defaultSeriesType) {
|
|
||||||
isSeriesTypeMixed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.seasonFolder !== defaultSeasonFolder) {
|
|
||||||
isSeasonFolderMixed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.isPopulated) {
|
|
||||||
hasUnsearchedItems = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasUnsearchedItems: !isLookingUpSeries && hasUnsearchedItems,
|
|
||||||
isMonitorMixed,
|
|
||||||
isQualityProfileIdMixed,
|
|
||||||
isSeriesTypeMixed,
|
|
||||||
isSeasonFolderMixed,
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
defaultMonitor,
|
|
||||||
defaultQualityProfileId,
|
|
||||||
defaultSeasonFolder,
|
|
||||||
defaultSeriesType,
|
|
||||||
items,
|
|
||||||
isLookingUpSeries,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleInputChange = useCallback(
|
|
||||||
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
|
|
||||||
if (name === 'monitor') {
|
|
||||||
setMonitor(value as SeriesMonitor);
|
|
||||||
} else if (name === 'qualityProfileId') {
|
|
||||||
setQualityProfileId(value as number);
|
|
||||||
} else if (name === 'seriesType') {
|
|
||||||
setSeriesType(value as SeriesType);
|
|
||||||
} else if (name === 'seasonFolder') {
|
|
||||||
setSeasonFolder(value as boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
setAddSeriesOption(name as keyof AddSeriesOptions, value);
|
|
||||||
|
|
||||||
selectedIds.forEach((id) => {
|
|
||||||
dispatch(
|
|
||||||
// @ts-expect-error - actions are not typed
|
|
||||||
setImportSeriesValue({
|
|
||||||
id,
|
|
||||||
[name]: value,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[selectedIds, dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleLookupPress = useCallback(() => {
|
|
||||||
dispatch(lookupUnsearchedSeries());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const handleCancelLookupPress = useCallback(() => {
|
|
||||||
dispatch(cancelLookupSeries());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const handleImportPress = useCallback(() => {
|
|
||||||
dispatch(importSeries({ ids: selectedIds }));
|
|
||||||
}, [selectedIds, dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isMonitorMixed && monitor !== 'mixed') {
|
|
||||||
setMonitor('mixed');
|
|
||||||
} else if (!isMonitorMixed && monitor !== defaultMonitor) {
|
|
||||||
setMonitor(defaultMonitor);
|
|
||||||
}
|
|
||||||
}, [defaultMonitor, isMonitorMixed, monitor]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isQualityProfileIdMixed && qualityProfileId !== 'mixed') {
|
|
||||||
setQualityProfileId('mixed');
|
|
||||||
} else if (
|
|
||||||
!isQualityProfileIdMixed &&
|
|
||||||
qualityProfileId !== defaultQualityProfileId
|
|
||||||
) {
|
|
||||||
setQualityProfileId(defaultQualityProfileId);
|
|
||||||
}
|
|
||||||
}, [defaultQualityProfileId, isQualityProfileIdMixed, qualityProfileId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSeriesTypeMixed && seriesType !== 'mixed') {
|
|
||||||
setSeriesType('mixed');
|
|
||||||
} else if (!isSeriesTypeMixed && seriesType !== defaultSeriesType) {
|
|
||||||
setSeriesType(defaultSeriesType);
|
|
||||||
}
|
|
||||||
}, [defaultSeriesType, isSeriesTypeMixed, seriesType]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSeasonFolderMixed && seasonFolder !== 'mixed') {
|
|
||||||
setSeasonFolder('mixed');
|
|
||||||
} else if (!isSeasonFolderMixed && seasonFolder !== defaultSeasonFolder) {
|
|
||||||
setSeasonFolder(defaultSeasonFolder);
|
|
||||||
}
|
|
||||||
}, [defaultSeasonFolder, isSeasonFolderMixed, seasonFolder]);
|
|
||||||
|
|
||||||
const selectedCount = selectedIds.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentFooter>
|
|
||||||
<div className={styles.inputContainer}>
|
|
||||||
<div className={styles.label}>{translate('Monitor')}</div>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.MONITOR_EPISODES_SELECT}
|
|
||||||
name="monitor"
|
|
||||||
value={monitor}
|
|
||||||
isDisabled={!selectedCount}
|
|
||||||
includeMixed={isMonitorMixed}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.inputContainer}>
|
|
||||||
<div className={styles.label}>{translate('QualityProfile')}</div>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
|
||||||
name="qualityProfileId"
|
|
||||||
value={qualityProfileId}
|
|
||||||
isDisabled={!selectedCount}
|
|
||||||
includeMixed={isQualityProfileIdMixed}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.inputContainer}>
|
|
||||||
<div className={styles.label}>{translate('SeriesType')}</div>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.SERIES_TYPE_SELECT}
|
|
||||||
name="seriesType"
|
|
||||||
value={seriesType}
|
|
||||||
isDisabled={!selectedCount}
|
|
||||||
includeMixed={isSeriesTypeMixed}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.inputContainer}>
|
|
||||||
<div className={styles.label}>{translate('SeasonFolder')}</div>
|
|
||||||
|
|
||||||
<CheckInput
|
|
||||||
name="seasonFolder"
|
|
||||||
value={seasonFolder}
|
|
||||||
isDisabled={!selectedCount}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className={styles.label}> </div>
|
|
||||||
|
|
||||||
<div className={styles.importButtonContainer}>
|
|
||||||
<SpinnerButton
|
|
||||||
className={styles.importButton}
|
|
||||||
kind={kinds.PRIMARY}
|
|
||||||
isSpinning={isImporting}
|
|
||||||
isDisabled={!selectedCount || isLookingUpSeries}
|
|
||||||
onPress={handleImportPress}
|
|
||||||
>
|
|
||||||
{translate('ImportCountSeries', { selectedCount })}
|
|
||||||
</SpinnerButton>
|
|
||||||
|
|
||||||
{isLookingUpSeries ? (
|
|
||||||
<Button
|
|
||||||
className={styles.loadingButton}
|
|
||||||
kind={kinds.WARNING}
|
|
||||||
onPress={handleCancelLookupPress}
|
|
||||||
>
|
|
||||||
{translate('CancelProcessing')}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{hasUnsearchedItems ? (
|
|
||||||
<Button
|
|
||||||
className={styles.loadingButton}
|
|
||||||
kind={kinds.SUCCESS}
|
|
||||||
onPress={handleLookupPress}
|
|
||||||
>
|
|
||||||
{translate('StartProcessing')}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isLookingUpSeries ? (
|
|
||||||
<LoadingIndicator className={styles.loading} size={24} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isLookingUpSeries ? translate('ProcessingFolders') : null}
|
|
||||||
|
|
||||||
{importError ? (
|
|
||||||
<Popover
|
|
||||||
anchor={
|
|
||||||
<Icon
|
|
||||||
className={styles.importError}
|
|
||||||
name={icons.WARNING}
|
|
||||||
kind={kinds.WARNING}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
title={translate('ImportErrors')}
|
|
||||||
body={
|
|
||||||
<ul>
|
|
||||||
{Array.isArray(importError.responseJSON) ? (
|
|
||||||
importError.responseJSON.map((error, index) => {
|
|
||||||
return <li key={index}>{error.errorMessage}</li>;
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<li>{JSON.stringify(importError.responseJSON)}</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
position={tooltipPositions.RIGHT}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageContentFooter>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ImportSeriesFooter;
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { cancelLookupSeries, lookupUnsearchedSeries } from 'Store/Actions/importSeriesActions';
|
||||||
|
import ImportSeriesFooter from './ImportSeriesFooter';
|
||||||
|
|
||||||
|
function isMixed(items, selectedIds, defaultValue, key) {
|
||||||
|
return _.some(items, (series) => {
|
||||||
|
return selectedIds.indexOf(series.id) > -1 && series[key] !== defaultValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state) => state.addSeries,
|
||||||
|
(state) => state.importSeries,
|
||||||
|
(state, { selectedIds }) => selectedIds,
|
||||||
|
(addSeries, importSeries, selectedIds) => {
|
||||||
|
const {
|
||||||
|
monitor: defaultMonitor,
|
||||||
|
qualityProfileId: defaultQualityProfileId,
|
||||||
|
seriesType: defaultSeriesType,
|
||||||
|
seasonFolder: defaultSeasonFolder
|
||||||
|
} = addSeries.defaults;
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLookingUpSeries,
|
||||||
|
isImporting,
|
||||||
|
items,
|
||||||
|
importError
|
||||||
|
} = importSeries;
|
||||||
|
|
||||||
|
const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor');
|
||||||
|
const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId');
|
||||||
|
const isSeriesTypeMixed = isMixed(items, selectedIds, defaultSeriesType, 'seriesType');
|
||||||
|
const isSeasonFolderMixed = isMixed(items, selectedIds, defaultSeasonFolder, 'seasonFolder');
|
||||||
|
const hasUnsearchedItems = !isLookingUpSeries && items.some((item) => !item.isPopulated);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedCount: selectedIds.length,
|
||||||
|
isLookingUpSeries,
|
||||||
|
isImporting,
|
||||||
|
defaultMonitor,
|
||||||
|
defaultQualityProfileId,
|
||||||
|
defaultSeriesType,
|
||||||
|
defaultSeasonFolder,
|
||||||
|
isMonitorMixed,
|
||||||
|
isQualityProfileIdMixed,
|
||||||
|
isSeriesTypeMixed,
|
||||||
|
isSeasonFolderMixed,
|
||||||
|
importError,
|
||||||
|
hasUnsearchedItems
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
onLookupPress: lookupUnsearchedSeries,
|
||||||
|
onCancelLookupPress: cancelLookupSeries
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesFooter);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
|
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
|
||||||
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
|
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
|
||||||
@@ -7,21 +8,16 @@ import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
|
|||||||
import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
|
import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
|
||||||
import Popover from 'Components/Tooltip/Popover';
|
import Popover from 'Components/Tooltip/Popover';
|
||||||
import { icons, tooltipPositions } from 'Helpers/Props';
|
import { icons, tooltipPositions } from 'Helpers/Props';
|
||||||
import { CheckInputChanged } from 'typings/inputs';
|
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import styles from './ImportSeriesHeader.css';
|
import styles from './ImportSeriesHeader.css';
|
||||||
|
|
||||||
interface ImportSeriesHeaderProps {
|
function ImportSeriesHeader(props) {
|
||||||
allSelected: boolean;
|
const {
|
||||||
allUnselected: boolean;
|
allSelected,
|
||||||
onSelectAllChange: (change: CheckInputChanged) => void;
|
allUnselected,
|
||||||
}
|
onSelectAllChange
|
||||||
|
} = props;
|
||||||
|
|
||||||
function ImportSeriesHeader({
|
|
||||||
allSelected,
|
|
||||||
allUnselected,
|
|
||||||
onSelectAllChange,
|
|
||||||
}: ImportSeriesHeaderProps) {
|
|
||||||
return (
|
return (
|
||||||
<VirtualTableHeader>
|
<VirtualTableHeader>
|
||||||
<VirtualTableSelectAllHeaderCell
|
<VirtualTableSelectAllHeaderCell
|
||||||
@@ -30,15 +26,26 @@ function ImportSeriesHeader({
|
|||||||
onSelectAllChange={onSelectAllChange}
|
onSelectAllChange={onSelectAllChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VirtualTableHeaderCell className={styles.folder} name="folder">
|
<VirtualTableHeaderCell
|
||||||
|
className={styles.folder}
|
||||||
|
name="folder"
|
||||||
|
>
|
||||||
{translate('Folder')}
|
{translate('Folder')}
|
||||||
</VirtualTableHeaderCell>
|
</VirtualTableHeaderCell>
|
||||||
|
|
||||||
<VirtualTableHeaderCell className={styles.monitor} name="monitor">
|
<VirtualTableHeaderCell
|
||||||
|
className={styles.monitor}
|
||||||
|
name="monitor"
|
||||||
|
>
|
||||||
{translate('Monitor')}
|
{translate('Monitor')}
|
||||||
|
|
||||||
<Popover
|
<Popover
|
||||||
anchor={<Icon className={styles.detailsIcon} name={icons.INFO} />}
|
anchor={
|
||||||
|
<Icon
|
||||||
|
className={styles.detailsIcon}
|
||||||
|
name={icons.INFO}
|
||||||
|
/>
|
||||||
|
}
|
||||||
title={translate('MonitoringOptions')}
|
title={translate('MonitoringOptions')}
|
||||||
body={<SeriesMonitoringOptionsPopoverContent />}
|
body={<SeriesMonitoringOptionsPopoverContent />}
|
||||||
position={tooltipPositions.RIGHT}
|
position={tooltipPositions.RIGHT}
|
||||||
@@ -52,11 +59,19 @@ function ImportSeriesHeader({
|
|||||||
{translate('QualityProfile')}
|
{translate('QualityProfile')}
|
||||||
</VirtualTableHeaderCell>
|
</VirtualTableHeaderCell>
|
||||||
|
|
||||||
<VirtualTableHeaderCell className={styles.seriesType} name="seriesType">
|
<VirtualTableHeaderCell
|
||||||
|
className={styles.seriesType}
|
||||||
|
name="seriesType"
|
||||||
|
>
|
||||||
{translate('SeriesType')}
|
{translate('SeriesType')}
|
||||||
|
|
||||||
<Popover
|
<Popover
|
||||||
anchor={<Icon className={styles.detailsIcon} name={icons.INFO} />}
|
anchor={
|
||||||
|
<Icon
|
||||||
|
className={styles.detailsIcon}
|
||||||
|
name={icons.INFO}
|
||||||
|
/>
|
||||||
|
}
|
||||||
title={translate('SeriesType')}
|
title={translate('SeriesType')}
|
||||||
body={<SeriesTypePopoverContent />}
|
body={<SeriesTypePopoverContent />}
|
||||||
position={tooltipPositions.RIGHT}
|
position={tooltipPositions.RIGHT}
|
||||||
@@ -70,11 +85,20 @@ function ImportSeriesHeader({
|
|||||||
{translate('SeasonFolder')}
|
{translate('SeasonFolder')}
|
||||||
</VirtualTableHeaderCell>
|
</VirtualTableHeaderCell>
|
||||||
|
|
||||||
<VirtualTableHeaderCell className={styles.series} name="series">
|
<VirtualTableHeaderCell
|
||||||
|
className={styles.series}
|
||||||
|
name="series"
|
||||||
|
>
|
||||||
{translate('Series')}
|
{translate('Series')}
|
||||||
</VirtualTableHeaderCell>
|
</VirtualTableHeaderCell>
|
||||||
</VirtualTableHeader>
|
</VirtualTableHeader>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ImportSeriesHeader.propTypes = {
|
||||||
|
allSelected: PropTypes.bool.isRequired,
|
||||||
|
allUnselected: PropTypes.bool.isRequired,
|
||||||
|
onSelectAllChange: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
export default ImportSeriesHeader;
|
export default ImportSeriesHeader;
|
||||||
105
frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.js
Normal file
105
frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React from 'react';
|
||||||
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
|
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
|
||||||
|
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
|
||||||
|
import { inputTypes } from 'Helpers/Props';
|
||||||
|
import ImportSeriesSelectSeriesConnector from './SelectSeries/ImportSeriesSelectSeriesConnector';
|
||||||
|
import styles from './ImportSeriesRow.css';
|
||||||
|
|
||||||
|
function ImportSeriesRow(props) {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
relativePath,
|
||||||
|
monitor,
|
||||||
|
qualityProfileId,
|
||||||
|
seasonFolder,
|
||||||
|
seriesType,
|
||||||
|
selectedSeries,
|
||||||
|
isExistingSeries,
|
||||||
|
isSelected,
|
||||||
|
onSelectedChange,
|
||||||
|
onInputChange
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<VirtualTableSelectCell
|
||||||
|
inputClassName={styles.selectInput}
|
||||||
|
id={id}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isDisabled={!selectedSeries || isExistingSeries}
|
||||||
|
onSelectedChange={onSelectedChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VirtualTableRowCell className={styles.folder}>
|
||||||
|
{relativePath}
|
||||||
|
</VirtualTableRowCell>
|
||||||
|
|
||||||
|
<VirtualTableRowCell className={styles.monitor}>
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.MONITOR_EPISODES_SELECT}
|
||||||
|
name="monitor"
|
||||||
|
value={monitor}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</VirtualTableRowCell>
|
||||||
|
|
||||||
|
<VirtualTableRowCell className={styles.qualityProfile}>
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||||
|
name="qualityProfileId"
|
||||||
|
value={qualityProfileId}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</VirtualTableRowCell>
|
||||||
|
|
||||||
|
<VirtualTableRowCell className={styles.seriesType}>
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.SERIES_TYPE_SELECT}
|
||||||
|
name="seriesType"
|
||||||
|
value={seriesType}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</VirtualTableRowCell>
|
||||||
|
|
||||||
|
<VirtualTableRowCell className={styles.seasonFolder}>
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="seasonFolder"
|
||||||
|
value={seasonFolder}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</VirtualTableRowCell>
|
||||||
|
|
||||||
|
<VirtualTableRowCell className={styles.series}>
|
||||||
|
<ImportSeriesSelectSeriesConnector
|
||||||
|
id={id}
|
||||||
|
isExistingSeries={isExistingSeries}
|
||||||
|
onInputChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</VirtualTableRowCell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportSeriesRow.propTypes = {
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
relativePath: PropTypes.string.isRequired,
|
||||||
|
monitor: PropTypes.string.isRequired,
|
||||||
|
qualityProfileId: PropTypes.number.isRequired,
|
||||||
|
seriesType: PropTypes.string.isRequired,
|
||||||
|
seasonFolder: PropTypes.bool.isRequired,
|
||||||
|
selectedSeries: PropTypes.object,
|
||||||
|
isExistingSeries: PropTypes.bool.isRequired,
|
||||||
|
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
isSelected: PropTypes.bool,
|
||||||
|
onSelectedChange: PropTypes.func.isRequired,
|
||||||
|
onInputChange: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
ImportSeriesRow.defaultsProps = {
|
||||||
|
items: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportSeriesRow;
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import React, { useCallback } from 'react';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import { useSelect } from 'App/SelectContext';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import { ImportSeries } from 'App/State/ImportSeriesAppState';
|
|
||||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
|
||||||
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
|
|
||||||
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
|
|
||||||
import { inputTypes } from 'Helpers/Props';
|
|
||||||
import { setImportSeriesValue } from 'Store/Actions/importSeriesActions';
|
|
||||||
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
|
|
||||||
import { InputChanged } from 'typings/inputs';
|
|
||||||
import { SelectStateInputProps } from 'typings/props';
|
|
||||||
import ImportSeriesSelectSeries from './SelectSeries/ImportSeriesSelectSeries';
|
|
||||||
import styles from './ImportSeriesRow.css';
|
|
||||||
|
|
||||||
function createItemSelector(id: string) {
|
|
||||||
return createSelector(
|
|
||||||
(state: AppState) => state.importSeries.items,
|
|
||||||
(items) => {
|
|
||||||
return (
|
|
||||||
items.find((item) => {
|
|
||||||
return item.id === id;
|
|
||||||
}) || ({} as ImportSeries)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportSeriesRowProps {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImportSeriesRow({ id }: ImportSeriesRowProps) {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const {
|
|
||||||
relativePath,
|
|
||||||
monitor,
|
|
||||||
qualityProfileId,
|
|
||||||
seasonFolder,
|
|
||||||
seriesType,
|
|
||||||
selectedSeries,
|
|
||||||
} = useSelector(createItemSelector(id));
|
|
||||||
|
|
||||||
const isExistingSeries = useSelector(
|
|
||||||
createExistingSeriesSelector(selectedSeries?.tvdbId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectState, selectDispatch] = useSelect();
|
|
||||||
|
|
||||||
const handleInputChange = useCallback(
|
|
||||||
({ name, value }: InputChanged) => {
|
|
||||||
dispatch(
|
|
||||||
// @ts-expect-error - actions are not typed
|
|
||||||
setImportSeriesValue({
|
|
||||||
id,
|
|
||||||
[name]: value,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[id, dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectedChange = useCallback(
|
|
||||||
({ id, value, shiftKey }: SelectStateInputProps) => {
|
|
||||||
selectDispatch({
|
|
||||||
type: 'toggleSelected',
|
|
||||||
id,
|
|
||||||
isSelected: value,
|
|
||||||
shiftKey,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[selectDispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<VirtualTableSelectCell
|
|
||||||
inputClassName={styles.selectInput}
|
|
||||||
id={id}
|
|
||||||
isSelected={selectState.selectedState[id]}
|
|
||||||
isDisabled={!selectedSeries || isExistingSeries}
|
|
||||||
onSelectedChange={handleSelectedChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VirtualTableRowCell className={styles.folder}>
|
|
||||||
{relativePath}
|
|
||||||
</VirtualTableRowCell>
|
|
||||||
|
|
||||||
<VirtualTableRowCell className={styles.monitor}>
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.MONITOR_EPISODES_SELECT}
|
|
||||||
name="monitor"
|
|
||||||
value={monitor}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
</VirtualTableRowCell>
|
|
||||||
|
|
||||||
<VirtualTableRowCell className={styles.qualityProfile}>
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
|
||||||
name="qualityProfileId"
|
|
||||||
value={qualityProfileId}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
</VirtualTableRowCell>
|
|
||||||
|
|
||||||
<VirtualTableRowCell className={styles.seriesType}>
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.SERIES_TYPE_SELECT}
|
|
||||||
name="seriesType"
|
|
||||||
value={seriesType}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
</VirtualTableRowCell>
|
|
||||||
|
|
||||||
<VirtualTableRowCell className={styles.seasonFolder}>
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.CHECK}
|
|
||||||
name="seasonFolder"
|
|
||||||
value={seasonFolder}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
</VirtualTableRowCell>
|
|
||||||
|
|
||||||
<VirtualTableRowCell className={styles.series}>
|
|
||||||
<ImportSeriesSelectSeries id={id} onInputChange={handleInputChange} />
|
|
||||||
</VirtualTableRowCell>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ImportSeriesRow;
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { setImportSeriesValue } from 'Store/Actions/importSeriesActions';
|
||||||
|
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||||
|
import ImportSeriesRow from './ImportSeriesRow';
|
||||||
|
|
||||||
|
function createImportSeriesItemSelector() {
|
||||||
|
return createSelector(
|
||||||
|
(state, { id }) => id,
|
||||||
|
(state) => state.importSeries.items,
|
||||||
|
(id, items) => {
|
||||||
|
return _.find(items, { id }) || {};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
createImportSeriesItemSelector(),
|
||||||
|
createAllSeriesSelector(),
|
||||||
|
(item, series) => {
|
||||||
|
const selectedSeries = item && item.selectedSeries;
|
||||||
|
const isExistingSeries = !!selectedSeries && _.some(series, { tvdbId: selectedSeries.tvdbId });
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
isExistingSeries
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
setImportSeriesValue
|
||||||
|
};
|
||||||
|
|
||||||
|
class ImportSeriesRowConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onInputChange = ({ name, value }) => {
|
||||||
|
this.props.setImportSeriesValue({
|
||||||
|
id: this.props.id,
|
||||||
|
[name]: value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
// Don't show the row until we have the information we require for it.
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
monitor,
|
||||||
|
seriesType,
|
||||||
|
seasonFolder
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
if (!items || !monitor || !seriesType || !seasonFolder == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ImportSeriesRow
|
||||||
|
{...this.props}
|
||||||
|
onInputChange={this.onInputChange}
|
||||||
|
onSeriesSelect={this.onSeriesSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportSeriesRowConnector.propTypes = {
|
||||||
|
rootFolderId: PropTypes.number.isRequired,
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
monitor: PropTypes.string,
|
||||||
|
seriesType: PropTypes.string,
|
||||||
|
seasonFolder: PropTypes.bool,
|
||||||
|
items: PropTypes.arrayOf(PropTypes.object),
|
||||||
|
setImportSeriesValue: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesRowConnector);
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
.row {
|
|
||||||
transition: background-color 500ms;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--tableRowHoverBackgroundColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
188
frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.js
Normal file
188
frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import VirtualTable from 'Components/Table/VirtualTable';
|
||||||
|
import VirtualTableRow from 'Components/Table/VirtualTableRow';
|
||||||
|
import ImportSeriesHeader from './ImportSeriesHeader';
|
||||||
|
import ImportSeriesRowConnector from './ImportSeriesRowConnector';
|
||||||
|
|
||||||
|
class ImportSeriesTable extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const {
|
||||||
|
unmappedFolders,
|
||||||
|
defaultMonitor,
|
||||||
|
defaultQualityProfileId,
|
||||||
|
defaultSeriesType,
|
||||||
|
defaultSeasonFolder,
|
||||||
|
onSeriesLookup,
|
||||||
|
onSetImportSeriesValue
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
monitor: defaultMonitor,
|
||||||
|
qualityProfileId: defaultQualityProfileId,
|
||||||
|
seriesType: defaultSeriesType,
|
||||||
|
seasonFolder: defaultSeasonFolder
|
||||||
|
};
|
||||||
|
|
||||||
|
unmappedFolders.forEach((unmappedFolder) => {
|
||||||
|
const id = unmappedFolder.name;
|
||||||
|
|
||||||
|
onSeriesLookup(id, unmappedFolder.path, unmappedFolder.relativePath);
|
||||||
|
|
||||||
|
onSetImportSeriesValue({
|
||||||
|
id,
|
||||||
|
...values
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// This isn't great, but it's the most reliable way to ensure the items
|
||||||
|
// are checked off even if they aren't actually visible since the cells
|
||||||
|
// are virtualized.
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
selectedState,
|
||||||
|
onSelectedChange,
|
||||||
|
onRemoveSelectedStateItem
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
prevProps.items.forEach((prevItem) => {
|
||||||
|
const {
|
||||||
|
id
|
||||||
|
} = prevItem;
|
||||||
|
|
||||||
|
const item = _.find(items, { id });
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
onRemoveSelectedStateItem(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedSeries = item.selectedSeries;
|
||||||
|
const isSelected = selectedState[id];
|
||||||
|
|
||||||
|
const isExistingSeries = !!selectedSeries &&
|
||||||
|
_.some(prevProps.allSeries, { tvdbId: selectedSeries.tvdbId });
|
||||||
|
|
||||||
|
// Props doesn't have a selected series or
|
||||||
|
// the selected series is an existing series.
|
||||||
|
if ((!selectedSeries && prevItem.selectedSeries) || (isExistingSeries && !prevItem.selectedSeries)) {
|
||||||
|
onSelectedChange({ id, value: false });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State is selected, but a series isn't selected or
|
||||||
|
// the selected series is an existing series.
|
||||||
|
if (isSelected && (!selectedSeries || isExistingSeries)) {
|
||||||
|
onSelectedChange({ id, value: false });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A series is being selected that wasn't previously selected.
|
||||||
|
if (selectedSeries && selectedSeries !== prevItem.selectedSeries) {
|
||||||
|
onSelectedChange({ id, value: true });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Control
|
||||||
|
|
||||||
|
rowRenderer = ({ key, rowIndex, style }) => {
|
||||||
|
const {
|
||||||
|
rootFolderId,
|
||||||
|
items,
|
||||||
|
selectedState,
|
||||||
|
onSelectedChange
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const item = items[rowIndex];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualTableRow
|
||||||
|
key={key}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<ImportSeriesRowConnector
|
||||||
|
key={item.id}
|
||||||
|
rootFolderId={rootFolderId}
|
||||||
|
isSelected={selectedState[item.id]}
|
||||||
|
onSelectedChange={onSelectedChange}
|
||||||
|
id={item.id}
|
||||||
|
/>
|
||||||
|
</VirtualTableRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
allSelected,
|
||||||
|
allUnselected,
|
||||||
|
isSmallScreen,
|
||||||
|
scroller,
|
||||||
|
selectedState,
|
||||||
|
onSelectAllChange
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualTable
|
||||||
|
items={items}
|
||||||
|
scroller={scroller}
|
||||||
|
isSmallScreen={isSmallScreen}
|
||||||
|
rowHeight={52}
|
||||||
|
overscanRowCount={2}
|
||||||
|
rowRenderer={this.rowRenderer}
|
||||||
|
header={
|
||||||
|
<ImportSeriesHeader
|
||||||
|
allSelected={allSelected}
|
||||||
|
allUnselected={allUnselected}
|
||||||
|
onSelectAllChange={onSelectAllChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
selectedState={selectedState}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportSeriesTable.propTypes = {
|
||||||
|
rootFolderId: PropTypes.number.isRequired,
|
||||||
|
items: PropTypes.arrayOf(PropTypes.object),
|
||||||
|
unmappedFolders: PropTypes.arrayOf(PropTypes.object),
|
||||||
|
defaultMonitor: PropTypes.string.isRequired,
|
||||||
|
defaultQualityProfileId: PropTypes.number,
|
||||||
|
defaultSeriesType: PropTypes.string.isRequired,
|
||||||
|
defaultSeasonFolder: PropTypes.bool.isRequired,
|
||||||
|
allSelected: PropTypes.bool.isRequired,
|
||||||
|
allUnselected: PropTypes.bool.isRequired,
|
||||||
|
selectedState: PropTypes.object.isRequired,
|
||||||
|
isSmallScreen: PropTypes.bool.isRequired,
|
||||||
|
allSeries: PropTypes.arrayOf(PropTypes.object),
|
||||||
|
scroller: PropTypes.instanceOf(Element).isRequired,
|
||||||
|
onSelectAllChange: PropTypes.func.isRequired,
|
||||||
|
onSelectedChange: PropTypes.func.isRequired,
|
||||||
|
onRemoveSelectedStateItem: PropTypes.func.isRequired,
|
||||||
|
onSeriesLookup: PropTypes.func.isRequired,
|
||||||
|
onSetImportSeriesValue: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportSeriesTable;
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import React, { RefObject, useCallback, useEffect, useRef } from 'react';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import { FixedSizeList, ListChildComponentProps } from 'react-window';
|
|
||||||
import { useAddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
|
|
||||||
import { useSelect } from 'App/SelectContext';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import { ImportSeries } from 'App/State/ImportSeriesAppState';
|
|
||||||
import VirtualTable from 'Components/Table/VirtualTable';
|
|
||||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
|
||||||
import {
|
|
||||||
queueLookupSeries,
|
|
||||||
setImportSeriesValue,
|
|
||||||
} from 'Store/Actions/importSeriesActions';
|
|
||||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
|
||||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
|
||||||
import { CheckInputChanged } from 'typings/inputs';
|
|
||||||
import { SelectStateInputProps } from 'typings/props';
|
|
||||||
import { UnmappedFolder } from 'typings/RootFolder';
|
|
||||||
import ImportSeriesHeader from './ImportSeriesHeader';
|
|
||||||
import ImportSeriesRow from './ImportSeriesRow';
|
|
||||||
import styles from './ImportSeriesTable.css';
|
|
||||||
|
|
||||||
const ROW_HEIGHT = 52;
|
|
||||||
|
|
||||||
interface RowItemData {
|
|
||||||
items: ImportSeries[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportSeriesTableProps {
|
|
||||||
unmappedFolders: UnmappedFolder[];
|
|
||||||
scrollerRef: RefObject<HTMLElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
|
|
||||||
const { items } = data;
|
|
||||||
|
|
||||||
if (index >= items.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = items[index];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
className={styles.row}
|
|
||||||
>
|
|
||||||
<ImportSeriesRow key={item.id} id={item.id} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImportSeriesTable({
|
|
||||||
unmappedFolders,
|
|
||||||
scrollerRef,
|
|
||||||
}: ImportSeriesTableProps) {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const { monitor, qualityProfileId, seriesType, seasonFolder } =
|
|
||||||
useAddSeriesOptions();
|
|
||||||
|
|
||||||
const items = useSelector((state: AppState) => state.importSeries.items);
|
|
||||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
|
||||||
const allSeries = useSelector(createAllSeriesSelector());
|
|
||||||
const [selectState, selectDispatch] = useSelect();
|
|
||||||
|
|
||||||
const defaultValues = useRef({
|
|
||||||
monitor,
|
|
||||||
qualityProfileId,
|
|
||||||
seriesType,
|
|
||||||
seasonFolder,
|
|
||||||
});
|
|
||||||
|
|
||||||
const listRef = useRef<FixedSizeList<RowItemData>>(null);
|
|
||||||
const initialUnmappedFolders = useRef(unmappedFolders);
|
|
||||||
const previousItems = usePrevious(items);
|
|
||||||
const { allSelected, allUnselected, selectedState } = selectState;
|
|
||||||
|
|
||||||
const handleSelectAllChange = useCallback(
|
|
||||||
({ value }: CheckInputChanged) => {
|
|
||||||
selectDispatch({
|
|
||||||
type: value ? 'selectAll' : 'unselectAll',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[selectDispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectedChange = useCallback(
|
|
||||||
({ id, value, shiftKey }: SelectStateInputProps) => {
|
|
||||||
selectDispatch({
|
|
||||||
type: 'toggleSelected',
|
|
||||||
id,
|
|
||||||
isSelected: value,
|
|
||||||
shiftKey,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[selectDispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRemoveSelectedStateItem = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
selectDispatch({
|
|
||||||
type: 'removeItem',
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[selectDispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initialUnmappedFolders.current.forEach(({ name, path, relativePath }) => {
|
|
||||||
dispatch(
|
|
||||||
queueLookupSeries({
|
|
||||||
name,
|
|
||||||
path,
|
|
||||||
relativePath,
|
|
||||||
term: name,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
dispatch(
|
|
||||||
// @ts-expect-error - actions are not typed
|
|
||||||
setImportSeriesValue({
|
|
||||||
id: name,
|
|
||||||
...defaultValues.current,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
previousItems?.forEach((prevItem) => {
|
|
||||||
const { id } = prevItem;
|
|
||||||
|
|
||||||
const item = items.find((i) => i.id === id);
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
handleRemoveSelectedStateItem(id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedSeries = item.selectedSeries;
|
|
||||||
const isSelected = selectedState[id];
|
|
||||||
|
|
||||||
const isExistingSeries =
|
|
||||||
!!selectedSeries &&
|
|
||||||
allSeries.some((s) => s.tvdbId === selectedSeries.tvdbId);
|
|
||||||
|
|
||||||
if (
|
|
||||||
(!selectedSeries && prevItem.selectedSeries) ||
|
|
||||||
(isExistingSeries && !prevItem.selectedSeries)
|
|
||||||
) {
|
|
||||||
handleSelectedChange({ id, value: false, shiftKey: false });
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSelected && (!selectedSeries || isExistingSeries)) {
|
|
||||||
handleSelectedChange({ id, value: false, shiftKey: false });
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedSeries && selectedSeries !== prevItem.selectedSeries) {
|
|
||||||
handleSelectedChange({ id, value: true, shiftKey: false });
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
allSeries,
|
|
||||||
items,
|
|
||||||
previousItems,
|
|
||||||
selectedState,
|
|
||||||
handleRemoveSelectedStateItem,
|
|
||||||
handleSelectedChange,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!items.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VirtualTable
|
|
||||||
Header={
|
|
||||||
<ImportSeriesHeader
|
|
||||||
allSelected={allSelected}
|
|
||||||
allUnselected={allUnselected}
|
|
||||||
onSelectAllChange={handleSelectAllChange}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
itemCount={items.length}
|
|
||||||
itemData={{
|
|
||||||
items,
|
|
||||||
}}
|
|
||||||
isSmallScreen={isSmallScreen}
|
|
||||||
listRef={listRef}
|
|
||||||
rowHeight={ROW_HEIGHT}
|
|
||||||
Row={Row}
|
|
||||||
scrollerRef={scrollerRef}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ImportSeriesTable;
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
|
||||||
|
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||||
|
import ImportSeriesTable from './ImportSeriesTable';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state) => state.addSeries,
|
||||||
|
(state) => state.importSeries,
|
||||||
|
(state) => state.app.dimensions,
|
||||||
|
createAllSeriesSelector(),
|
||||||
|
(addSeries, importSeries, dimensions, allSeries) => {
|
||||||
|
return {
|
||||||
|
defaultMonitor: addSeries.defaults.monitor,
|
||||||
|
defaultQualityProfileId: addSeries.defaults.qualityProfileId,
|
||||||
|
defaultSeriesType: addSeries.defaults.seriesType,
|
||||||
|
defaultSeasonFolder: addSeries.defaults.seasonFolder,
|
||||||
|
items: importSeries.items,
|
||||||
|
isSmallScreen: dimensions.isSmallScreen,
|
||||||
|
allSeries
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMapDispatchToProps(dispatch, props) {
|
||||||
|
return {
|
||||||
|
onSeriesLookup(name, path, relativePath) {
|
||||||
|
dispatch(queueLookupSeries({
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
relativePath,
|
||||||
|
term: name
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
onSetImportSeriesValue(values) {
|
||||||
|
dispatch(setImportSeriesValue(values));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, createMapDispatchToProps)(ImportSeriesTable);
|
||||||
@@ -1,36 +1,29 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
import Link from 'Components/Link/Link';
|
import Link from 'Components/Link/Link';
|
||||||
import { icons } from 'Helpers/Props';
|
import { icons } from 'Helpers/Props';
|
||||||
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
|
|
||||||
import ImportSeriesTitle from './ImportSeriesTitle';
|
import ImportSeriesTitle from './ImportSeriesTitle';
|
||||||
import styles from './ImportSeriesSearchResult.css';
|
import styles from './ImportSeriesSearchResult.css';
|
||||||
|
|
||||||
interface ImportSeriesSearchResultProps {
|
function ImportSeriesSearchResult(props) {
|
||||||
tvdbId: number;
|
const {
|
||||||
title: string;
|
tvdbId,
|
||||||
year: number;
|
title,
|
||||||
network?: string;
|
year,
|
||||||
onPress: (tvdbId: number) => void;
|
network,
|
||||||
}
|
isExistingSeries,
|
||||||
|
onPress
|
||||||
|
} = props;
|
||||||
|
|
||||||
function ImportSeriesSearchResult({
|
const onPressCallback = useCallback(() => onPress(tvdbId), [tvdbId, onPress]);
|
||||||
tvdbId,
|
|
||||||
title,
|
|
||||||
year,
|
|
||||||
network,
|
|
||||||
onPress,
|
|
||||||
}: ImportSeriesSearchResultProps) {
|
|
||||||
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
|
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
|
||||||
onPress(tvdbId);
|
|
||||||
}, [tvdbId, onPress]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<Link className={styles.series} onPress={handlePress}>
|
<Link
|
||||||
|
className={styles.series}
|
||||||
|
onPress={onPressCallback}
|
||||||
|
>
|
||||||
<ImportSeriesTitle
|
<ImportSeriesTitle
|
||||||
title={title}
|
title={title}
|
||||||
year={year}
|
year={year}
|
||||||
@@ -53,4 +46,13 @@ function ImportSeriesSearchResult({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ImportSeriesSearchResult.propTypes = {
|
||||||
|
tvdbId: PropTypes.number.isRequired,
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
year: PropTypes.number.isRequired,
|
||||||
|
network: PropTypes.string,
|
||||||
|
isExistingSeries: PropTypes.bool.isRequired,
|
||||||
|
onPress: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
export default ImportSeriesSearchResult;
|
export default ImportSeriesSearchResult;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
|
||||||
|
import ImportSeriesSearchResult from './ImportSeriesSearchResult';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
createExistingSeriesSelector(),
|
||||||
|
(isExistingSeries) => {
|
||||||
|
return {
|
||||||
|
isExistingSeries
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps)(ImportSeriesSearchResult);
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { Manager, Popper, Reference } from 'react-popper';
|
||||||
|
import FormInputButton from 'Components/Form/FormInputButton';
|
||||||
|
import TextInput from 'Components/Form/TextInput';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import Portal from 'Components/Portal';
|
||||||
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
|
import getUniqueElementId from 'Utilities/getUniqueElementId';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import ImportSeriesSearchResultConnector from './ImportSeriesSearchResultConnector';
|
||||||
|
import ImportSeriesTitle from './ImportSeriesTitle';
|
||||||
|
import styles from './ImportSeriesSelectSeries.css';
|
||||||
|
|
||||||
|
class ImportSeriesSelectSeries extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this._seriesLookupTimeout = null;
|
||||||
|
this._scheduleUpdate = null;
|
||||||
|
this._buttonId = getUniqueElementId();
|
||||||
|
this._contentId = getUniqueElementId();
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
term: props.id,
|
||||||
|
isOpen: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
if (this._scheduleUpdate) {
|
||||||
|
this._scheduleUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Control
|
||||||
|
|
||||||
|
_addListener() {
|
||||||
|
window.addEventListener('click', this.onWindowClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeListener() {
|
||||||
|
window.removeEventListener('click', this.onWindowClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onWindowClick = (event) => {
|
||||||
|
const button = document.getElementById(this._buttonId);
|
||||||
|
const content = document.getElementById(this._contentId);
|
||||||
|
|
||||||
|
if (!button || !content) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!button.contains(event.target) &&
|
||||||
|
!content.contains(event.target) &&
|
||||||
|
this.state.isOpen
|
||||||
|
) {
|
||||||
|
this.setState({ isOpen: false });
|
||||||
|
this._removeListener();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onPress = () => {
|
||||||
|
if (this.state.isOpen) {
|
||||||
|
this._removeListener();
|
||||||
|
} else {
|
||||||
|
this._addListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ isOpen: !this.state.isOpen });
|
||||||
|
};
|
||||||
|
|
||||||
|
onSearchInputChange = ({ value }) => {
|
||||||
|
if (this._seriesLookupTimeout) {
|
||||||
|
clearTimeout(this._seriesLookupTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ term: value }, () => {
|
||||||
|
this._seriesLookupTimeout = setTimeout(() => {
|
||||||
|
this.props.onSearchInputChange(value);
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onRefreshPress = () => {
|
||||||
|
this.props.onSearchInputChange(this.state.term);
|
||||||
|
};
|
||||||
|
|
||||||
|
onSeriesSelect = (tvdbId) => {
|
||||||
|
this.setState({ isOpen: false });
|
||||||
|
|
||||||
|
this.props.onSeriesSelect(tvdbId);
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
selectedSeries,
|
||||||
|
isExistingSeries,
|
||||||
|
isFetching,
|
||||||
|
isPopulated,
|
||||||
|
error,
|
||||||
|
items,
|
||||||
|
isQueued,
|
||||||
|
isLookingUpSeries
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const errorMessage = error &&
|
||||||
|
error.responseJSON &&
|
||||||
|
error.responseJSON.message;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Manager>
|
||||||
|
<Reference>
|
||||||
|
{({ ref }) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
id={this._buttonId}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
ref={ref}
|
||||||
|
className={styles.button}
|
||||||
|
component="div"
|
||||||
|
onPress={this.onPress}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
isLookingUpSeries && isQueued && !isPopulated ?
|
||||||
|
<LoadingIndicator
|
||||||
|
className={styles.loading}
|
||||||
|
size={20}
|
||||||
|
/> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
isPopulated && selectedSeries && isExistingSeries ?
|
||||||
|
<Icon
|
||||||
|
className={styles.warningIcon}
|
||||||
|
name={icons.WARNING}
|
||||||
|
kind={kinds.WARNING}
|
||||||
|
/> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
isPopulated && selectedSeries ?
|
||||||
|
<ImportSeriesTitle
|
||||||
|
title={selectedSeries.title}
|
||||||
|
year={selectedSeries.year}
|
||||||
|
network={selectedSeries.network}
|
||||||
|
isExistingSeries={isExistingSeries}
|
||||||
|
/> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
isPopulated && !selectedSeries ?
|
||||||
|
<div className={styles.noMatches}>
|
||||||
|
<Icon
|
||||||
|
className={styles.warningIcon}
|
||||||
|
name={icons.WARNING}
|
||||||
|
kind={kinds.WARNING}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{translate('NoMatchFound')}
|
||||||
|
</div> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isFetching && !!error ?
|
||||||
|
<div>
|
||||||
|
<Icon
|
||||||
|
className={styles.warningIcon}
|
||||||
|
title={errorMessage}
|
||||||
|
name={icons.WARNING}
|
||||||
|
kind={kinds.WARNING}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{translate('SearchFailedError')}
|
||||||
|
</div> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className={styles.dropdownArrowContainer}>
|
||||||
|
<Icon
|
||||||
|
name={icons.CARET_DOWN}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Reference>
|
||||||
|
|
||||||
|
<Portal>
|
||||||
|
<Popper
|
||||||
|
placement="bottom"
|
||||||
|
modifiers={{
|
||||||
|
preventOverflow: {
|
||||||
|
boundariesElement: 'viewport'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ ref, style, scheduleUpdate }) => {
|
||||||
|
this._scheduleUpdate = scheduleUpdate;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
id={this._contentId}
|
||||||
|
className={styles.contentContainer}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
this.state.isOpen ?
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.searchContainer}>
|
||||||
|
<div className={styles.searchIconContainer}>
|
||||||
|
<Icon name={icons.SEARCH} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
className={styles.searchInput}
|
||||||
|
name={`${name}_textInput`}
|
||||||
|
value={this.state.term}
|
||||||
|
onChange={this.onSearchInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormInputButton
|
||||||
|
kind={kinds.DEFAULT}
|
||||||
|
spinnerIcon={icons.REFRESH}
|
||||||
|
canSpin={true}
|
||||||
|
isSpinning={isFetching}
|
||||||
|
onPress={this.onRefreshPress}
|
||||||
|
>
|
||||||
|
<Icon name={icons.REFRESH} />
|
||||||
|
</FormInputButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.results}>
|
||||||
|
{
|
||||||
|
items.map((item) => {
|
||||||
|
return (
|
||||||
|
<ImportSeriesSearchResultConnector
|
||||||
|
key={item.tvdbId}
|
||||||
|
tvdbId={item.tvdbId}
|
||||||
|
title={item.title}
|
||||||
|
year={item.year}
|
||||||
|
network={item.network}
|
||||||
|
onPress={this.onSeriesSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Popper>
|
||||||
|
</Portal>
|
||||||
|
</Manager>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportSeriesSelectSeries.propTypes = {
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
selectedSeries: PropTypes.object,
|
||||||
|
isExistingSeries: PropTypes.bool.isRequired,
|
||||||
|
isFetching: PropTypes.bool.isRequired,
|
||||||
|
isPopulated: PropTypes.bool.isRequired,
|
||||||
|
error: PropTypes.object,
|
||||||
|
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
isQueued: PropTypes.bool.isRequired,
|
||||||
|
isLookingUpSeries: PropTypes.bool.isRequired,
|
||||||
|
onSearchInputChange: PropTypes.func.isRequired,
|
||||||
|
onSeriesSelect: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
ImportSeriesSelectSeries.defaultProps = {
|
||||||
|
isFetching: true,
|
||||||
|
isPopulated: false,
|
||||||
|
items: [],
|
||||||
|
isQueued: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportSeriesSelectSeries;
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
import {
|
|
||||||
autoUpdate,
|
|
||||||
flip,
|
|
||||||
FloatingPortal,
|
|
||||||
useClick,
|
|
||||||
useDismiss,
|
|
||||||
useFloating,
|
|
||||||
useInteractions,
|
|
||||||
} from '@floating-ui/react';
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import FormInputButton from 'Components/Form/FormInputButton';
|
|
||||||
import TextInput from 'Components/Form/TextInput';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import Link from 'Components/Link/Link';
|
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
|
||||||
import {
|
|
||||||
queueLookupSeries,
|
|
||||||
setImportSeriesValue,
|
|
||||||
} from 'Store/Actions/importSeriesActions';
|
|
||||||
import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector';
|
|
||||||
import { InputChanged } from 'typings/inputs';
|
|
||||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import ImportSeriesSearchResult from './ImportSeriesSearchResult';
|
|
||||||
import ImportSeriesTitle from './ImportSeriesTitle';
|
|
||||||
import styles from './ImportSeriesSelectSeries.css';
|
|
||||||
|
|
||||||
interface ImportSeriesSelectSeriesProps {
|
|
||||||
id: string;
|
|
||||||
onInputChange: (input: InputChanged) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImportSeriesSelectSeries({
|
|
||||||
id,
|
|
||||||
onInputChange,
|
|
||||||
}: ImportSeriesSelectSeriesProps) {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const isLookingUpSeries = useSelector(
|
|
||||||
(state: AppState) => state.importSeries.isLookingUpSeries
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
error,
|
|
||||||
isFetching = true,
|
|
||||||
isPopulated = false,
|
|
||||||
items = [],
|
|
||||||
isQueued = true,
|
|
||||||
selectedSeries,
|
|
||||||
isExistingSeries,
|
|
||||||
term: itemTerm,
|
|
||||||
// @ts-expect-error - ignoring this for now
|
|
||||||
} = useSelector(createImportSeriesItemSelector(id, { id }));
|
|
||||||
|
|
||||||
const seriesLookupTimeout = useRef<ReturnType<typeof setTimeout>>();
|
|
||||||
|
|
||||||
const [term, setTerm] = useState('');
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const errorMessage = getErrorMessage(error);
|
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
|
||||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSearchInputChange = useCallback(
|
|
||||||
({ value }: InputChanged<string>) => {
|
|
||||||
if (seriesLookupTimeout.current) {
|
|
||||||
clearTimeout(seriesLookupTimeout.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTerm(value);
|
|
||||||
|
|
||||||
seriesLookupTimeout.current = setTimeout(() => {
|
|
||||||
dispatch(
|
|
||||||
queueLookupSeries({
|
|
||||||
name: id,
|
|
||||||
term: value,
|
|
||||||
topOfQueue: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, 200);
|
|
||||||
},
|
|
||||||
[id, dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRefreshPress = useCallback(() => {
|
|
||||||
dispatch(
|
|
||||||
queueLookupSeries({
|
|
||||||
name: id,
|
|
||||||
term,
|
|
||||||
topOfQueue: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, [id, term, dispatch]);
|
|
||||||
|
|
||||||
const handleSeriesSelect = useCallback(
|
|
||||||
(tvdbId: number) => {
|
|
||||||
setIsOpen(false);
|
|
||||||
|
|
||||||
const selectedSeries = items.find((item) => item.tvdbId === tvdbId)!;
|
|
||||||
|
|
||||||
dispatch(
|
|
||||||
// @ts-expect-error - actions are not typed
|
|
||||||
setImportSeriesValue({
|
|
||||||
id,
|
|
||||||
selectedSeries,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selectedSeries.seriesType !== 'standard') {
|
|
||||||
onInputChange({
|
|
||||||
name: 'seriesType',
|
|
||||||
value: selectedSeries.seriesType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[id, items, dispatch, onInputChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTerm(itemTerm);
|
|
||||||
}, [itemTerm]);
|
|
||||||
|
|
||||||
const { refs, context, floatingStyles } = useFloating({
|
|
||||||
middleware: [
|
|
||||||
flip({
|
|
||||||
crossAxis: false,
|
|
||||||
mainAxis: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
open: isOpen,
|
|
||||||
placement: 'bottom',
|
|
||||||
whileElementsMounted: autoUpdate,
|
|
||||||
onOpenChange: setIsOpen,
|
|
||||||
});
|
|
||||||
|
|
||||||
const click = useClick(context);
|
|
||||||
const dismiss = useDismiss(context);
|
|
||||||
|
|
||||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
|
||||||
click,
|
|
||||||
dismiss,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div ref={refs.setReference} {...getReferenceProps()}>
|
|
||||||
<Link className={styles.button} component="div" onPress={handlePress}>
|
|
||||||
{isLookingUpSeries && isQueued && !isPopulated ? (
|
|
||||||
<LoadingIndicator className={styles.loading} size={20} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isPopulated && selectedSeries && isExistingSeries ? (
|
|
||||||
<Icon
|
|
||||||
className={styles.warningIcon}
|
|
||||||
name={icons.WARNING}
|
|
||||||
kind={kinds.WARNING}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isPopulated && selectedSeries ? (
|
|
||||||
<ImportSeriesTitle
|
|
||||||
title={selectedSeries.title}
|
|
||||||
year={selectedSeries.year}
|
|
||||||
network={selectedSeries.network}
|
|
||||||
isExistingSeries={isExistingSeries}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isPopulated && !selectedSeries ? (
|
|
||||||
<div>
|
|
||||||
<Icon
|
|
||||||
className={styles.warningIcon}
|
|
||||||
name={icons.WARNING}
|
|
||||||
kind={kinds.WARNING}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{translate('NoMatchFound')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isFetching && !!error ? (
|
|
||||||
<div>
|
|
||||||
<Icon
|
|
||||||
className={styles.warningIcon}
|
|
||||||
title={errorMessage}
|
|
||||||
name={icons.WARNING}
|
|
||||||
kind={kinds.WARNING}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{translate('SearchFailedError')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className={styles.dropdownArrowContainer}>
|
|
||||||
<Icon name={icons.CARET_DOWN} />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{isOpen ? (
|
|
||||||
<FloatingPortal id="portal-root">
|
|
||||||
<div
|
|
||||||
ref={refs.setFloating}
|
|
||||||
className={styles.contentContainer}
|
|
||||||
style={floatingStyles}
|
|
||||||
{...getFloatingProps()}
|
|
||||||
>
|
|
||||||
{isOpen ? (
|
|
||||||
<div className={styles.content}>
|
|
||||||
<div className={styles.searchContainer}>
|
|
||||||
<div className={styles.searchIconContainer}>
|
|
||||||
<Icon name={icons.SEARCH} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
className={styles.searchInput}
|
|
||||||
name={`${name}_textInput`}
|
|
||||||
value={term}
|
|
||||||
onChange={handleSearchInputChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormInputButton
|
|
||||||
kind={kinds.DEFAULT}
|
|
||||||
spinnerIcon={icons.REFRESH}
|
|
||||||
canSpin={true}
|
|
||||||
isSpinning={isFetching}
|
|
||||||
onPress={handleRefreshPress}
|
|
||||||
>
|
|
||||||
<Icon name={icons.REFRESH} />
|
|
||||||
</FormInputButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.results}>
|
|
||||||
{items.map((item) => {
|
|
||||||
return (
|
|
||||||
<ImportSeriesSearchResult
|
|
||||||
key={item.tvdbId}
|
|
||||||
tvdbId={item.tvdbId}
|
|
||||||
title={item.title}
|
|
||||||
year={item.year}
|
|
||||||
network={item.network}
|
|
||||||
onPress={handleSeriesSelect}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</FloatingPortal>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ImportSeriesSelectSeries;
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
|
||||||
|
import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector';
|
||||||
|
import * as seriesTypes from 'Utilities/Series/seriesTypes';
|
||||||
|
import ImportSeriesSelectSeries from './ImportSeriesSelectSeries';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state) => state.importSeries.isLookingUpSeries,
|
||||||
|
createImportSeriesItemSelector(),
|
||||||
|
(isLookingUpSeries, item) => {
|
||||||
|
return {
|
||||||
|
isLookingUpSeries,
|
||||||
|
...item
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
queueLookupSeries,
|
||||||
|
setImportSeriesValue
|
||||||
|
};
|
||||||
|
|
||||||
|
class ImportSeriesSelectSeriesConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onSearchInputChange = (term) => {
|
||||||
|
this.props.queueLookupSeries({
|
||||||
|
name: this.props.id,
|
||||||
|
term,
|
||||||
|
topOfQueue: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onSeriesSelect = (tvdbId) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
items,
|
||||||
|
onInputChange
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const selectedSeries = items.find((item) => item.tvdbId === tvdbId);
|
||||||
|
|
||||||
|
this.props.setImportSeriesValue({
|
||||||
|
id,
|
||||||
|
selectedSeries
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedSeries.seriesType !== seriesTypes.STANDARD) {
|
||||||
|
onInputChange({
|
||||||
|
name: 'seriesType',
|
||||||
|
value: selectedSeries.seriesType
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<ImportSeriesSelectSeries
|
||||||
|
{...this.props}
|
||||||
|
onSearchInputChange={this.onSearchInputChange}
|
||||||
|
onSeriesSelect={this.onSeriesSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportSeriesSelectSeriesConnector.propTypes = {
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
items: PropTypes.arrayOf(PropTypes.object),
|
||||||
|
selectedSeries: PropTypes.object,
|
||||||
|
isSelected: PropTypes.bool,
|
||||||
|
onInputChange: PropTypes.func.isRequired,
|
||||||
|
queueLookupSeries: PropTypes.func.isRequired,
|
||||||
|
setImportSeriesValue: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectSeriesConnector);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React from 'react';
|
||||||
|
import Label from 'Components/Label';
|
||||||
|
import { kinds } from 'Helpers/Props';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import styles from './ImportSeriesTitle.css';
|
||||||
|
|
||||||
|
function ImportSeriesTitle(props) {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
network,
|
||||||
|
isExistingSeries
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.titleContainer}>
|
||||||
|
<div className={styles.title}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
!title.contains(year) &&
|
||||||
|
year > 0 ?
|
||||||
|
<span className={styles.year}>
|
||||||
|
({year})
|
||||||
|
</span> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
network ?
|
||||||
|
<Label>{network}</Label> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
isExistingSeries ?
|
||||||
|
<Label
|
||||||
|
kind={kinds.WARNING}
|
||||||
|
>
|
||||||
|
{translate('Existing')}
|
||||||
|
</Label> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportSeriesTitle.propTypes = {
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
year: PropTypes.number.isRequired,
|
||||||
|
network: PropTypes.string,
|
||||||
|
isExistingSeries: PropTypes.bool.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportSeriesTitle;
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Label from 'Components/Label';
|
|
||||||
import { kinds } from 'Helpers/Props';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import styles from './ImportSeriesTitle.css';
|
|
||||||
|
|
||||||
interface ImportSeriesTitleProps {
|
|
||||||
title: string;
|
|
||||||
year: number;
|
|
||||||
network?: string;
|
|
||||||
isExistingSeries: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImportSeriesTitle({
|
|
||||||
title,
|
|
||||||
year,
|
|
||||||
network,
|
|
||||||
isExistingSeries,
|
|
||||||
}: ImportSeriesTitleProps) {
|
|
||||||
return (
|
|
||||||
<div className={styles.titleContainer}>
|
|
||||||
<div className={styles.title}>{title}</div>
|
|
||||||
|
|
||||||
{year > 0 && !title.includes(String(year)) ? (
|
|
||||||
<span className={styles.year}>({year})</span>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{network ? <Label>{network}</Label> : null}
|
|
||||||
|
|
||||||
{isExistingSeries ? (
|
|
||||||
<Label kind={kinds.WARNING}>{translate('Existing')}</Label>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ImportSeriesTitle;
|
|
||||||
30
frontend/src/AddSeries/ImportSeries/ImportSeries.js
Normal file
30
frontend/src/AddSeries/ImportSeries/ImportSeries.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import { Route } from 'react-router-dom';
|
||||||
|
import ImportSeriesConnector from 'AddSeries/ImportSeries/Import/ImportSeriesConnector';
|
||||||
|
import ImportSeriesSelectFolderConnector from 'AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector';
|
||||||
|
import Switch from 'Components/Router/Switch';
|
||||||
|
|
||||||
|
class ImportSeries extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
exact={true}
|
||||||
|
path="/add/import"
|
||||||
|
component={ImportSeriesSelectFolderConnector}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/add/import/:rootFolderId"
|
||||||
|
component={ImportSeriesConnector}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImportSeries;
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Route } from 'react-router-dom';
|
|
||||||
import Switch from 'Components/Router/Switch';
|
|
||||||
import ImportSeries from './Import/ImportSeries';
|
|
||||||
import ImportSeriesSelectFolder from './SelectFolder/ImportSeriesSelectFolder';
|
|
||||||
|
|
||||||
function ImportSeriesPage() {
|
|
||||||
return (
|
|
||||||
<Switch>
|
|
||||||
<Route
|
|
||||||
exact={true}
|
|
||||||
path="/add/import"
|
|
||||||
component={ImportSeriesSelectFolder}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route path="/add/import/:rootFolderId" component={ImportSeries} />
|
|
||||||
</Switch>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ImportSeriesPage;
|
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import Alert from 'Components/Alert';
|
||||||
|
import FieldSet from 'Components/FieldSet';
|
||||||
|
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||||
|
import PageContent from 'Components/Page/PageContent';
|
||||||
|
import PageContentBody from 'Components/Page/PageContentBody';
|
||||||
|
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||||
|
import RootFolders from 'RootFolder/RootFolders';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import styles from './ImportSeriesSelectFolder.css';
|
||||||
|
|
||||||
|
class ImportSeriesSelectFolder extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
isAddNewRootFolderModalOpen: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
onAddNewRootFolderPress = () => {
|
||||||
|
this.setState({ isAddNewRootFolderModalOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
onNewRootFolderSelect = ({ value }) => {
|
||||||
|
this.props.onNewRootFolderSelect(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
onAddRootFolderModalClose = () => {
|
||||||
|
this.setState({ isAddNewRootFolderModalOpen: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
isWindows,
|
||||||
|
isFetching,
|
||||||
|
isPopulated,
|
||||||
|
isSaving,
|
||||||
|
error,
|
||||||
|
saveError,
|
||||||
|
items
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const hasRootFolders = items.length > 0;
|
||||||
|
const goodFolderExample = (isWindows) ? 'C:\\tv shows' : '/tv shows';
|
||||||
|
const badFolderExample = (isWindows) ? 'C:\\tv shows\\the simpsons' : '/tv shows/the simpsons';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContent title={translate('ImportSeries')}>
|
||||||
|
<PageContentBody>
|
||||||
|
{
|
||||||
|
isFetching && !isPopulated ?
|
||||||
|
<LoadingIndicator /> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isFetching && error ?
|
||||||
|
<Alert kind={kinds.DANGER}>{translate('RootFoldersLoadError')}</Alert> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!error && isPopulated &&
|
||||||
|
<div>
|
||||||
|
<div className={styles.header}>
|
||||||
|
{translate('LibraryImportSeriesHeader')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tips}>
|
||||||
|
{translate('LibraryImportTips')}
|
||||||
|
<ul>
|
||||||
|
<li className={styles.tip}>
|
||||||
|
<InlineMarkdown data={translate('LibraryImportTipsQualityInEpisodeFilename')} />
|
||||||
|
</li>
|
||||||
|
<li className={styles.tip}>
|
||||||
|
<InlineMarkdown data={translate('LibraryImportTipsSeriesUseRootFolder', { goodFolderExample, badFolderExample })} />
|
||||||
|
</li>
|
||||||
|
<li className={styles.tip}>
|
||||||
|
{translate('LibraryImportTipsDontUseDownloadsFolder')}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
hasRootFolders ?
|
||||||
|
<div className={styles.recentFolders}>
|
||||||
|
<FieldSet legend={translate('RootFolders')}>
|
||||||
|
<RootFolders
|
||||||
|
isFetching={isFetching}
|
||||||
|
isPopulated={isPopulated}
|
||||||
|
error={error}
|
||||||
|
items={items}
|
||||||
|
/>
|
||||||
|
</FieldSet>
|
||||||
|
</div> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isSaving && saveError ?
|
||||||
|
<Alert
|
||||||
|
className={styles.addErrorAlert}
|
||||||
|
kind={kinds.DANGER}
|
||||||
|
>
|
||||||
|
{translate('AddRootFolderError')}
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{
|
||||||
|
Array.isArray(saveError.responseJSON) ?
|
||||||
|
saveError.responseJSON.map((e, index) => {
|
||||||
|
return (
|
||||||
|
<li key={index}>
|
||||||
|
{e.errorMessage}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}) :
|
||||||
|
<li>
|
||||||
|
{
|
||||||
|
JSON.stringify(saveError.responseJSON)
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</Alert> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className={hasRootFolders ? undefined : styles.startImport}>
|
||||||
|
<Button
|
||||||
|
kind={kinds.PRIMARY}
|
||||||
|
size={sizes.LARGE}
|
||||||
|
onPress={this.onAddNewRootFolderPress}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={styles.importButtonIcon}
|
||||||
|
name={icons.DRIVE}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
hasRootFolders ?
|
||||||
|
translate('ChooseAnotherFolder') :
|
||||||
|
translate('StartImport')
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileBrowserModal
|
||||||
|
isOpen={this.state.isAddNewRootFolderModalOpen}
|
||||||
|
name="rootFolderPath"
|
||||||
|
value=""
|
||||||
|
onChange={this.onNewRootFolderSelect}
|
||||||
|
onModalClose={this.onAddRootFolderModalClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</PageContentBody>
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportSeriesSelectFolder.propTypes = {
|
||||||
|
isWindows: PropTypes.bool.isRequired,
|
||||||
|
isFetching: PropTypes.bool.isRequired,
|
||||||
|
isPopulated: PropTypes.bool.isRequired,
|
||||||
|
isSaving: PropTypes.bool.isRequired,
|
||||||
|
error: PropTypes.object,
|
||||||
|
saveError: PropTypes.object,
|
||||||
|
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
onNewRootFolderSelect: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportSeriesSelectFolder;
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import Alert from 'Components/Alert';
|
|
||||||
import FieldSet from 'Components/FieldSet';
|
|
||||||
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import Button from 'Components/Link/Button';
|
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
|
||||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
|
||||||
import PageContent from 'Components/Page/PageContent';
|
|
||||||
import PageContentBody from 'Components/Page/PageContentBody';
|
|
||||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
|
||||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
|
||||||
import RootFolders from 'RootFolder/RootFolders';
|
|
||||||
import {
|
|
||||||
addRootFolder,
|
|
||||||
fetchRootFolders,
|
|
||||||
} from 'Store/Actions/rootFolderActions';
|
|
||||||
import useIsWindows from 'System/useIsWindows';
|
|
||||||
import { InputChanged } from 'typings/inputs';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import styles from './ImportSeriesSelectFolder.css';
|
|
||||||
|
|
||||||
function ImportSeriesSelectFolder() {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const { isFetching, isPopulated, isSaving, error, saveError, items } =
|
|
||||||
useSelector((state: AppState) => state.rootFolders);
|
|
||||||
|
|
||||||
const isWindows = useIsWindows();
|
|
||||||
|
|
||||||
const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] =
|
|
||||||
useState(false);
|
|
||||||
|
|
||||||
const wasSaving = usePrevious(isSaving);
|
|
||||||
|
|
||||||
const hasRootFolders = items.length > 0;
|
|
||||||
const goodFolderExample = isWindows ? 'C:\\tv shows' : '/tv shows';
|
|
||||||
const badFolderExample = isWindows
|
|
||||||
? 'C:\\tv shows\\the simpsons'
|
|
||||||
: '/tv shows/the simpsons';
|
|
||||||
|
|
||||||
const handleAddNewRootFolderPress = useCallback(() => {
|
|
||||||
setIsAddNewRootFolderModalOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAddRootFolderModalClose = useCallback(() => {
|
|
||||||
setIsAddNewRootFolderModalOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNewRootFolderSelect = useCallback(
|
|
||||||
({ value }: InputChanged<string>) => {
|
|
||||||
dispatch(addRootFolder({ path: value }));
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchRootFolders());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isSaving && wasSaving && !saveError) {
|
|
||||||
items.reduce((acc, item) => {
|
|
||||||
if (item.id > acc) {
|
|
||||||
return item.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}, [isSaving, wasSaving, saveError, items]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContent title={translate('ImportSeries')}>
|
|
||||||
<PageContentBody>
|
|
||||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
|
||||||
|
|
||||||
{!isFetching && error ? (
|
|
||||||
<Alert kind={kinds.DANGER}>{translate('RootFoldersLoadError')}</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!error && isPopulated && (
|
|
||||||
<div>
|
|
||||||
<div className={styles.header}>
|
|
||||||
{translate('LibraryImportSeriesHeader')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.tips}>
|
|
||||||
{translate('LibraryImportTips')}
|
|
||||||
<ul>
|
|
||||||
<li className={styles.tip}>
|
|
||||||
<InlineMarkdown
|
|
||||||
data={translate(
|
|
||||||
'LibraryImportTipsQualityInEpisodeFilename'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li className={styles.tip}>
|
|
||||||
<InlineMarkdown
|
|
||||||
data={translate('LibraryImportTipsSeriesUseRootFolder', {
|
|
||||||
goodFolderExample,
|
|
||||||
badFolderExample,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li className={styles.tip}>
|
|
||||||
{translate('LibraryImportTipsDontUseDownloadsFolder')}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasRootFolders ? (
|
|
||||||
<div className={styles.recentFolders}>
|
|
||||||
<FieldSet legend={translate('RootFolders')}>
|
|
||||||
<RootFolders />
|
|
||||||
</FieldSet>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isSaving && saveError ? (
|
|
||||||
<Alert className={styles.addErrorAlert} kind={kinds.DANGER}>
|
|
||||||
{translate('AddRootFolderError')}
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
{Array.isArray(saveError.responseJSON) ? (
|
|
||||||
saveError.responseJSON.map((e, index) => {
|
|
||||||
return <li key={index}>{e.errorMessage}</li>;
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<li>{JSON.stringify(saveError.responseJSON)}</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className={hasRootFolders ? undefined : styles.startImport}>
|
|
||||||
<Button
|
|
||||||
kind={kinds.PRIMARY}
|
|
||||||
size={sizes.LARGE}
|
|
||||||
onPress={handleAddNewRootFolderPress}
|
|
||||||
>
|
|
||||||
<Icon className={styles.importButtonIcon} name={icons.DRIVE} />
|
|
||||||
{hasRootFolders
|
|
||||||
? translate('ChooseAnotherFolder')
|
|
||||||
: translate('StartImport')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FileBrowserModal
|
|
||||||
isOpen={isAddNewRootFolderModalOpen}
|
|
||||||
name="rootFolderPath"
|
|
||||||
value=""
|
|
||||||
onChange={handleNewRootFolderSelect}
|
|
||||||
onModalClose={handleAddRootFolderModalClose}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PageContentBody>
|
|
||||||
</PageContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ImportSeriesSelectFolder;
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { push } from 'connected-react-router';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { addRootFolder, fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||||
|
import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
|
||||||
|
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||||
|
import ImportSeriesSelectFolder from './ImportSeriesSelectFolder';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
createRootFoldersSelector(),
|
||||||
|
createSystemStatusSelector(),
|
||||||
|
(rootFolders, systemStatus) => {
|
||||||
|
return {
|
||||||
|
...rootFolders,
|
||||||
|
isWindows: systemStatus.isWindows
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
fetchRootFolders,
|
||||||
|
addRootFolder,
|
||||||
|
push
|
||||||
|
};
|
||||||
|
|
||||||
|
class ImportSeriesSelectFolderConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.fetchRootFolders();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
isSaving,
|
||||||
|
saveError
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
if (prevProps.isSaving && !isSaving && !saveError) {
|
||||||
|
const newRootFolders = _.differenceBy(items, prevProps.items, (item) => item.id);
|
||||||
|
|
||||||
|
if (newRootFolders.length === 1) {
|
||||||
|
this.props.push(`${window.Sonarr.urlBase}/add/import/${newRootFolders[0].id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onNewRootFolderSelect = (path) => {
|
||||||
|
this.props.addRootFolder({ path });
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<ImportSeriesSelectFolder
|
||||||
|
{...this.props}
|
||||||
|
onNewRootFolderSelect={this.onNewRootFolderSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportSeriesSelectFolderConnector.propTypes = {
|
||||||
|
isSaving: PropTypes.bool.isRequired,
|
||||||
|
saveError: PropTypes.object,
|
||||||
|
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
fetchRootFolders: PropTypes.func.isRequired,
|
||||||
|
addRootFolder: PropTypes.func.isRequired,
|
||||||
|
push: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectFolderConnector);
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
|
|
||||||
import { SeriesMonitor, SeriesType } from 'Series/Series';
|
|
||||||
|
|
||||||
export interface AddSeriesOptions {
|
|
||||||
rootFolderPath: string;
|
|
||||||
monitor: SeriesMonitor;
|
|
||||||
qualityProfileId: number;
|
|
||||||
seriesType: SeriesType;
|
|
||||||
seasonFolder: boolean;
|
|
||||||
searchForMissingEpisodes: boolean;
|
|
||||||
searchForCutoffUnmetEpisodes: boolean;
|
|
||||||
tags: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { useOptions, useOption, setOption } =
|
|
||||||
createOptionsStore<AddSeriesOptions>('add_series_options', () => {
|
|
||||||
return {
|
|
||||||
rootFolderPath: '',
|
|
||||||
monitor: 'all',
|
|
||||||
qualityProfileId: 0,
|
|
||||||
seriesType: 'standard',
|
|
||||||
seasonFolder: true,
|
|
||||||
searchForMissingEpisodes: false,
|
|
||||||
searchForCutoffUnmetEpisodes: false,
|
|
||||||
tags: [],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useAddSeriesOptions = useOptions;
|
|
||||||
export const useAddSeriesOption = useOption;
|
|
||||||
export const setAddSeriesOption = setOption;
|
|
||||||
@@ -4,7 +4,7 @@ import React from 'react';
|
|||||||
import DocumentTitle from 'react-document-title';
|
import DocumentTitle from 'react-document-title';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { Store } from 'redux';
|
import { Store } from 'redux';
|
||||||
import Page from 'Components/Page/Page';
|
import PageConnector from 'Components/Page/PageConnector';
|
||||||
import ApplyTheme from './ApplyTheme';
|
import ApplyTheme from './ApplyTheme';
|
||||||
import AppRoutes from './AppRoutes';
|
import AppRoutes from './AppRoutes';
|
||||||
|
|
||||||
@@ -22,9 +22,9 @@ function App({ store, history }: AppProps) {
|
|||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<ConnectedRouter history={history}>
|
<ConnectedRouter history={history}>
|
||||||
<ApplyTheme />
|
<ApplyTheme />
|
||||||
<Page>
|
<PageConnector>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</Page>
|
</PageConnector>
|
||||||
</ConnectedRouter>
|
</ConnectedRouter>
|
||||||
</Provider>
|
</Provider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -3,36 +3,36 @@ import { Redirect, Route } from 'react-router-dom';
|
|||||||
import Blocklist from 'Activity/Blocklist/Blocklist';
|
import Blocklist from 'Activity/Blocklist/Blocklist';
|
||||||
import History from 'Activity/History/History';
|
import History from 'Activity/History/History';
|
||||||
import Queue from 'Activity/Queue/Queue';
|
import Queue from 'Activity/Queue/Queue';
|
||||||
import AddNewSeries from 'AddSeries/AddNewSeries/AddNewSeries';
|
import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector';
|
||||||
import ImportSeriesPage from 'AddSeries/ImportSeries/ImportSeriesPage';
|
import ImportSeries from 'AddSeries/ImportSeries/ImportSeries';
|
||||||
import CalendarPage from 'Calendar/CalendarPage';
|
import CalendarPage from 'Calendar/CalendarPage';
|
||||||
import NotFound from 'Components/NotFound';
|
import NotFound from 'Components/NotFound';
|
||||||
import Switch from 'Components/Router/Switch';
|
import Switch from 'Components/Router/Switch';
|
||||||
import SeriesDetailsPage from 'Series/Details/SeriesDetailsPage';
|
import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
|
||||||
import SeriesIndex from 'Series/Index/SeriesIndex';
|
import SeriesIndex from 'Series/Index/SeriesIndex';
|
||||||
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
|
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
|
||||||
import DownloadClientSettings from 'Settings/DownloadClients/DownloadClientSettings';
|
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
|
||||||
import GeneralSettings from 'Settings/General/GeneralSettings';
|
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
|
||||||
import ImportListSettings from 'Settings/ImportLists/ImportListSettings';
|
import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
|
||||||
import IndexerSettings from 'Settings/Indexers/IndexerSettings';
|
import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
|
||||||
import MediaManagement from 'Settings/MediaManagement/MediaManagement';
|
import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
|
||||||
import MetadataSettings from 'Settings/Metadata/MetadataSettings';
|
import MetadataSettings from 'Settings/Metadata/MetadataSettings';
|
||||||
import MetadataSourceSettings from 'Settings/MetadataSource/MetadataSourceSettings';
|
import MetadataSourceSettings from 'Settings/MetadataSource/MetadataSourceSettings';
|
||||||
import NotificationSettings from 'Settings/Notifications/NotificationSettings';
|
import NotificationSettings from 'Settings/Notifications/NotificationSettings';
|
||||||
import Profiles from 'Settings/Profiles/Profiles';
|
import Profiles from 'Settings/Profiles/Profiles';
|
||||||
import Quality from 'Settings/Quality/Quality';
|
import QualityConnector from 'Settings/Quality/QualityConnector';
|
||||||
import Settings from 'Settings/Settings';
|
import Settings from 'Settings/Settings';
|
||||||
import TagSettings from 'Settings/Tags/TagSettings';
|
import TagSettings from 'Settings/Tags/TagSettings';
|
||||||
import UISettings from 'Settings/UI/UISettings';
|
import UISettingsConnector from 'Settings/UI/UISettingsConnector';
|
||||||
import Backups from 'System/Backup/Backups';
|
import BackupsConnector from 'System/Backup/BackupsConnector';
|
||||||
import LogsTable from 'System/Events/LogsTable';
|
import LogsTableConnector from 'System/Events/LogsTableConnector';
|
||||||
import Logs from 'System/Logs/Logs';
|
import Logs from 'System/Logs/Logs';
|
||||||
import Status from 'System/Status/Status';
|
import Status from 'System/Status/Status';
|
||||||
import Tasks from 'System/Tasks/Tasks';
|
import Tasks from 'System/Tasks/Tasks';
|
||||||
import Updates from 'System/Updates/Updates';
|
import Updates from 'System/Updates/Updates';
|
||||||
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
|
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
|
||||||
import CutoffUnmet from 'Wanted/CutoffUnmet/CutoffUnmet';
|
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
|
||||||
import Missing from 'Wanted/Missing/Missing';
|
import MissingConnector from 'Wanted/Missing/MissingConnector';
|
||||||
|
|
||||||
function RedirectWithUrlBase() {
|
function RedirectWithUrlBase() {
|
||||||
return <Redirect to={getPathWithUrlBase('/')} />;
|
return <Redirect to={getPathWithUrlBase('/')} />;
|
||||||
@@ -58,15 +58,15 @@ function AppRoutes() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Route path="/add/new" component={AddNewSeries} />
|
<Route path="/add/new" component={AddNewSeriesConnector} />
|
||||||
|
|
||||||
<Route path="/add/import" component={ImportSeriesPage} />
|
<Route path="/add/import" component={ImportSeries} />
|
||||||
|
|
||||||
<Route path="/serieseditor" exact={true} render={RedirectWithUrlBase} />
|
<Route path="/serieseditor" exact={true} render={RedirectWithUrlBase} />
|
||||||
|
|
||||||
<Route path="/seasonpass" exact={true} render={RedirectWithUrlBase} />
|
<Route path="/seasonpass" exact={true} render={RedirectWithUrlBase} />
|
||||||
|
|
||||||
<Route path="/series/:titleSlug" component={SeriesDetailsPage} />
|
<Route path="/series/:titleSlug" component={SeriesDetailsPageConnector} />
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
Calendar
|
Calendar
|
||||||
@@ -88,9 +88,9 @@ function AppRoutes() {
|
|||||||
Wanted
|
Wanted
|
||||||
*/}
|
*/}
|
||||||
|
|
||||||
<Route path="/wanted/missing" component={Missing} />
|
<Route path="/wanted/missing" component={MissingConnector} />
|
||||||
|
|
||||||
<Route path="/wanted/cutoffunmet" component={CutoffUnmet} />
|
<Route path="/wanted/cutoffunmet" component={CutoffUnmetConnector} />
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
Settings
|
Settings
|
||||||
@@ -98,25 +98,31 @@ function AppRoutes() {
|
|||||||
|
|
||||||
<Route exact={true} path="/settings" component={Settings} />
|
<Route exact={true} path="/settings" component={Settings} />
|
||||||
|
|
||||||
<Route path="/settings/mediamanagement" component={MediaManagement} />
|
<Route
|
||||||
|
path="/settings/mediamanagement"
|
||||||
|
component={MediaManagementConnector}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="/settings/profiles" component={Profiles} />
|
<Route path="/settings/profiles" component={Profiles} />
|
||||||
|
|
||||||
<Route path="/settings/quality" component={Quality} />
|
<Route path="/settings/quality" component={QualityConnector} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/settings/customformats"
|
path="/settings/customformats"
|
||||||
component={CustomFormatSettingsPage}
|
component={CustomFormatSettingsPage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/settings/indexers" component={IndexerSettings} />
|
<Route path="/settings/indexers" component={IndexerSettingsConnector} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/settings/downloadclients"
|
path="/settings/downloadclients"
|
||||||
component={DownloadClientSettings}
|
component={DownloadClientSettingsConnector}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/settings/importlists" component={ImportListSettings} />
|
<Route
|
||||||
|
path="/settings/importlists"
|
||||||
|
component={ImportListSettingsConnector}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="/settings/connect" component={NotificationSettings} />
|
<Route path="/settings/connect" component={NotificationSettings} />
|
||||||
|
|
||||||
@@ -129,9 +135,9 @@ function AppRoutes() {
|
|||||||
|
|
||||||
<Route path="/settings/tags" component={TagSettings} />
|
<Route path="/settings/tags" component={TagSettings} />
|
||||||
|
|
||||||
<Route path="/settings/general" component={GeneralSettings} />
|
<Route path="/settings/general" component={GeneralSettingsConnector} />
|
||||||
|
|
||||||
<Route path="/settings/ui" component={UISettings} />
|
<Route path="/settings/ui" component={UISettingsConnector} />
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
System
|
System
|
||||||
@@ -141,11 +147,11 @@ function AppRoutes() {
|
|||||||
|
|
||||||
<Route path="/system/tasks" component={Tasks} />
|
<Route path="/system/tasks" component={Tasks} />
|
||||||
|
|
||||||
<Route path="/system/backup" component={Backups} />
|
<Route path="/system/backup" component={BackupsConnector} />
|
||||||
|
|
||||||
<Route path="/system/updates" component={Updates} />
|
<Route path="/system/updates" component={Updates} />
|
||||||
|
|
||||||
<Route path="/system/events" component={LogsTable} />
|
<Route path="/system/events" component={LogsTableConnector} />
|
||||||
|
|
||||||
<Route path="/system/logs/files" component={Logs} />
|
<Route path="/system/logs/files" component={Logs} />
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import usePrevious from 'Helpers/Hooks/usePrevious';
|
|||||||
import { kinds } from 'Helpers/Props';
|
import { kinds } from 'Helpers/Props';
|
||||||
import { fetchUpdates } from 'Store/Actions/systemActions';
|
import { fetchUpdates } from 'Store/Actions/systemActions';
|
||||||
import UpdateChanges from 'System/Updates/UpdateChanges';
|
import UpdateChanges from 'System/Updates/UpdateChanges';
|
||||||
import useUpdates from 'System/Updates/useUpdates';
|
|
||||||
import Update from 'typings/Update';
|
import Update from 'typings/Update';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import AppState from './State/AppState';
|
import AppState from './State/AppState';
|
||||||
@@ -66,12 +65,14 @@ interface AppUpdatedModalContentProps {
|
|||||||
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { version, prevVersion } = useSelector((state: AppState) => state.app);
|
const { version, prevVersion } = useSelector((state: AppState) => state.app);
|
||||||
const { isFetched, error, data } = useUpdates();
|
const { isPopulated, error, items } = useSelector(
|
||||||
|
(state: AppState) => state.system.updates
|
||||||
|
);
|
||||||
const previousVersion = usePrevious(version);
|
const previousVersion = usePrevious(version);
|
||||||
|
|
||||||
const { onModalClose } = props;
|
const { onModalClose } = props;
|
||||||
|
|
||||||
const update = mergeUpdates(data, version, prevVersion);
|
const update = mergeUpdates(items, version, prevVersion);
|
||||||
|
|
||||||
const handleSeeChangesPress = useCallback(() => {
|
const handleSeeChangesPress = useCallback(() => {
|
||||||
window.location.href = `${window.Sonarr.urlBase}/system/updates`;
|
window.location.href = `${window.Sonarr.urlBase}/system/updates`;
|
||||||
@@ -99,7 +100,7 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isFetched && !error && !!update ? (
|
{isPopulated && !error && !!update ? (
|
||||||
<div>
|
<div>
|
||||||
{update.changes ? (
|
{update.changes ? (
|
||||||
<div className={styles.maintenance}>
|
<div className={styles.maintenance}>
|
||||||
@@ -125,7 +126,7 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isFetched && !error ? <LoadingIndicator /> : null}
|
{!isPopulated && !error ? <LoadingIndicator /> : null}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import useTheme from 'Helpers/Hooks/useTheme';
|
import { useSelector } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
import themes from 'Styles/Themes';
|
import themes from 'Styles/Themes';
|
||||||
|
import AppState from './State/AppState';
|
||||||
|
|
||||||
|
function createThemeSelector() {
|
||||||
|
return createSelector(
|
||||||
|
(state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
|
||||||
|
(theme) => {
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ApplyTheme() {
|
function ApplyTheme() {
|
||||||
const theme = useTheme();
|
const theme = useSelector(createThemeSelector());
|
||||||
|
|
||||||
const updateCSSVariables = useCallback(() => {
|
const updateCSSVariables = useCallback(() => {
|
||||||
Object.entries(themes[theme]).forEach(([key, value]) => {
|
Object.entries(themes[theme]).forEach(([key, value]) => {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { cloneDeep } from 'lodash';
|
import { cloneDeep } from 'lodash';
|
||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import useSelectState, {
|
import useSelectState, { SelectState } from 'Helpers/Hooks/useSelectState';
|
||||||
SelectState,
|
|
||||||
SelectStateModel,
|
|
||||||
} from 'Helpers/Hooks/useSelectState';
|
|
||||||
import ModelBase from './ModelBase';
|
import ModelBase from './ModelBase';
|
||||||
|
|
||||||
export type SelectContextAction =
|
export type SelectContextAction =
|
||||||
@@ -12,13 +9,13 @@ export type SelectContextAction =
|
|||||||
| { type: 'unselectAll' }
|
| { type: 'unselectAll' }
|
||||||
| {
|
| {
|
||||||
type: 'toggleSelected';
|
type: 'toggleSelected';
|
||||||
id: number | string;
|
id: number;
|
||||||
isSelected: boolean | null;
|
isSelected: boolean;
|
||||||
shiftKey: boolean;
|
shiftKey: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'removeItem';
|
type: 'removeItem';
|
||||||
id: number | string;
|
id: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'updateItems';
|
type: 'updateItems';
|
||||||
@@ -27,7 +24,7 @@ export type SelectContextAction =
|
|||||||
|
|
||||||
export type SelectDispatch = (action: SelectContextAction) => void;
|
export type SelectDispatch = (action: SelectContextAction) => void;
|
||||||
|
|
||||||
interface SelectProviderOptions<T extends SelectStateModel> {
|
interface SelectProviderOptions<T extends ModelBase> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
children: any;
|
children: any;
|
||||||
items: Array<T>;
|
items: Array<T>;
|
||||||
@@ -37,7 +34,7 @@ const SelectContext = React.createContext<
|
|||||||
[SelectState, SelectDispatch] | undefined
|
[SelectState, SelectDispatch] | undefined
|
||||||
>(cloneDeep(undefined));
|
>(cloneDeep(undefined));
|
||||||
|
|
||||||
export function SelectProvider<T extends SelectStateModel>(
|
export function SelectProvider<T extends ModelBase>(
|
||||||
props: SelectProviderOptions<T>
|
props: SelectProviderOptions<T>
|
||||||
) {
|
) {
|
||||||
const { items } = props;
|
const { items } = props;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Column from 'Components/Table/Column';
|
import Column from 'Components/Table/Column';
|
||||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||||
import { ValidationFailure } from 'typings/pending';
|
import { ValidationFailure } from 'typings/pending';
|
||||||
import { Filter, FilterBuilderProp } from './AppState';
|
import { FilterBuilderProp, PropertyFilter } from './AppState';
|
||||||
|
|
||||||
export interface Error {
|
export interface Error {
|
||||||
status?: number;
|
status?: number;
|
||||||
@@ -35,7 +35,7 @@ export interface TableAppSectionState {
|
|||||||
|
|
||||||
export interface AppSectionFilterState<T> {
|
export interface AppSectionFilterState<T> {
|
||||||
selectedFilterKey: string;
|
selectedFilterKey: string;
|
||||||
filters: Filter[];
|
filters: PropertyFilter[];
|
||||||
filterBuilderProps: FilterBuilderProp<T>[];
|
filterBuilderProps: FilterBuilderProp<T>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,8 +43,9 @@ export interface AppSectionSchemaState<T> {
|
|||||||
isSchemaFetching: boolean;
|
isSchemaFetching: boolean;
|
||||||
isSchemaPopulated: boolean;
|
isSchemaPopulated: boolean;
|
||||||
schemaError: Error;
|
schemaError: Error;
|
||||||
schema: T[];
|
schema: {
|
||||||
selectedSchema?: T;
|
items: T[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSectionItemSchemaState<T> {
|
export interface AppSectionItemSchemaState<T> {
|
||||||
@@ -62,23 +63,14 @@ export interface AppSectionItemState<T> {
|
|||||||
item: T;
|
item: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSectionListState<T> {
|
|
||||||
isFetching: boolean;
|
|
||||||
isPopulated: boolean;
|
|
||||||
error: Error;
|
|
||||||
items: T[];
|
|
||||||
pendingChanges: Partial<T>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppSectionProviderState<T>
|
export interface AppSectionProviderState<T>
|
||||||
extends AppSectionDeleteState,
|
extends AppSectionDeleteState,
|
||||||
AppSectionSaveState {
|
AppSectionSaveState {
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
isPopulated: boolean;
|
isPopulated: boolean;
|
||||||
isTesting?: boolean;
|
|
||||||
error: Error;
|
error: Error;
|
||||||
items: T[];
|
items: T[];
|
||||||
pendingChanges?: Partial<T>;
|
pendingChanges: Partial<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppSectionState<T> {
|
interface AppSectionState<T> {
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
import ModelBase from 'App/ModelBase';
|
|
||||||
import { FilterBuilderTypes } from 'Helpers/Props/filterBuilderTypes';
|
|
||||||
import { DateFilterValue, FilterType } from 'Helpers/Props/filterTypes';
|
|
||||||
import { Error } from './AppSectionState';
|
|
||||||
import BlocklistAppState from './BlocklistAppState';
|
import BlocklistAppState from './BlocklistAppState';
|
||||||
import CalendarAppState from './CalendarAppState';
|
import CalendarAppState from './CalendarAppState';
|
||||||
import CaptchaAppState from './CaptchaAppState';
|
import CaptchaAppState from './CaptchaAppState';
|
||||||
import CommandAppState from './CommandAppState';
|
import CommandAppState from './CommandAppState';
|
||||||
import CustomFiltersAppState from './CustomFiltersAppState';
|
|
||||||
import EpisodeFilesAppState from './EpisodeFilesAppState';
|
import EpisodeFilesAppState from './EpisodeFilesAppState';
|
||||||
import EpisodesAppState from './EpisodesAppState';
|
import EpisodesAppState from './EpisodesAppState';
|
||||||
import HistoryAppState, { SeriesHistoryAppState } from './HistoryAppState';
|
import HistoryAppState from './HistoryAppState';
|
||||||
import ImportSeriesAppState from './ImportSeriesAppState';
|
|
||||||
import InteractiveImportAppState from './InteractiveImportAppState';
|
import InteractiveImportAppState from './InteractiveImportAppState';
|
||||||
import MessagesAppState from './MessagesAppState';
|
|
||||||
import OAuthAppState from './OAuthAppState';
|
import OAuthAppState from './OAuthAppState';
|
||||||
import OrganizePreviewAppState from './OrganizePreviewAppState';
|
|
||||||
import ParseAppState from './ParseAppState';
|
import ParseAppState from './ParseAppState';
|
||||||
import PathsAppState from './PathsAppState';
|
import PathsAppState from './PathsAppState';
|
||||||
import ProviderOptionsAppState from './ProviderOptionsAppState';
|
import ProviderOptionsAppState from './ProviderOptionsAppState';
|
||||||
|
import QueueAppState from './QueueAppState';
|
||||||
import ReleasesAppState from './ReleasesAppState';
|
import ReleasesAppState from './ReleasesAppState';
|
||||||
import RootFolderAppState from './RootFolderAppState';
|
import RootFolderAppState from './RootFolderAppState';
|
||||||
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
|
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
|
||||||
@@ -26,43 +19,41 @@ import SystemAppState from './SystemAppState';
|
|||||||
import TagsAppState from './TagsAppState';
|
import TagsAppState from './TagsAppState';
|
||||||
import WantedAppState from './WantedAppState';
|
import WantedAppState from './WantedAppState';
|
||||||
|
|
||||||
export interface FilterBuilderPropOption {
|
interface FilterBuilderPropOption {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterBuilderProp<T> {
|
export interface FilterBuilderProp<T> {
|
||||||
name: string;
|
name: string;
|
||||||
label: string | (() => string);
|
label: string;
|
||||||
type: FilterBuilderTypes;
|
type: string;
|
||||||
valueType?: string;
|
valueType?: string;
|
||||||
optionsSelector?: (items: T[]) => FilterBuilderPropOption[];
|
optionsSelector?: (items: T[]) => FilterBuilderPropOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PropertyFilter {
|
export interface PropertyFilter {
|
||||||
key: string;
|
key: string;
|
||||||
value: string | string[] | number[] | boolean[] | DateFilterValue;
|
value: boolean | string | number | string[] | number[];
|
||||||
type: FilterType;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Filter {
|
export interface Filter {
|
||||||
key: string;
|
key: string;
|
||||||
label: string | (() => string);
|
label: string;
|
||||||
filters: PropertyFilter[];
|
filters: PropertyFilter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomFilter extends ModelBase {
|
export interface CustomFilter {
|
||||||
|
id: number;
|
||||||
type: string;
|
type: string;
|
||||||
label: string;
|
label: string;
|
||||||
filters: PropertyFilter[];
|
filters: PropertyFilter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSectionState {
|
export interface AppSectionState {
|
||||||
isUpdated: boolean;
|
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isDisconnected: boolean;
|
|
||||||
isReconnecting: boolean;
|
isReconnecting: boolean;
|
||||||
isRestarting: boolean;
|
|
||||||
isSidebarVisible: boolean;
|
isSidebarVisible: boolean;
|
||||||
version: string;
|
version: string;
|
||||||
prevVersion?: string;
|
prevVersion?: string;
|
||||||
@@ -72,11 +63,6 @@ export interface AppSectionState {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
translations: {
|
|
||||||
error?: Error;
|
|
||||||
isPopulated: boolean;
|
|
||||||
};
|
|
||||||
messages: MessagesAppState;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
@@ -85,23 +71,20 @@ interface AppState {
|
|||||||
calendar: CalendarAppState;
|
calendar: CalendarAppState;
|
||||||
captcha: CaptchaAppState;
|
captcha: CaptchaAppState;
|
||||||
commands: CommandAppState;
|
commands: CommandAppState;
|
||||||
customFilters: CustomFiltersAppState;
|
|
||||||
episodeFiles: EpisodeFilesAppState;
|
episodeFiles: EpisodeFilesAppState;
|
||||||
episodeHistory: HistoryAppState;
|
episodeHistory: HistoryAppState;
|
||||||
episodes: EpisodesAppState;
|
episodes: EpisodesAppState;
|
||||||
episodesSelection: EpisodesAppState;
|
episodesSelection: EpisodesAppState;
|
||||||
history: HistoryAppState;
|
history: HistoryAppState;
|
||||||
importSeries: ImportSeriesAppState;
|
|
||||||
interactiveImport: InteractiveImportAppState;
|
interactiveImport: InteractiveImportAppState;
|
||||||
oAuth: OAuthAppState;
|
oAuth: OAuthAppState;
|
||||||
organizePreview: OrganizePreviewAppState;
|
|
||||||
parse: ParseAppState;
|
parse: ParseAppState;
|
||||||
paths: PathsAppState;
|
paths: PathsAppState;
|
||||||
providerOptions: ProviderOptionsAppState;
|
providerOptions: ProviderOptionsAppState;
|
||||||
|
queue: QueueAppState;
|
||||||
releases: ReleasesAppState;
|
releases: ReleasesAppState;
|
||||||
rootFolders: RootFolderAppState;
|
rootFolders: RootFolderAppState;
|
||||||
series: SeriesAppState;
|
series: SeriesAppState;
|
||||||
seriesHistory: SeriesHistoryAppState;
|
|
||||||
seriesIndex: SeriesIndexAppState;
|
seriesIndex: SeriesIndexAppState;
|
||||||
settings: SettingsAppState;
|
settings: SettingsAppState;
|
||||||
system: SystemAppState;
|
system: SystemAppState;
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import Backup from 'typings/Backup';
|
|
||||||
import AppSectionState, { Error } from './AppSectionState';
|
|
||||||
|
|
||||||
interface BackupAppState extends AppSectionState<Backup> {
|
|
||||||
isRestoring: boolean;
|
|
||||||
restoreError?: Error;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default BackupAppState;
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import AppSectionState, {
|
import AppSectionState, {
|
||||||
AppSectionDeleteState,
|
AppSectionDeleteState,
|
||||||
AppSectionSaveState,
|
|
||||||
} from 'App/State/AppSectionState';
|
} from 'App/State/AppSectionState';
|
||||||
import { CustomFilter } from './AppState';
|
import { CustomFilter } from './AppState';
|
||||||
|
|
||||||
interface CustomFiltersAppState
|
interface CustomFiltersAppState
|
||||||
extends AppSectionState<CustomFilter>,
|
extends AppSectionState<CustomFilter>,
|
||||||
AppSectionDeleteState,
|
AppSectionDeleteState {}
|
||||||
AppSectionSaveState {}
|
|
||||||
|
|
||||||
export default CustomFiltersAppState;
|
export default CustomFiltersAppState;
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import AppSectionState from 'App/State/AppSectionState';
|
import AppSectionState from 'App/State/AppSectionState';
|
||||||
import Column from 'Components/Table/Column';
|
|
||||||
import Episode from 'Episode/Episode';
|
import Episode from 'Episode/Episode';
|
||||||
|
|
||||||
interface EpisodesAppState extends AppSectionState<Episode> {
|
type EpisodesAppState = AppSectionState<Episode>;
|
||||||
columns: Column[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EpisodesAppState;
|
export default EpisodesAppState;
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import AppSectionState, {
|
|||||||
} from 'App/State/AppSectionState';
|
} from 'App/State/AppSectionState';
|
||||||
import History from 'typings/History';
|
import History from 'typings/History';
|
||||||
|
|
||||||
export type SeriesHistoryAppState = AppSectionState<History>;
|
|
||||||
|
|
||||||
interface HistoryAppState
|
interface HistoryAppState
|
||||||
extends AppSectionState<History>,
|
extends AppSectionState<History>,
|
||||||
AppSectionFilterState<History>,
|
AppSectionFilterState<History>,
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import Series, { SeriesMonitor, SeriesType } from 'Series/Series';
|
|
||||||
import { Error } from './AppSectionState';
|
|
||||||
|
|
||||||
export interface ImportSeries {
|
|
||||||
id: string;
|
|
||||||
error?: Error;
|
|
||||||
isFetching: boolean;
|
|
||||||
isPopulated: boolean;
|
|
||||||
isQueued: boolean;
|
|
||||||
items: Series[];
|
|
||||||
monitor: SeriesMonitor;
|
|
||||||
path: string;
|
|
||||||
qualityProfileId: number;
|
|
||||||
relativePath: string;
|
|
||||||
seasonFolder: boolean;
|
|
||||||
selectedSeries?: Series;
|
|
||||||
seriesType: SeriesType;
|
|
||||||
term: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportSeriesAppState {
|
|
||||||
isLookingUpSeries: false;
|
|
||||||
isImporting: false;
|
|
||||||
isImported: false;
|
|
||||||
importError: Error | null;
|
|
||||||
items: ImportSeries[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ImportSeriesAppState;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import ModelBase from 'App/ModelBase';
|
|
||||||
import AppSectionState from 'App/State/AppSectionState';
|
|
||||||
|
|
||||||
export type MessageType = 'error' | 'info' | 'success' | 'warning';
|
|
||||||
|
|
||||||
export interface Message extends ModelBase {
|
|
||||||
hideAfter: number;
|
|
||||||
message: string;
|
|
||||||
name: string;
|
|
||||||
type: MessageType;
|
|
||||||
}
|
|
||||||
|
|
||||||
type MessagesAppState = AppSectionState<Message>;
|
|
||||||
|
|
||||||
export default MessagesAppState;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import ModelBase from 'App/ModelBase';
|
|
||||||
import AppSectionState from 'App/State/AppSectionState';
|
|
||||||
|
|
||||||
export interface OrganizePreviewModel extends ModelBase {
|
|
||||||
seriesId: number;
|
|
||||||
seasonNumber: number;
|
|
||||||
episodeNumbers: number[];
|
|
||||||
episodeFileId: number;
|
|
||||||
existingPath: string;
|
|
||||||
newPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type OrganizePreviewAppState = AppSectionState<OrganizePreviewModel>;
|
|
||||||
|
|
||||||
export default OrganizePreviewAppState;
|
|
||||||
44
frontend/src/App/State/QueueAppState.ts
Normal file
44
frontend/src/App/State/QueueAppState.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import Queue from 'typings/Queue';
|
||||||
|
import AppSectionState, {
|
||||||
|
AppSectionFilterState,
|
||||||
|
AppSectionItemState,
|
||||||
|
Error,
|
||||||
|
PagedAppSectionState,
|
||||||
|
TableAppSectionState,
|
||||||
|
} from './AppSectionState';
|
||||||
|
|
||||||
|
export interface QueueStatus {
|
||||||
|
totalCount: number;
|
||||||
|
count: number;
|
||||||
|
unknownCount: number;
|
||||||
|
errors: boolean;
|
||||||
|
warnings: boolean;
|
||||||
|
unknownErrors: boolean;
|
||||||
|
unknownWarnings: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueDetailsAppState extends AppSectionState<Queue> {
|
||||||
|
params: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueuePagedAppState
|
||||||
|
extends AppSectionState<Queue>,
|
||||||
|
AppSectionFilterState<Queue>,
|
||||||
|
PagedAppSectionState,
|
||||||
|
TableAppSectionState {
|
||||||
|
isGrabbing: boolean;
|
||||||
|
grabError: Error;
|
||||||
|
isRemoving: boolean;
|
||||||
|
removeError: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueueAppState {
|
||||||
|
status: AppSectionItemState<QueueStatus>;
|
||||||
|
details: QueueDetailsAppState;
|
||||||
|
paged: QueuePagedAppState;
|
||||||
|
options: {
|
||||||
|
includeUnknownSeriesItems: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QueueAppState;
|
||||||
@@ -2,16 +2,11 @@ import AppSectionState, {
|
|||||||
AppSectionDeleteState,
|
AppSectionDeleteState,
|
||||||
AppSectionItemSchemaState,
|
AppSectionItemSchemaState,
|
||||||
AppSectionItemState,
|
AppSectionItemState,
|
||||||
AppSectionListState,
|
|
||||||
AppSectionSaveState,
|
AppSectionSaveState,
|
||||||
AppSectionSchemaState,
|
|
||||||
PagedAppSectionState,
|
PagedAppSectionState,
|
||||||
} from 'App/State/AppSectionState';
|
} from 'App/State/AppSectionState';
|
||||||
import Language from 'Language/Language';
|
import Language from 'Language/Language';
|
||||||
import AutoTagging, { AutoTaggingSpecification } from 'typings/AutoTagging';
|
|
||||||
import CustomFormat from 'typings/CustomFormat';
|
import CustomFormat from 'typings/CustomFormat';
|
||||||
import CustomFormatSpecification from 'typings/CustomFormatSpecification';
|
|
||||||
import DelayProfile from 'typings/DelayProfile';
|
|
||||||
import DownloadClient from 'typings/DownloadClient';
|
import DownloadClient from 'typings/DownloadClient';
|
||||||
import ImportList from 'typings/ImportList';
|
import ImportList from 'typings/ImportList';
|
||||||
import ImportListExclusion from 'typings/ImportListExclusion';
|
import ImportListExclusion from 'typings/ImportListExclusion';
|
||||||
@@ -19,59 +14,25 @@ import ImportListOptionsSettings from 'typings/ImportListOptionsSettings';
|
|||||||
import Indexer from 'typings/Indexer';
|
import Indexer from 'typings/Indexer';
|
||||||
import IndexerFlag from 'typings/IndexerFlag';
|
import IndexerFlag from 'typings/IndexerFlag';
|
||||||
import Notification from 'typings/Notification';
|
import Notification from 'typings/Notification';
|
||||||
import QualityDefinition from 'typings/QualityDefinition';
|
|
||||||
import QualityProfile from 'typings/QualityProfile';
|
import QualityProfile from 'typings/QualityProfile';
|
||||||
import DownloadClientOptions from 'typings/Settings/DownloadClientOptions';
|
|
||||||
import General from 'typings/Settings/General';
|
import General from 'typings/Settings/General';
|
||||||
import IndexerOptions from 'typings/Settings/IndexerOptions';
|
|
||||||
import MediaManagement from 'typings/Settings/MediaManagement';
|
|
||||||
import NamingConfig from 'typings/Settings/NamingConfig';
|
import NamingConfig from 'typings/Settings/NamingConfig';
|
||||||
import NamingExample from 'typings/Settings/NamingExample';
|
import NamingExample from 'typings/Settings/NamingExample';
|
||||||
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
||||||
import RemotePathMapping from 'typings/Settings/RemotePathMapping';
|
|
||||||
import UiSettings from 'typings/Settings/UiSettings';
|
import UiSettings from 'typings/Settings/UiSettings';
|
||||||
import MetadataAppState from './MetadataAppState';
|
import MetadataAppState from './MetadataAppState';
|
||||||
|
|
||||||
type Presets<T> = T & {
|
|
||||||
presets: T[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface AutoTaggingAppState
|
|
||||||
extends AppSectionState<AutoTagging>,
|
|
||||||
AppSectionDeleteState,
|
|
||||||
AppSectionSaveState {}
|
|
||||||
|
|
||||||
export interface AutoTaggingSpecificationAppState
|
|
||||||
extends AppSectionState<AutoTaggingSpecification>,
|
|
||||||
AppSectionDeleteState,
|
|
||||||
AppSectionSaveState,
|
|
||||||
AppSectionSchemaState<AutoTaggingSpecification> {}
|
|
||||||
|
|
||||||
export interface DelayProfileAppState
|
|
||||||
extends AppSectionListState<DelayProfile>,
|
|
||||||
AppSectionDeleteState,
|
|
||||||
AppSectionSaveState {}
|
|
||||||
|
|
||||||
export interface DownloadClientAppState
|
export interface DownloadClientAppState
|
||||||
extends AppSectionState<DownloadClient>,
|
extends AppSectionState<DownloadClient>,
|
||||||
AppSectionDeleteState,
|
AppSectionDeleteState,
|
||||||
AppSectionSaveState,
|
AppSectionSaveState {
|
||||||
AppSectionSchemaState<Presets<DownloadClient>> {
|
|
||||||
isTestingAll: boolean;
|
isTestingAll: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadClientOptionsAppState
|
|
||||||
extends AppSectionItemState<DownloadClientOptions>,
|
|
||||||
AppSectionSaveState {}
|
|
||||||
|
|
||||||
export interface GeneralAppState
|
export interface GeneralAppState
|
||||||
extends AppSectionItemState<General>,
|
extends AppSectionItemState<General>,
|
||||||
AppSectionSaveState {}
|
AppSectionSaveState {}
|
||||||
|
|
||||||
export interface MediaManagementAppState
|
|
||||||
extends AppSectionItemState<MediaManagement>,
|
|
||||||
AppSectionSaveState {}
|
|
||||||
|
|
||||||
export interface NamingAppState
|
export interface NamingAppState
|
||||||
extends AppSectionItemState<NamingConfig>,
|
extends AppSectionItemState<NamingConfig>,
|
||||||
AppSectionSaveState {}
|
AppSectionSaveState {}
|
||||||
@@ -81,42 +42,22 @@ export type NamingExamplesAppState = AppSectionItemState<NamingExample>;
|
|||||||
export interface ImportListAppState
|
export interface ImportListAppState
|
||||||
extends AppSectionState<ImportList>,
|
extends AppSectionState<ImportList>,
|
||||||
AppSectionDeleteState,
|
AppSectionDeleteState,
|
||||||
AppSectionSaveState,
|
|
||||||
AppSectionSchemaState<Presets<ImportList>> {
|
|
||||||
isTestingAll: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IndexerOptionsAppState
|
|
||||||
extends AppSectionItemState<IndexerOptions>,
|
|
||||||
AppSectionSaveState {}
|
AppSectionSaveState {}
|
||||||
|
|
||||||
export interface IndexerAppState
|
export interface IndexerAppState
|
||||||
extends AppSectionState<Indexer>,
|
extends AppSectionState<Indexer>,
|
||||||
AppSectionDeleteState,
|
AppSectionDeleteState,
|
||||||
AppSectionSaveState,
|
AppSectionSaveState {
|
||||||
AppSectionSchemaState<Presets<Indexer>> {
|
|
||||||
isTestingAll: boolean;
|
isTestingAll: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationAppState
|
export interface NotificationAppState
|
||||||
extends AppSectionState<Notification>,
|
extends AppSectionState<Notification>,
|
||||||
AppSectionDeleteState,
|
AppSectionDeleteState {}
|
||||||
AppSectionSaveState,
|
|
||||||
AppSectionSchemaState<Presets<Notification>> {}
|
|
||||||
|
|
||||||
export interface QualityDefinitionsAppState
|
|
||||||
extends AppSectionState<QualityDefinition>,
|
|
||||||
AppSectionSaveState {
|
|
||||||
pendingChanges: {
|
|
||||||
[key: number]: Partial<QualityProfile>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QualityProfilesAppState
|
export interface QualityProfilesAppState
|
||||||
extends AppSectionState<QualityProfile>,
|
extends AppSectionState<QualityProfile>,
|
||||||
AppSectionItemSchemaState<QualityProfile>,
|
AppSectionItemSchemaState<QualityProfile> {}
|
||||||
AppSectionDeleteState,
|
|
||||||
AppSectionSaveState {}
|
|
||||||
|
|
||||||
export interface ReleaseProfilesAppState
|
export interface ReleaseProfilesAppState
|
||||||
extends AppSectionState<ReleaseProfile>,
|
extends AppSectionState<ReleaseProfile>,
|
||||||
@@ -129,12 +70,6 @@ export interface CustomFormatAppState
|
|||||||
AppSectionDeleteState,
|
AppSectionDeleteState,
|
||||||
AppSectionSaveState {}
|
AppSectionSaveState {}
|
||||||
|
|
||||||
export interface CustomFormatSpecificationAppState
|
|
||||||
extends AppSectionState<CustomFormatSpecification>,
|
|
||||||
AppSectionDeleteState,
|
|
||||||
AppSectionSaveState,
|
|
||||||
AppSectionSchemaState<Presets<CustomFormatSpecification>> {}
|
|
||||||
|
|
||||||
export interface ImportListOptionsSettingsAppState
|
export interface ImportListOptionsSettingsAppState
|
||||||
extends AppSectionItemState<ImportListOptionsSettings>,
|
extends AppSectionItemState<ImportListOptionsSettings>,
|
||||||
AppSectionSaveState {}
|
AppSectionSaveState {}
|
||||||
@@ -147,43 +82,27 @@ export interface ImportListExclusionsSettingsAppState
|
|||||||
pendingChanges: Partial<ImportListExclusion>;
|
pendingChanges: Partial<ImportListExclusion>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemotePathMappingsAppState
|
|
||||||
extends AppSectionState<RemotePathMapping>,
|
|
||||||
AppSectionDeleteState,
|
|
||||||
AppSectionSaveState {
|
|
||||||
pendingChanges: Partial<RemotePathMapping>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>;
|
export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>;
|
||||||
export type LanguageSettingsAppState = AppSectionState<Language>;
|
export type LanguageSettingsAppState = AppSectionState<Language>;
|
||||||
export type UiSettingsAppState = AppSectionItemState<UiSettings>;
|
export type UiSettingsAppState = AppSectionItemState<UiSettings>;
|
||||||
|
|
||||||
interface SettingsAppState {
|
interface SettingsAppState {
|
||||||
advancedSettings: boolean;
|
advancedSettings: boolean;
|
||||||
autoTaggings: AutoTaggingAppState;
|
|
||||||
autoTaggingSpecifications: AutoTaggingSpecificationAppState;
|
|
||||||
customFormats: CustomFormatAppState;
|
customFormats: CustomFormatAppState;
|
||||||
customFormatSpecifications: CustomFormatSpecificationAppState;
|
|
||||||
delayProfiles: DelayProfileAppState;
|
|
||||||
downloadClients: DownloadClientAppState;
|
downloadClients: DownloadClientAppState;
|
||||||
downloadClientOptions: DownloadClientOptionsAppState;
|
|
||||||
general: GeneralAppState;
|
general: GeneralAppState;
|
||||||
importListExclusions: ImportListExclusionsSettingsAppState;
|
importListExclusions: ImportListExclusionsSettingsAppState;
|
||||||
importListOptions: ImportListOptionsSettingsAppState;
|
importListOptions: ImportListOptionsSettingsAppState;
|
||||||
importLists: ImportListAppState;
|
importLists: ImportListAppState;
|
||||||
indexerFlags: IndexerFlagSettingsAppState;
|
indexerFlags: IndexerFlagSettingsAppState;
|
||||||
indexerOptions: IndexerOptionsAppState;
|
|
||||||
indexers: IndexerAppState;
|
indexers: IndexerAppState;
|
||||||
languages: LanguageSettingsAppState;
|
languages: LanguageSettingsAppState;
|
||||||
mediaManagement: MediaManagementAppState;
|
|
||||||
metadata: MetadataAppState;
|
metadata: MetadataAppState;
|
||||||
naming: NamingAppState;
|
naming: NamingAppState;
|
||||||
namingExamples: NamingExamplesAppState;
|
namingExamples: NamingExamplesAppState;
|
||||||
notifications: NotificationAppState;
|
notifications: NotificationAppState;
|
||||||
qualityDefinitions: QualityDefinitionsAppState;
|
|
||||||
qualityProfiles: QualityProfilesAppState;
|
qualityProfiles: QualityProfilesAppState;
|
||||||
releaseProfiles: ReleaseProfilesAppState;
|
releaseProfiles: ReleaseProfilesAppState;
|
||||||
remotePathMappings: RemotePathMappingsAppState;
|
|
||||||
ui: UiSettingsAppState;
|
ui: UiSettingsAppState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user