Compare commits

...

86 Commits

Author SHA1 Message Date
Servarr 98384ab390 Automated API Docs update 2024-05-31 14:30:17 +03:00
Bogdan 0c654377f4 Fixed: Manual Interaction Required with possible null movie
Prevent a NullRef when the notification is sent due to an invalid movie title

Fixes #10053
2024-05-31 13:50:49 +03:00
Bogdan e8c925274a Implement equality checks for providers 2024-05-22 03:51:11 +03:00
Bogdan 320bfeec16 Fixed: Trimming slashes from UrlBase when using environment variable
(cherry picked from commit d7ceb11a64c3926f35aabf67c935680cf031bd0e)
2024-05-22 03:19:25 +03:00
Bogdan 638f92495c Bump version to 5.7.0 2024-05-14 20:18:27 +03:00
Bogdan 077b041d3f Fixed: Revert "Validate that folders in paths don't start or end with a space"
This reverts commit 0d0575f3a9.
2024-05-14 18:08:38 +03:00
Bogdan ff3dd3ae42 Tests for Wanted pages 2024-05-14 18:05:01 +03:00
Bogdan 2e3beddcbc Fixed: Sorting by movie titles in Missing/Cutoff Unmet under Postgres 2024-05-14 15:56:49 +03:00
Servarr dc068bbf3d Automated API Docs update 2024-05-14 03:07:05 +03:00
Bogdan 7a303c1ebf Remove not implemented endpoints from API docs 2024-05-14 02:53:51 +03:00
Bogdan 152f50a1ef New: Wanted Cutoff/Missing 2024-05-14 02:53:51 +03:00
Bogdan 9798202589 Add missing translation for External 2024-05-14 02:53:51 +03:00
Bogdan 7969776339 Rename file for getMovieStatusDetails 2024-05-14 02:53:51 +03:00
Bogdan 288982d7bd Bump Npgsql to 7.0.7 2024-05-13 15:14:57 +03:00
Servarr d39a3ade5b Automated API Docs update 2024-05-12 22:29:56 +03:00
Bogdan 1fc6e88bc4 New: Add `isExisting` flag for movies in collections API 2024-05-12 22:20:13 +03:00
Bogdan e8e1841e6c New: No Release Dates availability message
Co-authored-by: bakerboy448 <55419169+bakerboy448@users.noreply.github.com>
2024-05-12 17:16:15 +03:00
Bogdan d17eb4f33f Bump version to 5.6.0 2024-05-12 16:28:32 +03:00
Bogdan 685f462959 New: Include trending and popular options for Discover Movies 2024-05-11 16:29:42 +03:00
Servarr 7be8a34130 Automated API Docs update 2024-05-10 21:30:13 +03:00
Ivan Sanz Carasa 886711b496 New: LanguageId filter added to all movie endpoint 2024-05-10 20:54:57 +03:00
Servarr 5185e037da Automated API Docs update 2024-05-10 20:51:41 +03:00
Mark McDowall 38e7e37d57 Refactor movie tags for CustomScript, Webhook and Notifiarr events
(cherry picked from commit cc0a284660f139d5f47b27a2c389973e5e888587)

Closes #10003
2024-05-10 16:15:51 +03:00
Stevie Robinson 190c4c5893 New: Blocklist Custom Filters
(cherry picked from commit f81bb3ec1945d343dd0695a2826dac8833cb6346)

Closes #9997
2024-05-10 16:04:03 +03:00
Mark McDowall 0ec18ce4b3 New: Parse 480i Bluray/Remux as Bluray 480p
(cherry picked from commit 627b2a4289ecdd5558d37940624289708e01e10a)

Closes #10010
2024-05-10 14:59:24 +03:00
Weblate a08575b7bc Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dani Talens <databio@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Michael5564445 <michaelvelosk@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translation: Servarr/Radarr
2024-05-10 14:00:54 +03:00
Bogdan 556cc885ec Refactor PasswordInput to use type password
(cherry picked from commit c7c1e3ac9e5bffd4d92298fed70916e3808613fd)
2024-05-10 14:00:22 +03:00
Bogdan 586c0c6e13 Fixed: Notifications with only On Rename enabled 2024-05-10 00:47:31 +03:00
Bogdan cec569461d Fixed: Text color for inputs on login page 2024-05-09 20:58:49 +03:00
Mark McDowall 8b79b5afbf New: Dark theme for login screen
(cherry picked from commit cae134ec7b331d1c906343716472f3d043614b2c)

Closes #9998
2024-05-09 20:58:49 +03:00
Mickaël Thomas cd4552ce6f New: Support stoppedUP and stoppedDL states from qBittorrent
(cherry picked from commit 73a4bdea5247ee87e6bbae95f5325e1f03c88a7f)

Closes #9995
2024-05-09 20:58:49 +03:00
Bogdan 256439304b Use number input for seed ratio
(cherry picked from commit 1eddf3a152fae04142263c02a3e3b317ff2feeb2)

Plus translations

Closes #10000
2024-05-09 20:58:49 +03:00
Bogdan bb44fbc362 New: Root folder exists validation for import lists
Also moved the AppendArgument to avoid cases like `Invalid Path: '{path}'`.
2024-05-09 20:58:49 +03:00
Servarr cd401f72f5 Automated API Docs update 2024-05-08 04:31:35 +03:00
Bogdan c9624e7550 Fixed: Ignore invalid movie tags when writing XBMC metadata
Co-authored-by: Mark McDowall <mark@mcdowall.ca>

Fixes #9984
2024-05-08 04:05:34 +03:00
Bogdan 649702eaca Fixed: Indexer flags for torrent release pushes
(cherry picked from commit 47ba002806fe2c2004a649aa193ae318343a84e4)
2024-05-07 18:11:58 +03:00
Servarr 1c52f0f5bd Automated API Docs update 2024-05-06 23:54:09 +03:00
Bogdan dff85dc1f3 New: Display excluded label for movies in collections 2024-05-06 23:19:15 +03:00
Bogdan 1090aeff75 Fixed: Ignore exclusions in missing movies for collections
Fixes #9966
2024-05-06 23:18:02 +03:00
Jared 086a0addba
New: Config file setting to disable log database (#9943)
Co-authored-by: sillock1 <jprest97@gmail.com>
2024-05-06 21:51:19 +03:00
Bogdan 8b6cf34ce4 Fixed: Parsing long downloading/seeding values from Transmission
Fixes #9987
2024-05-06 21:26:36 +03:00
Jared 7f03a916f1
New: Optionally use Environment Variables for settings in config.xml (#9985)
Co-authored-by: sillock1 <jprest97@gmail.com>
2024-05-05 22:32:07 +03:00
Mika 3a6d603a9e Add file-count for Transmission RPC
(cherry picked from commit 23c741fd001582fa363c2723eff9facd3091618b)

Closes #9973
2024-05-05 13:03:21 +03:00
Weblate cd2c7dc7fb Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Michael5564445 <michaelvelosk@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translation: Servarr/Radarr
2024-05-05 12:50:06 +03:00
Bogdan f1d76c3483 Fix translations for SSL settings 2024-05-05 12:38:58 +03:00
Stevie Robinson 39eac4b5ad Add missing translation key
(cherry picked from commit 8be8c7f89cf4d40bee941c5ce768aa1a74ebe398)
2024-05-05 12:27:53 +03:00
Mark McDowall 71e1003358 Forward X-Forwarded-Host header
(cherry picked from commit 3fbe4361386e9fb8dafdf82ad9f00f02bec746cc)
2024-05-05 12:27:10 +03:00
Bogdan 89b6a5d51f Bump version to 5.5.3 2024-05-05 12:26:22 +03:00
Bogdan 711637c448 Fixed: Initialize databases after app folder migrations 2024-05-05 00:57:46 +03:00
Bruno Garcia 2677d25980 Update Sentry SDK add features
Co-authored-by: Stefan Jandl <reg@bitfox.at>
(cherry picked from commit 6377c688fc7b35749d608bf62796446bb5bcb11b)
2024-05-01 23:27:51 +03:00
Bogdan 56639bcd42 Fix translations for SSL settings 2024-04-30 12:41:38 +03:00
Bogdan 1ed62b9ced Use newer Node.js task for in pipelines 2024-04-29 14:40:14 +03:00
Servarr a596dda253 Automated API Docs update 2024-04-29 14:40:03 +03:00
Bogdan c0b354039d Parameter binding for API requests 2024-04-29 01:19:25 +03:00
Bogdan 3b5078d117 Fixed: Delay profiles reordering 2024-04-29 01:18:40 +03:00
Bogdan db1fee8d8a New: Use absolute timestamps for movie history 2024-04-28 20:22:32 +03:00
Mark McDowall 0d0575f3a9 New: Validate that folders in paths don't start or end with a space
(cherry picked from commit 316b5cbf75b45ef9a25f96ce1f2fbed25ad94296)

Closes #9958
2024-04-28 13:31:07 +03:00
Stevie Robinson 2d82347a66 New: Don't initially select 0 byte files in Interactive Import
(cherry picked from commit 04bd535cfca5e25c6a2d5417c6f18d5bf5180f67)

Closes #9960
2024-04-28 13:27:28 +03:00
Bogdan 25838df550 Fixed: Limit titles in task name to 10 movies
(cherry picked from commit c81ae6546118e954e481894d0b3fa6e9a20359c7)

Closes #9961
2024-04-28 13:22:25 +03:00
Mark McDowall b3a8b99f9a Fixed: Improve paths longer than 256 on Windows failing to hardlink
(cherry picked from commit a97fbcc40a6247bf59678425cf460588fd4dbecd)
2024-04-28 13:19:14 +03:00
Christopher 93a852841f New: Remove qBitorrent torrents that reach inactive seeding time
(cherry picked from commit d738035fed859eb475051f3df494b9c975a42e82)
2024-04-28 13:18:58 +03:00
Bogdan ead1ec43be Bump version to 5.5.2 2024-04-28 12:55:35 +03:00
Weblate 04b6dd44cb Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ano10 <arnaudthommeray+github@ik.me>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Mailme Dashite <mailmedashite@protonmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: aghus <aghus.m@outlook.com>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: maodun96 <435795439@qq.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr
2024-04-27 21:13:22 +03:00
Bogdan 3db78079f3 Fixed: Retrying download on not suppressed HTTP errors 2024-04-25 17:22:49 +03:00
Bogdan c8a6b9f565 Database corruption message linking to wiki 2024-04-25 11:29:26 +03:00
Bogdan 811cafd9ae Bump dotnet to 6.0.29 2024-04-22 08:08:41 +03:00
fireph ac7039d651 New: Footnote to indicate some renaming tokens support truncation
(cherry picked from commit 7fc3bebc91db217a1c24ab2d01ebbc5bf03c918e)

Closes #9905
2024-04-21 18:36:51 +03:00
Bogdan a2d11cf684 Bump typescript eslint plugin and parser 2024-04-21 12:40:23 +03:00
Bogdan cc32635f6f Bump frontend dependencies 2024-04-21 10:31:56 +03:00
Bogdan 10f9cb64ac Bump version to 5.5.1 2024-04-21 09:16:33 +03:00
Weblate f77e27bace Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Altair <villagermd@outlook.com>
Co-authored-by: Ano10 <arnaudthommeray+github@ik.me>
Co-authored-by: Fonkio <maxime.fabre10@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Jacopo Luca Maria Latrofa <jacopo.latrofa@gmail.com>
Co-authored-by: Mailme Dashite <mailmedashite@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: YSLG <1451164040@qq.com>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: myrad2267 <myrad2267@gmail.com>
Co-authored-by: toeiazarothis <patrickdealmeida89000@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr
2024-04-19 17:25:34 +03:00
Servarr 8ea6d59d59 Automated API Docs update 2024-04-19 17:24:01 +03:00
Bogdan 98668d0d25 Bump SixLabors.ImageSharp to 3.1.4 2024-04-19 07:59:50 +03:00
Gauthier 649d57a234 Improve Multi Language Regex and field translations
(cherry picked from commit 6c232b062c5c11b76a2f205fcd949619e4346d16)

Closes #9931
2024-04-16 12:53:04 +03:00
Josh McKinney dc7c8bf800 Add dev container workspace
Allows the linting and style settings for the frontend to be applied even when you load the main repo as a workspace

(cherry picked from commit d6278fced49b26be975c3a6039b38a94f700864b)

Closes #9929
2024-04-16 11:41:14 +03:00
Bogdan 8d90c7678f Fixed: Re-testing edited providers will forcibly test them
(cherry picked from commit e9662544621b2d1fb133ff9d96d0eb20b8198725)

Closes #9933
2024-04-16 11:39:43 +03:00
Bogdan 02518e2116 Fixed: Validate provider's settings in Test All endpoint 2024-04-16 11:39:35 +03:00
Mark McDowall 3191a883dc New: Improve multi-language negate Custom Format
(cherry picked from commit 42b11528b4699b8343887185c93a02b139192d83)

Closes #9720
2024-04-13 11:03:37 +03:00
Bogdan 31a714e6b3 Bump version to 5.5.0 2024-04-13 08:46:17 +03:00
Mark McDowall f7ca0b8b06 New: Auto tag movies based on tags present/absent on movies
(cherry picked from commit f4c19a384bd9bb4e35c9fa0ca5d9a448c04e409e)

Closes #9916
2024-04-10 23:40:26 +03:00
Josh McKinney 56be9502af Add DevContainer, VSCode config and extensions.json
(cherry picked from commit 5061dc4b5e5ea9925740496a5939a1762788b793)

Closes #9914
2024-04-10 22:59:13 +03:00
Mark McDowall 77381d3f72 New: Option to prefix app name on Telegram notification titles
(cherry picked from commit 37863a8deb339ef730b2dd5be61e1da1311fdd23)

Closes #9913
2024-04-10 22:34:52 +03:00
Bogdan 198e6324e0 Truncate long names for import lists 2024-04-09 18:19:26 +03:00
Alan Collins 81c9537e5a New: 'Custom Format:Format Name' rename token
cherry picked from commit 48cb5d227187a06930aad5ee1b4e7b76422d8421)

New: Update Custom Format renaming token to allow excluding specific formats

(cherry picked from commit 6584d95331d0e0763e1688a397a3ccaf5fa6ca38)

Closes #9835
Closes #9826
2024-04-09 08:04:57 +03:00
Bogdan d3cbb9be8d New: Detect shfs mounts 2024-04-08 22:25:42 +03:00
Bogdan 2e043c0cf7 Bump version to 5.4.6 2024-04-07 07:58:52 +03:00
322 changed files with 8911 additions and 3842 deletions

View File

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

View File

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

12
.github/dependabot.yml vendored Normal file
View File

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

1
.gitignore vendored
View File

@ -126,6 +126,7 @@ coverage*.xml
coverage*.json coverage*.json
setup/Output/ setup/Output/
*.~is *.~is
.mono
# VS outout folders # VS outout folders
bin bin

7
.vscode/extensions.json vendored Normal file
View File

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

26
.vscode/launch.json vendored Normal file
View File

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

44
.vscode/tasks.json vendored Normal file
View File

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

View File

@ -9,13 +9,13 @@ variables:
testsFolder: './_tests' testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '5.4.5' majorVersion: '5.7.0'
minorVersion: $[counter('minorVersion', 2000)] minorVersion: $[counter('minorVersion', 2000)]
radarrVersion: '$(majorVersion).$(minorVersion)' radarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(radarrVersion)' buildName: '$(Build.SourceBranchName).$(radarrVersion)'
sentryOrg: 'servarr' sentryOrg: 'servarr'
sentryUrl: 'https://sentry.servarr.com' sentryUrl: 'https://sentry.servarr.com'
dotnetVersion: '6.0.417' dotnetVersion: '6.0.421'
nodeVersion: '20.X' nodeVersion: '20.X'
innoVersion: '6.2.2' innoVersion: '6.2.2'
windowsImage: 'windows-2022' windowsImage: 'windows-2022'
@ -166,10 +166,10 @@ stages:
pool: pool:
vmImage: $(imageName) vmImage: $(imageName)
steps: steps:
- task: NodeTool@0 - task: UseNode@1
displayName: Set Node.js version displayName: Set Node.js version
inputs: inputs:
versionSpec: $(nodeVersion) version: $(nodeVersion)
- checkout: self - checkout: self
submodules: true submodules: true
fetchDepth: 1 fetchDepth: 1
@ -1089,10 +1089,10 @@ stages:
pool: pool:
vmImage: $(imageName) vmImage: $(imageName)
steps: steps:
- task: NodeTool@0 - task: UseNode@1
displayName: Set Node.js version displayName: Set Node.js version
inputs: inputs:
versionSpec: $(nodeVersion) version: $(nodeVersion)
- checkout: self - checkout: self
submodules: true submodules: true
fetchDepth: 1 fetchDepth: 1

View File

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import ConfirmModal from 'Components/Modal/ConfirmModal'; import ConfirmModal from 'Components/Modal/ConfirmModal';
import PageContent from 'Components/Page/PageContent'; import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody'; import PageContentBody from 'Components/Page/PageContentBody';
@ -20,6 +21,7 @@ import getSelectedIds from 'Utilities/Table/getSelectedIds';
import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
import selectAll from 'Utilities/Table/selectAll'; import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected'; import toggleSelected from 'Utilities/Table/toggleSelected';
import BlocklistFilterModal from './BlocklistFilterModal';
import BlocklistRowConnector from './BlocklistRowConnector'; import BlocklistRowConnector from './BlocklistRowConnector';
class Blocklist extends Component { class Blocklist extends Component {
@ -114,9 +116,13 @@ class Blocklist extends Component {
error, error,
items, items,
columns, columns,
selectedFilterKey,
filters,
customFilters,
totalRecords, totalRecords,
isRemoving, isRemoving,
isClearingBlocklistExecuting, isClearingBlocklistExecuting,
onFilterSelect,
...otherProps ...otherProps
} = this.props; } = this.props;
@ -161,6 +167,15 @@ class Blocklist extends Component {
iconName={icons.TABLE} iconName={icons.TABLE}
/> />
</TableOptionsModalWrapper> </TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={BlocklistFilterModal}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection> </PageToolbarSection>
</PageToolbar> </PageToolbar>
@ -180,7 +195,11 @@ class Blocklist extends Component {
{ {
isPopulated && !error && !items.length && isPopulated && !error && !items.length &&
<Alert kind={kinds.INFO}> <Alert kind={kinds.INFO}>
{translate('NoHistoryBlocklist')} {
selectedFilterKey === 'all' ?
translate('NoHistoryBlocklist') :
translate('BlocklistFilterHasNoItems')
}
</Alert> </Alert>
} }
@ -251,11 +270,15 @@ Blocklist.propTypes = {
error: PropTypes.object, error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number, totalRecords: PropTypes.number,
isRemoving: PropTypes.bool.isRequired, isRemoving: PropTypes.bool.isRequired,
isClearingBlocklistExecuting: PropTypes.bool.isRequired, isClearingBlocklistExecuting: PropTypes.bool.isRequired,
onRemoveSelected: PropTypes.func.isRequired, onRemoveSelected: PropTypes.func.isRequired,
onClearBlocklistPress: PropTypes.func.isRequired onClearBlocklistPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired
}; };
export default Blocklist; export default Blocklist;

View File

@ -6,6 +6,7 @@ import * as commandNames from 'Commands/commandNames';
import withCurrentPage from 'Components/withCurrentPage'; import withCurrentPage from 'Components/withCurrentPage';
import * as blocklistActions from 'Store/Actions/blocklistActions'; import * as blocklistActions from 'Store/Actions/blocklistActions';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import Blocklist from './Blocklist'; import Blocklist from './Blocklist';
@ -13,10 +14,12 @@ import Blocklist from './Blocklist';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
(state) => state.blocklist, (state) => state.blocklist,
createCustomFiltersSelector('blocklist'),
createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST), createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST),
(blocklist, isClearingBlocklistExecuting) => { (blocklist, customFilters, isClearingBlocklistExecuting) => {
return { return {
isClearingBlocklistExecuting, isClearingBlocklistExecuting,
customFilters,
...blocklist ...blocklist
}; };
} }
@ -97,6 +100,14 @@ class BlocklistConnector extends Component {
this.props.setBlocklistSort({ sortKey }); this.props.setBlocklistSort({ sortKey });
}; };
onFilterSelect = (selectedFilterKey) => {
this.props.setBlocklistFilter({ selectedFilterKey });
};
onClearBlocklistPress = () => {
this.props.executeCommand({ name: commandNames.CLEAR_BLOCKLIST });
};
onTableOptionChange = (payload) => { onTableOptionChange = (payload) => {
this.props.setBlocklistTableOption(payload); this.props.setBlocklistTableOption(payload);
@ -105,10 +116,6 @@ class BlocklistConnector extends Component {
} }
}; };
onClearBlocklistPress = () => {
this.props.executeCommand({ name: commandNames.CLEAR_BLOCKLIST });
};
// //
// Render // Render
@ -122,6 +129,7 @@ class BlocklistConnector extends Component {
onPageSelect={this.onPageSelect} onPageSelect={this.onPageSelect}
onRemoveSelected={this.onRemoveSelected} onRemoveSelected={this.onRemoveSelected}
onSortPress={this.onSortPress} onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange} onTableOptionChange={this.onTableOptionChange}
onClearBlocklistPress={this.onClearBlocklistPress} onClearBlocklistPress={this.onClearBlocklistPress}
{...this.props} {...this.props}
@ -142,6 +150,7 @@ BlocklistConnector.propTypes = {
gotoBlocklistPage: PropTypes.func.isRequired, gotoBlocklistPage: PropTypes.func.isRequired,
removeBlocklistItems: PropTypes.func.isRequired, removeBlocklistItems: PropTypes.func.isRequired,
setBlocklistSort: PropTypes.func.isRequired, setBlocklistSort: PropTypes.func.isRequired,
setBlocklistFilter: PropTypes.func.isRequired,
setBlocklistTableOption: PropTypes.func.isRequired, setBlocklistTableOption: PropTypes.func.isRequired,
clearBlocklist: PropTypes.func.isRequired, clearBlocklist: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired executeCommand: PropTypes.func.isRequired

View File

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

View File

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

View File

@ -33,6 +33,8 @@ import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks'; import Tasks from 'System/Tasks/Tasks';
import UpdatesConnector from 'System/Updates/UpdatesConnector'; import UpdatesConnector from 'System/Updates/UpdatesConnector';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
import MissingConnector from 'Wanted/Missing/MissingConnector';
function AppRoutes(props) { function AppRoutes(props) {
const { const {
@ -121,6 +123,20 @@ function AppRoutes(props) {
component={BlocklistConnector} component={BlocklistConnector}
/> />
{/*
Wanted
*/}
<Route
path="/wanted/missing"
component={MissingConnector}
/>
<Route
path="/wanted/cutoffunmet"
component={CutoffUnmetConnector}
/>
{/* {/*
Settings Settings
*/} */}

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import InteractiveImportAppState from 'App/State/InteractiveImportAppState'; import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState'; import CalendarAppState from './CalendarAppState';
import CommandAppState from './CommandAppState'; import CommandAppState from './CommandAppState';
import HistoryAppState from './HistoryAppState'; import HistoryAppState from './HistoryAppState';
@ -54,6 +55,7 @@ export interface AppSectionState {
interface AppState { interface AppState {
app: AppSectionState; app: AppSectionState;
blocklist: BlocklistAppState;
calendar: CalendarAppState; calendar: CalendarAppState;
commands: CommandAppState; commands: CommandAppState;
history: HistoryAppState; history: HistoryAppState;

View File

@ -0,0 +1,8 @@
import Blocklist from 'typings/Blocklist';
import AppSectionState, { AppSectionFilterState } from './AppSectionState';
interface BlocklistAppState
extends AppSectionState<Blocklist>,
AppSectionFilterState<Blocklist> {}
export default BlocklistAppState;

View File

@ -115,3 +115,16 @@ $hoverScale: 1.05;
color: var(--iconButtonHoverLightColor); color: var(--iconButtonHoverLightColor);
} }
} }
.excluded {
position: absolute;
top: 0;
right: 0;
z-index: 1;
width: 0;
height: 0;
border-width: 0 25px 25px 0;
border-style: solid;
border-color: transparent var(--dangerColor) transparent transparent;
color: var(--white);
}

View File

@ -6,6 +6,7 @@ interface CssExports {
'content': string; 'content': string;
'controls': string; 'controls': string;
'editorSelect': string; 'editorSelect': string;
'excluded': string;
'externalLinks': string; 'externalLinks': string;
'link': string; 'link': string;
'monitorToggleButton': string; 'monitorToggleButton': string;

View File

@ -5,6 +5,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton';
import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar'; import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar';
import MoviePoster from 'Movie/MoviePoster'; import MoviePoster from 'Movie/MoviePoster';
import translate from 'Utilities/String/translate';
import AddNewCollectionMovieModal from './../AddNewCollectionMovieModal'; import AddNewCollectionMovieModal from './../AddNewCollectionMovieModal';
import styles from './CollectionMovie.css'; import styles from './CollectionMovie.css';
@ -72,6 +73,7 @@ class CollectionMovie extends Component {
isAvailable, isAvailable,
movieFile, movieFile,
isExistingMovie, isExistingMovie,
isExcluded,
posterWidth, posterWidth,
posterHeight, posterHeight,
detailedProgressBar, detailedProgressBar,
@ -107,6 +109,15 @@ class CollectionMovie extends Component {
</div> </div>
} }
{
isExcluded ?
<div
className={styles.excluded}
title={translate('Excluded')}
/> :
null
}
<Link <Link
className={styles.link} className={styles.link}
style={elementStyle} style={elementStyle}
@ -189,6 +200,7 @@ CollectionMovie.propTypes = {
posterHeight: PropTypes.number.isRequired, posterHeight: PropTypes.number.isRequired,
detailedProgressBar: PropTypes.bool.isRequired, detailedProgressBar: PropTypes.bool.isRequired,
isExistingMovie: PropTypes.bool, isExistingMovie: PropTypes.bool,
isExcluded: PropTypes.bool,
tmdbId: PropTypes.number.isRequired, tmdbId: PropTypes.number.isRequired,
imdbId: PropTypes.string, imdbId: PropTypes.string,
youTubeTrailerId: PropTypes.string, youTubeTrailerId: PropTypes.string,

View File

@ -17,6 +17,7 @@ import IndexerSelectInputConnector from './IndexerSelectInputConnector';
import KeyValueListInput from './KeyValueListInput'; import KeyValueListInput from './KeyValueListInput';
import LanguageSelectInputConnector from './LanguageSelectInputConnector'; import LanguageSelectInputConnector from './LanguageSelectInputConnector';
import MovieMonitoredSelectInput from './MovieMonitoredSelectInput'; import MovieMonitoredSelectInput from './MovieMonitoredSelectInput';
import MovieTagInput from './MovieTagInput';
import NumberInput from './NumberInput'; import NumberInput from './NumberInput';
import OAuthInputConnector from './OAuthInputConnector'; import OAuthInputConnector from './OAuthInputConnector';
import PasswordInput from './PasswordInput'; import PasswordInput from './PasswordInput';
@ -89,6 +90,10 @@ function getComponent(type) {
case inputTypes.DYNAMIC_SELECT: case inputTypes.DYNAMIC_SELECT:
return EnhancedSelectInputConnector; return EnhancedSelectInputConnector;
case inputTypes.MOVIE_TAG:
return MovieTagInput;
case inputTypes.TAG: case inputTypes.TAG:
return TagInputConnector; return TagInputConnector;

View File

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

View File

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

View File

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

View File

@ -27,6 +27,8 @@ function getType({ type, selectOptionsProviderAction }) {
return inputTypes.DYNAMIC_SELECT; return inputTypes.DYNAMIC_SELECT;
} }
return inputTypes.SELECT; return inputTypes.SELECT;
case 'movieTag':
return inputTypes.MOVIE_TAG;
case 'tag': case 'tag':
return inputTypes.TEXT_TAG; return inputTypes.TEXT_TAG;
case 'tagSelect': case 'tagSelect':

View File

@ -71,6 +71,22 @@ const links = [
] ]
}, },
{
iconName: icons.WARNING,
title: () => translate('Wanted'),
to: '/wanted/missing',
children: [
{
title: () => translate('Missing'),
to: '/wanted/missing'
},
{
title: () => translate('CutoffUnmet'),
to: '/wanted/cutoffunmet'
}
]
},
{ {
iconName: icons.SETTINGS, iconName: icons.SETTINGS,
title: () => translate('Settings'), title: () => translate('Settings'),

View File

@ -244,6 +244,26 @@ class SignalRConnector extends Component {
this.props.dispatchSetVersion({ version }); this.props.dispatchSetVersion({ version });
}; };
handleWantedCutoff = (body) => {
if (body.action === 'updated') {
this.props.dispatchUpdateItem({
section: 'wanted.cutoffUnmet',
updateOnly: true,
...body.resource
});
}
};
handleWantedMissing = (body) => {
if (body.action === 'updated') {
this.props.dispatchUpdateItem({
section: 'wanted.missing',
updateOnly: true,
...body.resource
});
}
};
handleSystemTask = () => { handleSystemTask = () => {
this.props.dispatchFetchCommands(); this.props.dispatchFetchCommands();
}; };

View File

@ -25,14 +25,3 @@
font-family: 'Ubuntu Mono'; font-family: 'Ubuntu Mono';
src: url('UbuntuMono-Regular.eot?#iefix&v=1.3.0') format('embedded-opentype'), url('UbuntuMono-Regular.woff?v=1.3.0') format('woff'), url('UbuntuMono-Regular.ttf?v=1.3.0') format('truetype'); src: url('UbuntuMono-Regular.eot?#iefix&v=1.3.0') format('embedded-opentype'), url('UbuntuMono-Regular.woff?v=1.3.0') format('woff'), url('UbuntuMono-Regular.ttf?v=1.3.0') format('truetype');
} }
/*
* text-security-disc
*/
@font-face {
font-weight: normal;
font-style: normal;
font-family: 'text-security-disc';
src: url('text-security-disc.woff?v=1.3.0') format('woff'), url('text-security-disc.ttf?v=1.3.0') format('truetype');
}

View File

@ -75,9 +75,19 @@ class DiscoverMovie extends Component {
const { const {
items, items,
sortKey, sortKey,
sortDirection sortDirection,
includeRecommendations,
includeTrending,
includePopular
} = this.props; } = this.props;
if (includeRecommendations !== prevProps.includeRecommendations ||
includeTrending !== prevProps.includeTrending ||
includePopular !== prevProps.includePopular
) {
this.props.dispatchFetchListMovies();
}
if (sortKey !== prevProps.sortKey || if (sortKey !== prevProps.sortKey ||
sortDirection !== prevProps.sortDirection || sortDirection !== prevProps.sortDirection ||
hasDifferentItemsOrOrder(prevProps.items, items) hasDifferentItemsOrOrder(prevProps.items, items)
@ -443,6 +453,9 @@ DiscoverMovie.propTypes = {
sortKey: PropTypes.string, sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all), sortDirection: PropTypes.oneOf(sortDirections.all),
view: PropTypes.string.isRequired, view: PropTypes.string.isRequired,
includeRecommendations: PropTypes.bool.isRequired,
includeTrending: PropTypes.bool.isRequired,
includePopular: PropTypes.bool.isRequired,
isSyncingLists: PropTypes.bool.isRequired, isSyncingLists: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired, isSmallScreen: PropTypes.bool.isRequired,
onSortSelect: PropTypes.func.isRequired, onSortSelect: PropTypes.func.isRequired,
@ -451,7 +464,8 @@ DiscoverMovie.propTypes = {
onScroll: PropTypes.func.isRequired, onScroll: PropTypes.func.isRequired,
onAddMoviesPress: PropTypes.func.isRequired, onAddMoviesPress: PropTypes.func.isRequired,
onExcludeMoviesPress: PropTypes.func.isRequired, onExcludeMoviesPress: PropTypes.func.isRequired,
onImportListSyncPress: PropTypes.func.isRequired onImportListSyncPress: PropTypes.func.isRequired,
dispatchFetchListMovies: PropTypes.func.isRequired
}; };
export default DiscoverMovie; export default DiscoverMovie;

View File

@ -17,15 +17,18 @@ import DiscoverMovie from './DiscoverMovie';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
(state) => state.discoverMovie,
createDiscoverMovieClientSideCollectionItemsSelector('discoverMovie'), createDiscoverMovieClientSideCollectionItemsSelector('discoverMovie'),
createCommandExecutingSelector(commandNames.IMPORT_LIST_SYNC), createCommandExecutingSelector(commandNames.IMPORT_LIST_SYNC),
createDimensionsSelector(), createDimensionsSelector(),
( (
discoverMovie,
movies, movies,
isSyncingLists, isSyncingLists,
dimensionsState dimensionsState
) => { ) => {
return { return {
...discoverMovie.options,
...movies, ...movies,
isSyncingLists, isSyncingLists,
isSmallScreen: dimensionsState.isSmallScreen isSmallScreen: dimensionsState.isSmallScreen

View File

@ -49,7 +49,9 @@ class DiscoverMovieOverviewOptionsModalContent extends Component {
showRatings: props.showRatings, showRatings: props.showRatings,
showYear: props.showYear, showYear: props.showYear,
showGenres: props.showGenres, showGenres: props.showGenres,
includeRecommendations: props.includeRecommendations includeRecommendations: props.includeRecommendations,
includeTrending: props.includeTrending,
includePopular: props.includePopular
}; };
} }
@ -61,7 +63,9 @@ class DiscoverMovieOverviewOptionsModalContent extends Component {
showRatings, showRatings,
showCertification, showCertification,
showGenres, showGenres,
includeRecommendations includeRecommendations,
includeTrending,
includePopular
} = this.props; } = this.props;
const state = {}; const state = {};
@ -94,6 +98,14 @@ class DiscoverMovieOverviewOptionsModalContent extends Component {
state.includeRecommendations = includeRecommendations; state.includeRecommendations = includeRecommendations;
} }
if (includeTrending !== prevProps.includeTrending) {
state.includeTrending = includeTrending;
}
if (includePopular !== prevProps.includePopular) {
state.includePopular = includePopular;
}
if (!_.isEmpty(state)) { if (!_.isEmpty(state)) {
this.setState(state); this.setState(state);
} }
@ -135,19 +147,22 @@ class DiscoverMovieOverviewOptionsModalContent extends Component {
showRatings, showRatings,
showYear, showYear,
showGenres, showGenres,
includeRecommendations includeRecommendations,
includeTrending,
includePopular
} = this.state; } = this.state;
return ( return (
<ModalContent onModalClose={onModalClose}> <ModalContent onModalClose={onModalClose}>
<ModalHeader> <ModalHeader>
Overview Options {translate('OverviewOptions')}
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<Form> <Form>
<FormGroup> <FormGroup>
<FormLabel>{translate('IncludeRadarrRecommendations')}</FormLabel> <FormLabel>{translate('IncludeRadarrRecommendations')}</FormLabel>
<FormInputGroup <FormInputGroup
type={inputTypes.CHECK} type={inputTypes.CHECK}
name="includeRecommendations" name="includeRecommendations"
@ -157,6 +172,30 @@ class DiscoverMovieOverviewOptionsModalContent extends Component {
/> />
</FormGroup> </FormGroup>
<FormGroup>
<FormLabel>{translate('IncludeTrending')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeTrending"
value={includeTrending}
helpText={translate('IncludeTrendingMoviesHelpText')}
onChange={this.onChangeOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IncludePopular')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includePopular"
value={includePopular}
helpText={translate('IncludePopularMoviesHelpText')}
onChange={this.onChangeOption}
/>
</FormGroup>
<FormGroup> <FormGroup>
<FormLabel>{translate('PosterSize')}</FormLabel> <FormLabel>{translate('PosterSize')}</FormLabel>
@ -246,6 +285,8 @@ DiscoverMovieOverviewOptionsModalContent.propTypes = {
showCertification: PropTypes.bool.isRequired, showCertification: PropTypes.bool.isRequired,
showGenres: PropTypes.bool.isRequired, showGenres: PropTypes.bool.isRequired,
includeRecommendations: PropTypes.bool.isRequired, includeRecommendations: PropTypes.bool.isRequired,
includeTrending: PropTypes.bool.isRequired,
includePopular: PropTypes.bool.isRequired,
onChangeOverviewOption: PropTypes.func.isRequired, onChangeOverviewOption: PropTypes.func.isRequired,
onChangeOption: PropTypes.func.isRequired, onChangeOption: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired onModalClose: PropTypes.func.isRequired

View File

@ -3,7 +3,7 @@ import React from 'react';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import TmdbRating from 'Components/TmdbRating'; import TmdbRating from 'Components/TmdbRating';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import { getMovieStatusDetails } from 'Movie/MovieStatus'; import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
import formatRuntime from 'Utilities/Date/formatRuntime'; import formatRuntime from 'Utilities/Date/formatRuntime';
import getRelativeDate from 'Utilities/Date/getRelativeDate'; import getRelativeDate from 'Utilities/Date/getRelativeDate';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';

View File

@ -45,7 +45,9 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
this.state = { this.state = {
size: props.size, size: props.size,
showTitle: props.showTitle, showTitle: props.showTitle,
includeRecommendations: props.includeRecommendations includeRecommendations: props.includeRecommendations,
includeTrending: props.includeTrending,
includePopular: props.includePopular
}; };
} }
@ -53,7 +55,9 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
const { const {
size, size,
showTitle, showTitle,
includeRecommendations includeRecommendations,
includeTrending,
includePopular
} = this.props; } = this.props;
const state = {}; const state = {};
@ -70,6 +74,14 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
state.includeRecommendations = includeRecommendations; state.includeRecommendations = includeRecommendations;
} }
if (includeTrending !== prevProps.includeTrending) {
state.includeTrending = includeTrending;
}
if (includePopular !== prevProps.includePopular) {
state.includePopular = includePopular;
}
if (!_.isEmpty(state)) { if (!_.isEmpty(state)) {
this.setState(state); this.setState(state);
} }
@ -107,13 +119,15 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
const { const {
size, size,
showTitle, showTitle,
includeRecommendations includeRecommendations,
includeTrending,
includePopular
} = this.state; } = this.state;
return ( return (
<ModalContent onModalClose={onModalClose}> <ModalContent onModalClose={onModalClose}>
<ModalHeader> <ModalHeader>
Poster Options {translate('PosterOptions')}
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
@ -130,6 +144,30 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
/> />
</FormGroup> </FormGroup>
<FormGroup>
<FormLabel>{translate('IncludeTrending')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeTrending"
value={includeTrending}
helpText={translate('IncludeTrendingMoviesHelpText')}
onChange={this.onChangeOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IncludePopular')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includePopular"
value={includePopular}
helpText={translate('IncludePopularMoviesHelpText')}
onChange={this.onChangeOption}
/>
</FormGroup>
<FormGroup> <FormGroup>
<FormLabel>{translate('PosterSize')}</FormLabel> <FormLabel>{translate('PosterSize')}</FormLabel>
@ -172,6 +210,8 @@ DiscoverMoviePosterOptionsModalContent.propTypes = {
size: PropTypes.string.isRequired, size: PropTypes.string.isRequired,
showTitle: PropTypes.bool.isRequired, showTitle: PropTypes.bool.isRequired,
includeRecommendations: PropTypes.bool.isRequired, includeRecommendations: PropTypes.bool.isRequired,
includeTrending: PropTypes.bool.isRequired,
includePopular: PropTypes.bool.isRequired,
onChangePosterOption: PropTypes.func.isRequired, onChangePosterOption: PropTypes.func.isRequired,
onChangeOption: PropTypes.func.isRequired, onChangeOption: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired onModalClose: PropTypes.func.isRequired

View File

@ -15,17 +15,29 @@ class DiscoverMovieTableOptions extends Component {
super(props, context); super(props, context);
this.state = { this.state = {
includeRecommendations: props.includeRecommendations includeRecommendations: props.includeRecommendations,
includeTrending: props.includeTrending,
includePopular: props.includePopular
}; };
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { includeRecommendations } = this.props; const {
includeRecommendations,
includeTrending,
includePopular
} = this.props;
if (includeRecommendations !== prevProps.includeRecommendations) { if (includeRecommendations !== prevProps.includeRecommendations) {
this.setState({ this.setState({ includeRecommendations });
includeRecommendations }
});
if (includeTrending !== prevProps.includeTrending) {
this.setState({ includeTrending });
}
if (includePopular !== prevProps.includePopular) {
this.setState({ includePopular });
} }
} }
@ -47,27 +59,57 @@ class DiscoverMovieTableOptions extends Component {
render() { render() {
const { const {
includeRecommendations includeRecommendations,
includeTrending,
includePopular
} = this.state; } = this.state;
return ( return (
<FormGroup> <>
<FormLabel>{translate('IncludeRadarrRecommendations')}</FormLabel> <FormGroup>
<FormLabel>{translate('IncludeRadarrRecommendations')}</FormLabel>
<FormInputGroup <FormInputGroup
type={inputTypes.CHECK} type={inputTypes.CHECK}
name="includeRecommendations" name="includeRecommendations"
value={includeRecommendations} value={includeRecommendations}
helpText={translate('IncludeRecommendationsHelpText')} helpText={translate('IncludeRecommendationsHelpText')}
onChange={this.onChangeOption} onChange={this.onChangeOption}
/> />
</FormGroup> </FormGroup>
<FormGroup>
<FormLabel>{translate('IncludeTrending')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeTrending"
value={includeTrending}
helpText={translate('IncludeTrendingMoviesHelpText')}
onChange={this.onChangeOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IncludePopular')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includePopular"
value={includePopular}
helpText={translate('IncludePopularMoviesHelpText')}
onChange={this.onChangeOption}
/>
</FormGroup>
</>
); );
} }
} }
DiscoverMovieTableOptions.propTypes = { DiscoverMovieTableOptions.propTypes = {
includeRecommendations: PropTypes.bool.isRequired, includeRecommendations: PropTypes.bool.isRequired,
includeTrending: PropTypes.bool.isRequired,
includePopular: PropTypes.bool.isRequired,
onChangeOption: PropTypes.func.isRequired onChangeOption: PropTypes.func.isRequired
}; };

View File

@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell'; import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell';
import { getMovieStatusDetails } from 'Movie/MovieStatus'; import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
import styles from './ListMovieStatusCell.css'; import styles from './ListMovieStatusCell.css';
function ListMovieStatusCell(props) { function ListMovieStatusCell(props) {

View File

@ -17,6 +17,7 @@ export const INDEXER_FLAGS_SELECT = 'indexerFlagsSelect';
export const LANGUAGE_SELECT = 'languageSelect'; export const LANGUAGE_SELECT = 'languageSelect';
export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect'; export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect';
export const SELECT = 'select'; export const SELECT = 'select';
export const MOVIE_TAG = 'movieTag';
export const DYNAMIC_SELECT = 'dynamicSelect'; export const DYNAMIC_SELECT = 'dynamicSelect';
export const TAG = 'tag'; export const TAG = 'tag';
export const TEXT = 'text'; export const TEXT = 'text';
@ -45,6 +46,7 @@ export const all = [
INDEXER_FLAGS_SELECT, INDEXER_FLAGS_SELECT,
LANGUAGE_SELECT, LANGUAGE_SELECT,
SELECT, SELECT,
MOVIE_TAG,
DYNAMIC_SELECT, DYNAMIC_SELECT,
TAG, TAG,
TEXT, TEXT,

View File

@ -104,7 +104,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
useEffect( useEffect(
() => { () => {
if (allowMovieChange && movie && quality && languages) { if (allowMovieChange && movie && quality && languages && size > 0) {
onSelectedChange({ onSelectedChange({
id, id,
hasMovieFileId: !!movieFileId, hasMovieFileId: !!movieFileId,

View File

@ -8,18 +8,29 @@ import translate from 'Utilities/String/translate';
import styles from './MovieReleaseDates.css'; import styles from './MovieReleaseDates.css';
interface MovieReleaseDatesProps { interface MovieReleaseDatesProps {
inCinemas: string; inCinemas?: string;
physicalRelease: string; digitalRelease?: string;
digitalRelease: string; physicalRelease?: string;
} }
function MovieReleaseDates(props: MovieReleaseDatesProps) { function MovieReleaseDates(props: MovieReleaseDatesProps) {
const { inCinemas, physicalRelease, digitalRelease } = props; const { inCinemas, digitalRelease, physicalRelease } = props;
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector( const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector() createUISettingsSelector()
); );
if (!inCinemas && !physicalRelease && !digitalRelease) {
return (
<div>
<div className={styles.dateIcon}>
<Icon name={icons.MISSING} />
</div>
{translate('NoMovieReleaseDatesAvailable')}
</div>
);
}
return ( return (
<div> <div>
{inCinemas ? ( {inCinemas ? (

View File

@ -15,7 +15,7 @@ function MovieHistoryModal(props) {
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onModalClose={onModalClose} onModalClose={onModalClose}
size={sizes.EXTRA_LARGE} size={sizes.EXTRA_EXTRA_LARGE}
> >
<MovieHistoryModalContentConnector <MovieHistoryModalContentConnector
{...otherProps} {...otherProps}

View File

@ -4,13 +4,13 @@ import HistoryDetailsModal from 'Activity/History/Details/HistoryDetailsModal';
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell'; import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal'; import ConfirmModal from 'Components/Modal/ConfirmModal';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import { icons, kinds } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
import MovieFormats from 'Movie/MovieFormats'; import MovieFormats from 'Movie/MovieFormats';
import MovieLanguage from 'Movie/MovieLanguage'; import MovieLanguage from 'Movie/MovieLanguage';
import MovieQuality from 'Movie/MovieQuality'; import MovieQuality from 'Movie/MovieQuality';
import formatDateTime from 'Utilities/Date/formatDateTime';
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 './MovieHistoryRow.css'; import styles from './MovieHistoryRow.css';
@ -109,9 +109,9 @@ class MovieHistoryRow extends Component {
{formatCustomFormatScore(customFormatScore, customFormats.length)} {formatCustomFormatScore(customFormatScore, customFormats.length)}
</TableRowCell> </TableRowCell>
<RelativeDateCellConnector <TableRowCell>
date={date} {formatDateTime(date, shortDateFormat, timeFormat, { includeSeconds: true })}
/> </TableRowCell>
<TableRowCell className={styles.actions}> <TableRowCell className={styles.actions}>
<IconButton <IconButton

View File

@ -4,7 +4,7 @@ import Icon from 'Components/Icon';
import MonitorToggleButton from 'Components/MonitorToggleButton'; import MonitorToggleButton from 'Components/MonitorToggleButton';
import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell'; import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import { getMovieStatusDetails } from 'Movie/MovieStatus'; import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
import { toggleMovieMonitored } from 'Store/Actions/movieActions'; import { toggleMovieMonitored } from 'Store/Actions/movieActions';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './MovieStatusCell.css'; import styles from './MovieStatusCell.css';

View File

@ -0,0 +1,6 @@
.movieSearchCell {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 70px;
white-space: nowrap;
}

View File

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

View File

@ -0,0 +1,81 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import MovieInteractiveSearchModalConnector from './Search/MovieInteractiveSearchModalConnector';
import styles from './MovieSearchCell.css';
class MovieSearchCell extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isInteractiveSearchModalOpen: false
};
}
//
// Listeners
onManualSearchPress = () => {
this.setState({ isInteractiveSearchModalOpen: true });
};
onInteractiveSearchModalClose = () => {
this.setState({ isInteractiveSearchModalOpen: false });
};
//
// Render
render() {
const {
movieId,
movieTitle,
isSearching,
onSearchPress,
...otherProps
} = this.props;
return (
<TableRowCell className={styles.movieSearchCell}>
<SpinnerIconButton
name={icons.SEARCH}
isSpinning={isSearching}
onPress={onSearchPress}
title={translate('AutomaticSearch')}
/>
<IconButton
name={icons.INTERACTIVE}
onPress={this.onManualSearchPress}
title={translate('InteractiveSearch')}
/>
<MovieInteractiveSearchModalConnector
isOpen={this.state.isInteractiveSearchModalOpen}
movieId={movieId}
movieTitle={movieTitle}
onModalClose={this.onInteractiveSearchModalClose}
{...otherProps}
/>
</TableRowCell>
);
}
}
MovieSearchCell.propTypes = {
movieId: PropTypes.number.isRequired,
movieTitle: PropTypes.string.isRequired,
isSearching: PropTypes.bool.isRequired,
onSearchPress: PropTypes.func.isRequired
};
export default MovieSearchCell;

View File

@ -0,0 +1,48 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import MovieSearchCell from 'Movie/MovieSearchCell';
import { executeCommand } from 'Store/Actions/commandActions';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createMovieSelector from 'Store/Selectors/createMovieSelector';
import { isCommandExecuting } from 'Utilities/Command';
function createMapStateToProps() {
return createSelector(
(state, { movieId }) => movieId,
createMovieSelector(),
createCommandsSelector(),
(movieId, movie, commands) => {
const isSearching = commands.some((command) => {
const movieSearch = command.name === commandNames.MOVIE_SEARCH;
if (!movieSearch) {
return false;
}
return (
isCommandExecuting(command) &&
command.body.movieIds.indexOf(movieId) > -1
);
});
return {
movieMonitored: movie.monitored,
isSearching
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onSearchPress(name, path) {
dispatch(executeCommand({
name: commandNames.MOVIE_SEARCH,
movieIds: [props.movieId]
}));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(MovieSearchCell);

View File

@ -0,0 +1,4 @@
.center {
display: flex;
justify-content: center;
}

View File

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

View File

@ -1,33 +1,115 @@
import { icons } from 'Helpers/Props'; import PropTypes from 'prop-types';
import React from 'react';
import QueueDetails from 'Activity/Queue/QueueDetails';
import Icon from 'Components/Icon';
import ProgressBar from 'Components/ProgressBar';
import { icons, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import MovieQuality from './MovieQuality';
import styles from './MovieStatus.css';
export function getMovieStatusDetails(status) { function MovieStatus(props) {
const {
isAvailable,
monitored,
grabbed,
queueItem,
movieFile
} = props;
let statusDetails = { const hasMovieFile = !!movieFile;
icon: icons.ANNOUNCED, const isQueued = !!queueItem;
title: translate('Announced'),
message: translate('AnnouncedMsg')
};
if (status === 'deleted') { if (isQueued) {
statusDetails = { const {
icon: icons.MOVIE_DELETED, sizeleft,
title: translate('Deleted'), size
message: translate('DeletedMsg') } = queueItem;
};
} else if (status === 'inCinemas') { const progress = size ? (100 - sizeleft / size * 100) : 0;
statusDetails = {
icon: icons.IN_CINEMAS, return (
title: translate('InCinemas'), <div className={styles.center}>
message: translate('InCinemasMsg') <QueueDetails
}; {...queueItem}
} else if (status === 'released') { progressBar={
statusDetails = { <ProgressBar
icon: icons.MOVIE_FILE, progress={progress}
title: translate('Released'), kind={kinds.PURPLE}
message: translate('ReleasedMsg') size={sizes.MEDIUM}
}; />
}
/>
</div>
);
} }
return statusDetails; if (grabbed) {
return (
<div className={styles.center}>
<Icon
name={icons.DOWNLOADING}
title={translate('MovieIsDownloading')}
/>
</div>
);
}
if (hasMovieFile) {
const quality = movieFile.quality;
const isCutoffNotMet = movieFile.qualityCutoffNotMet;
return (
<div className={styles.center}>
<MovieQuality
quality={quality}
size={movieFile.size}
isCutoffNotMet={isCutoffNotMet}
title={translate('MovieDownloaded')}
/>
</div>
);
}
if (!monitored) {
return (
<div className={styles.center}>
<Icon
name={icons.UNMONITORED}
kind={kinds.DISABLED}
title={translate('MovieIsNotMonitored')}
/>
</div>
);
}
if (isAvailable) {
return (
<div className={styles.center}>
<Icon
name={icons.MISSING}
title={translate('MovieMissingFromDisk')}
/>
</div>
);
}
return (
<div className={styles.center}>
<Icon
name={icons.NOT_AIRED}
title={translate('MovieIsNotAvailable')}
/>
</div>
);
} }
MovieStatus.propTypes = {
isAvailable: PropTypes.bool.isRequired,
monitored: PropTypes.bool.isRequired,
grabbed: PropTypes.bool,
queueItem: PropTypes.object,
movieFile: PropTypes.object
};
export default MovieStatus;

View File

@ -0,0 +1,50 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import MovieStatus from 'Movie/MovieStatus';
import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector';
import { createMovieByEntitySelector } from 'Store/Selectors/createMovieSelector';
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
function createMapStateToProps() {
return createSelector(
createMovieByEntitySelector(),
createQueueItemSelector(),
createMovieFileSelector(),
(movie, queueItem, movieFile) => {
const result = _.pick(movie, [
'isAvailable',
'monitored',
'grabbed'
]);
result.queueItem = queueItem;
result.movieFile = movieFile;
return result;
}
);
}
class MovieStatusConnector extends Component {
//
// Render
render() {
return (
<MovieStatus
{...this.props}
/>
);
}
}
MovieStatusConnector.propTypes = {
movieId: PropTypes.number.isRequired,
movieFileId: PropTypes.number.isRequired
};
export default connect(createMapStateToProps, null)(MovieStatusConnector);

View File

@ -8,6 +8,7 @@ function MovieInteractiveSearchModal(props) {
const { const {
isOpen, isOpen,
movieId, movieId,
movieTitle,
onModalClose onModalClose
} = props; } = props;
@ -20,6 +21,7 @@ function MovieInteractiveSearchModal(props) {
> >
<MovieInteractiveSearchModalContent <MovieInteractiveSearchModalContent
movieId={movieId} movieId={movieId}
movieTitle={movieTitle}
onModalClose={onModalClose} onModalClose={onModalClose}
/> />
</Modal> </Modal>
@ -29,6 +31,7 @@ function MovieInteractiveSearchModal(props) {
MovieInteractiveSearchModal.propTypes = { MovieInteractiveSearchModal.propTypes = {
isOpen: PropTypes.bool.isRequired, isOpen: PropTypes.bool.isRequired,
movieId: PropTypes.number.isRequired, movieId: PropTypes.number.isRequired,
movieTitle: PropTypes.string,
onModalClose: PropTypes.func.isRequired onModalClose: PropTypes.func.isRequired
}; };

View File

@ -12,13 +12,17 @@ import translate from 'Utilities/String/translate';
function MovieInteractiveSearchModalContent(props) { function MovieInteractiveSearchModalContent(props) {
const { const {
movieId, movieId,
movieTitle,
onModalClose onModalClose
} = props; } = props;
return ( return (
<ModalContent onModalClose={onModalClose}> <ModalContent onModalClose={onModalClose}>
<ModalHeader> <ModalHeader>
{translate('InteractiveSearchModalHeader')} {movieTitle === undefined ?
translate('InteractiveSearchModalHeader') :
translate('InteractiveSearchModalHeaderTitle', { title: movieTitle })
}
</ModalHeader> </ModalHeader>
<ModalBody scrollDirection={scrollDirections.BOTH}> <ModalBody scrollDirection={scrollDirections.BOTH}>
@ -38,6 +42,7 @@ function MovieInteractiveSearchModalContent(props) {
MovieInteractiveSearchModalContent.propTypes = { MovieInteractiveSearchModalContent.propTypes = {
movieId: PropTypes.number.isRequired, movieId: PropTypes.number.isRequired,
movieTitle: PropTypes.string,
onModalClose: PropTypes.func.isRequired onModalClose: PropTypes.func.isRequired
}; };

View File

@ -0,0 +1,32 @@
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
export default function getMovieStatusDetails(status) {
let statusDetails = {
icon: icons.ANNOUNCED,
title: translate('Announced'),
message: translate('AnnouncedMsg')
};
if (status === 'deleted') {
statusDetails = {
icon: icons.MOVIE_DELETED,
title: translate('Deleted'),
message: translate('DeletedMsg')
};
} else if (status === 'inCinemas') {
statusDetails = {
icon: icons.IN_CINEMAS,
title: translate('InCinemas'),
message: translate('InCinemasMsg')
};
} else if (status === 'released') {
statusDetails = {
icon: icons.MOVIE_FILE,
title: translate('Released'),
message: translate('ReleasedMsg')
};
}
return statusDetails;
}

View File

@ -1,9 +1,13 @@
export const CALENDAR = 'calendar'; export const CALENDAR = 'calendar';
export const MOVIES = 'movies'; export const MOVIES = 'movies';
export const INTERACTIVE_IMPORT = 'interactiveImport.movies'; export const INTERACTIVE_IMPORT = 'interactiveImport.movies';
export const WANTED_CUTOFF_UNMET = 'wanted.cutoffUnmet';
export const WANTED_MISSING = 'wanted.missing';
export default { export default {
CALENDAR, CALENDAR,
MOVIES, MOVIES,
INTERACTIVE_IMPORT INTERACTIVE_IMPORT,
WANTED_CUTOFF_UNMET,
WANTED_MISSING
}; };

View File

@ -8,7 +8,7 @@ function createMapStateToProps() {
createMovieFileSelector(), createMovieFileSelector(),
(movieFile) => { (movieFile) => {
return { return {
language: movieFile ? movieFile.language : undefined languages: movieFile ? movieFile.languages : undefined
}; };
} }
); );

View File

@ -17,6 +17,8 @@
} }
.name { .name {
@add-mixin truncate;
text-align: center; text-align: center;
font-weight: lighter; font-weight: lighter;
font-size: 24px; font-size: 24px;

View File

@ -72,15 +72,15 @@ const fileNameTokens = [
]; ];
const movieTokens = [ const movieTokens = [
{ token: '{Movie Title}', example: 'Movie\'s Title' }, { token: '{Movie Title}', example: 'Movie\'s Title', footNote: 1 },
{ token: '{Movie Title:DE}', example: 'Titel des Films' }, { token: '{Movie Title:DE}', example: 'Titel des Films', footNote: 1 },
{ token: '{Movie CleanTitle}', example: 'Movies Title' }, { token: '{Movie CleanTitle}', example: 'Movies Title', footNote: 1 },
{ token: '{Movie TitleThe}', example: 'Movie\'s Title, The' }, { token: '{Movie TitleThe}', example: 'Movie\'s Title, The', footNote: 1 },
{ token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας' }, { token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 },
{ token: '{Movie CleanOriginalTitle}', example: 'Τίτλος ταινίας' }, { token: '{Movie CleanOriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 },
{ token: '{Movie TitleFirstCharacter}', example: 'M' }, { token: '{Movie TitleFirstCharacter}', example: 'M' },
{ token: '{Movie TitleFirstCharacter:DE}', example: 'T' }, { token: '{Movie TitleFirstCharacter:DE}', example: 'T' },
{ token: '{Movie Collection}', example: 'The Movie Collection' }, { token: '{Movie Collection}', example: 'The Movie Collection', footNote: 1 },
{ token: '{Movie Certification}', example: 'R' }, { token: '{Movie Certification}', example: 'R' },
{ token: '{Release Year}', example: '2009' } { token: '{Release Year}', example: '2009' }
]; ];
@ -112,15 +112,16 @@ const mediaInfoTokens = [
]; ];
const releaseGroupTokens = [ const releaseGroupTokens = [
{ token: '{Release Group}', example: 'Rls Grp' } { token: '{Release Group}', example: 'Rls Grp', footNote: 1 }
]; ];
const editionTokens = [ const editionTokens = [
{ token: '{Edition Tags}', example: 'IMAX' } { token: '{Edition Tags}', example: 'IMAX', footNote: 1 }
]; ];
const customFormatTokens = [ const customFormatTokens = [
{ token: '{Custom Formats}', example: 'Surround Sound x264' } { token: '{Custom Formats}', example: 'Surround Sound x264' },
{ token: '{Custom Format:FormatName}', example: 'AMZN' }
]; ];
const originalTokens = [ const originalTokens = [
@ -267,7 +268,7 @@ class NamingModal extends Component {
<FieldSet legend={translate('Movie')}> <FieldSet legend={translate('Movie')}>
<div className={styles.groups}> <div className={styles.groups}>
{ {
movieTokens.map(({ token, example }) => { movieTokens.map(({ token, example, footNote }) => {
return ( return (
<NamingOption <NamingOption
key={token} key={token}
@ -275,6 +276,7 @@ class NamingModal extends Component {
value={value} value={value}
token={token} token={token}
example={example} example={example}
footNote={footNote}
tokenSeparator={tokenSeparator} tokenSeparator={tokenSeparator}
tokenCase={tokenCase} tokenCase={tokenCase}
onPress={this.onOptionPress} onPress={this.onOptionPress}
@ -284,6 +286,11 @@ class NamingModal extends Component {
) )
} }
</div> </div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('MovieFootNote')} />
</div>
</FieldSet> </FieldSet>
<FieldSet legend={translate('MovieID')}> <FieldSet legend={translate('MovieID')}>
@ -364,7 +371,7 @@ class NamingModal extends Component {
<FieldSet legend={translate('ReleaseGroup')}> <FieldSet legend={translate('ReleaseGroup')}>
<div className={styles.groups}> <div className={styles.groups}>
{ {
releaseGroupTokens.map(({ token, example }) => { releaseGroupTokens.map(({ token, example, footNote }) => {
return ( return (
<NamingOption <NamingOption
key={token} key={token}
@ -372,6 +379,7 @@ class NamingModal extends Component {
value={value} value={value}
token={token} token={token}
example={example} example={example}
footNote={footNote}
tokenSeparator={tokenSeparator} tokenSeparator={tokenSeparator}
tokenCase={tokenCase} tokenCase={tokenCase}
onPress={this.onOptionPress} onPress={this.onOptionPress}
@ -381,12 +389,17 @@ class NamingModal extends Component {
) )
} }
</div> </div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('ReleaseGroupFootNote')} />
</div>
</FieldSet> </FieldSet>
<FieldSet legend={translate('Edition')}> <FieldSet legend={translate('Edition')}>
<div className={styles.groups}> <div className={styles.groups}>
{ {
editionTokens.map(({ token, example }) => { editionTokens.map(({ token, example, footNote }) => {
return ( return (
<NamingOption <NamingOption
key={token} key={token}
@ -394,6 +407,7 @@ class NamingModal extends Component {
value={value} value={value}
token={token} token={token}
example={example} example={example}
footNote={footNote}
tokenSeparator={tokenSeparator} tokenSeparator={tokenSeparator}
tokenCase={tokenCase} tokenCase={tokenCase}
onPress={this.onOptionPress} onPress={this.onOptionPress}
@ -403,6 +417,11 @@ class NamingModal extends Component {
) )
} }
</div> </div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('EditionFootNote')} />
</div>
</FieldSet> </FieldSet>
<FieldSet legend={translate('CustomFormats')}> <FieldSet legend={translate('CustomFormats')}>

View File

@ -12,7 +12,7 @@ export default function TagInUse(props) {
return null; return null;
} }
if (count > 1 && labelPlural ) { if (count > 1 && labelPlural) {
return ( return (
<div> <div>
{count} {labelPlural.toLowerCase()} {count} {labelPlural.toLowerCase()}

View File

@ -1,29 +1,29 @@
import createAjaxRequest from 'Utilities/createAjaxRequest'; import createAjaxRequest from 'Utilities/createAjaxRequest';
import updateEpisodes from 'Utilities/Episode/updateEpisodes'; import updateMovies from 'Utilities/Movie/updateMovies';
import getSectionState from 'Utilities/State/getSectionState'; import getSectionState from 'Utilities/State/getSectionState';
function createBatchToggleEpisodeMonitoredHandler(section, fetchHandler) { function createBatchToggleMovieMonitoredHandler(section, fetchHandler) {
return function(getState, payload, dispatch) { return function(getState, payload, dispatch) {
const { const {
episodeIds, movieIds,
monitored monitored
} = payload; } = payload;
const state = getSectionState(getState(), section, true); const state = getSectionState(getState(), section, true);
dispatch(updateEpisodes(section, state.items, episodeIds, { dispatch(updateMovies(section, state.items, movieIds, {
isSaving: true isSaving: true
})); }));
const promise = createAjaxRequest({ const promise = createAjaxRequest({
url: '/episode/monitor', url: '/movie/editor',
method: 'PUT', method: 'PUT',
data: JSON.stringify({ episodeIds, monitored }), data: JSON.stringify({ movieIds, monitored }),
dataType: 'json' dataType: 'json'
}).request; }).request;
promise.done(() => { promise.done(() => {
dispatch(updateEpisodes(section, state.items, episodeIds, { dispatch(updateMovies(section, state.items, movieIds, {
isSaving: false, isSaving: false,
monitored monitored
})); }));
@ -32,11 +32,11 @@ function createBatchToggleEpisodeMonitoredHandler(section, fetchHandler) {
}); });
promise.fail(() => { promise.fail(() => {
dispatch(updateEpisodes(section, state.items, episodeIds, { dispatch(updateMovies(section, state.items, movieIds, {
isSaving: false isSaving: false
})); }));
}); });
}; };
} }
export default createBatchToggleEpisodeMonitoredHandler; export default createBatchToggleMovieMonitoredHandler;

View File

@ -1,8 +1,11 @@
import $ from 'jquery';
import _ from 'lodash';
import createAjaxRequest from 'Utilities/createAjaxRequest'; import createAjaxRequest from 'Utilities/createAjaxRequest';
import getProviderState from 'Utilities/State/getProviderState'; import getProviderState from 'Utilities/State/getProviderState';
import { set } from '../baseActions'; import { set } from '../baseActions';
const abortCurrentRequests = {}; const abortCurrentRequests = {};
let lastTestData = null;
export function createCancelTestProviderHandler(section) { export function createCancelTestProviderHandler(section) {
return function(getState, payload, dispatch) { return function(getState, payload, dispatch) {
@ -17,10 +20,25 @@ function createTestProviderHandler(section, url) {
return function(getState, payload, dispatch) { return function(getState, payload, dispatch) {
dispatch(set({ section, isTesting: true })); dispatch(set({ section, isTesting: true }));
const testData = getProviderState(payload, getState, section); const {
queryParams = {},
...otherPayload
} = payload;
const testData = getProviderState({ ...otherPayload }, getState, section);
const params = { ...queryParams };
// If the user is re-testing the same provider without changes
// force it to be tested.
if (_.isEqual(testData, lastTestData)) {
params.forceTest = true;
}
lastTestData = testData;
const ajaxOptions = { const ajaxOptions = {
url: `${url}/test`, url: `${url}/test?${$.param(params, true)}`,
method: 'POST', method: 'POST',
contentType: 'application/json', contentType: 'application/json',
dataType: 'json', dataType: 'json',
@ -32,6 +50,8 @@ function createTestProviderHandler(section, url) {
abortCurrentRequests[section] = abortRequest; abortCurrentRequests[section] = abortRequest;
request.done((data) => { request.done((data) => {
lastTestData = null;
dispatch(set({ dispatch(set({
section, section,
isTesting: false, isTesting: false,

View File

@ -1,6 +1,6 @@
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions'; import { batchActions } from 'redux-batched-actions';
import { sortDirections } from 'Helpers/Props'; import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks'; import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest'; import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
@ -79,6 +79,31 @@ export const defaultState = {
isVisible: true, isVisible: true,
isModifiable: false isModifiable: false
} }
],
selectedFilterKey: 'all',
filters: [
{
key: 'all',
label: () => translate('All'),
filters: []
}
],
filterBuilderProps: [
{
name: 'movieIds',
label: () => translate('Movie'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.MOVIE
},
{
name: 'protocols',
label: () => translate('Protocol'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.PROTOCOL
}
] ]
}; };
@ -86,6 +111,7 @@ export const persistState = [
'blocklist.pageSize', 'blocklist.pageSize',
'blocklist.sortKey', 'blocklist.sortKey',
'blocklist.sortDirection', 'blocklist.sortDirection',
'blocklist.selectedFilterKey',
'blocklist.columns' 'blocklist.columns'
]; ];
@ -99,6 +125,7 @@ export const GOTO_NEXT_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistNextPage';
export const GOTO_LAST_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistLastPage'; export const GOTO_LAST_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistLastPage';
export const GOTO_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistPage'; export const GOTO_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistPage';
export const SET_BLOCKLIST_SORT = 'blocklist/setBlocklistSort'; export const SET_BLOCKLIST_SORT = 'blocklist/setBlocklistSort';
export const SET_BLOCKLIST_FILTER = 'blocklist/setBlocklistFilter';
export const SET_BLOCKLIST_TABLE_OPTION = 'blocklist/setBlocklistTableOption'; export const SET_BLOCKLIST_TABLE_OPTION = 'blocklist/setBlocklistTableOption';
export const REMOVE_BLOCKLIST_ITEM = 'blocklist/removeBlocklistItem'; export const REMOVE_BLOCKLIST_ITEM = 'blocklist/removeBlocklistItem';
export const REMOVE_BLOCKLIST_ITEMS = 'blocklist/removeBlocklistItems'; export const REMOVE_BLOCKLIST_ITEMS = 'blocklist/removeBlocklistItems';
@ -114,6 +141,7 @@ export const gotoBlocklistNextPage = createThunk(GOTO_NEXT_BLOCKLIST_PAGE);
export const gotoBlocklistLastPage = createThunk(GOTO_LAST_BLOCKLIST_PAGE); export const gotoBlocklistLastPage = createThunk(GOTO_LAST_BLOCKLIST_PAGE);
export const gotoBlocklistPage = createThunk(GOTO_BLOCKLIST_PAGE); export const gotoBlocklistPage = createThunk(GOTO_BLOCKLIST_PAGE);
export const setBlocklistSort = createThunk(SET_BLOCKLIST_SORT); export const setBlocklistSort = createThunk(SET_BLOCKLIST_SORT);
export const setBlocklistFilter = createThunk(SET_BLOCKLIST_FILTER);
export const setBlocklistTableOption = createAction(SET_BLOCKLIST_TABLE_OPTION); export const setBlocklistTableOption = createAction(SET_BLOCKLIST_TABLE_OPTION);
export const removeBlocklistItem = createThunk(REMOVE_BLOCKLIST_ITEM); export const removeBlocklistItem = createThunk(REMOVE_BLOCKLIST_ITEM);
export const removeBlocklistItems = createThunk(REMOVE_BLOCKLIST_ITEMS); export const removeBlocklistItems = createThunk(REMOVE_BLOCKLIST_ITEMS);
@ -134,7 +162,8 @@ export const actionHandlers = handleThunks({
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_BLOCKLIST_PAGE, [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_BLOCKLIST_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_BLOCKLIST_PAGE, [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_BLOCKLIST_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_BLOCKLIST_PAGE, [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_BLOCKLIST_PAGE,
[serverSideCollectionHandlers.SORT]: SET_BLOCKLIST_SORT [serverSideCollectionHandlers.SORT]: SET_BLOCKLIST_SORT,
[serverSideCollectionHandlers.FILTER]: SET_BLOCKLIST_FILTER
}), }),
[REMOVE_BLOCKLIST_ITEM]: createRemoveItemHandler(section, '/blocklist'), [REMOVE_BLOCKLIST_ITEM]: createRemoveItemHandler(section, '/blocklist'),

View File

@ -42,7 +42,9 @@ export const defaultState = {
view: 'overview', view: 'overview',
options: { options: {
includeRecommendations: true includeRecommendations: true,
includeTrending: true,
includePopular: true
}, },
defaults: { defaults: {
@ -583,10 +585,14 @@ export const actionHandlers = handleThunks({
...otherPayload ...otherPayload
} = payload; } = payload;
const includeRecommendations = getState().discoverMovie.options.includeRecommendations; const {
includeRecommendations = false,
includeTrending = false,
includePopular = false
} = getState().discoverMovie.options;
const promise = createAjaxRequest({ const promise = createAjaxRequest({
url: `/importlist/movie?includeRecommendations=${includeRecommendations}`, url: `/importlist/movie?includeRecommendations=${includeRecommendations}&includeTrending=${includeTrending}&includePopular=${includePopular}`,
data: otherPayload, data: otherPayload,
traditional: true traditional: true
}).request; }).request;

View File

@ -28,6 +28,7 @@ import * as rootFolders from './rootFolderActions';
import * as settings from './settingsActions'; import * as settings from './settingsActions';
import * as system from './systemActions'; import * as system from './systemActions';
import * as tags from './tagActions'; import * as tags from './tagActions';
import * as wanted from './wantedActions';
export default [ export default [
addMovie, addMovie,
@ -59,5 +60,6 @@ export default [
movieCredits, movieCredits,
settings, settings,
system, system,
tags tags,
wanted
]; ];

View File

@ -0,0 +1,298 @@
import { createAction } from 'redux-actions';
import { filterTypes, sortDirections } from 'Helpers/Props';
import createBatchToggleMovieMonitoredHandler from 'Store/Actions/Creators/createBatchToggleMovieMonitoredHandler';
import { createThunk, handleThunks } from 'Store/thunks';
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
import translate from 'Utilities/String/translate';
import createHandleActions from './Creators/createHandleActions';
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
import createClearReducer from './Creators/Reducers/createClearReducer';
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
//
// Variables
export const section = 'wanted';
//
// State
export const defaultState = {
missing: {
isFetching: false,
isPopulated: false,
pageSize: 20,
sortKey: 'movieMetadata.sortTitle',
sortDirection: sortDirections.ASCENDING,
error: null,
items: [],
columns: [
{
name: 'movieMetadata.sortTitle',
label: () => translate('MovieTitle'),
isSortable: true,
isVisible: true
},
{
name: 'movieMetadata.year',
label: () => translate('Year'),
isSortable: true,
isVisible: true
},
{
name: 'status',
label: () => translate('Status'),
isVisible: true
},
{
name: 'actions',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
],
selectedFilterKey: 'monitored',
filters: [
{
key: 'monitored',
label: () => translate('Monitored'),
filters: [
{
key: 'monitored',
value: true,
type: filterTypes.EQUAL
}
]
},
{
key: 'unmonitored',
label: () => translate('Unmonitored'),
filters: [
{
key: 'monitored',
value: false,
type: filterTypes.EQUAL
}
]
}
]
},
cutoffUnmet: {
isFetching: false,
isPopulated: false,
pageSize: 20,
sortKey: 'movieMetadata.sortTitle',
sortDirection: sortDirections.ASCENDING,
items: [],
columns: [
{
name: 'movieMetadata.sortTitle',
label: () => translate('MovieTitle'),
isSortable: true,
isVisible: true
},
{
name: 'movieMetadata.year',
label: () => translate('Year'),
isSortable: true,
isVisible: true
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: false
},
{
name: 'status',
label: () => translate('Status'),
isVisible: true
},
{
name: 'actions',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
],
selectedFilterKey: 'monitored',
filters: [
{
key: 'monitored',
label: () => translate('Monitored'),
filters: [
{
key: 'monitored',
value: true,
type: filterTypes.EQUAL
}
]
},
{
key: 'unmonitored',
label: () => translate('Unmonitored'),
filters: [
{
key: 'monitored',
value: false,
type: filterTypes.EQUAL
}
]
}
]
}
};
export const persistState = [
'wanted.missing.pageSize',
'wanted.missing.sortKey',
'wanted.missing.sortDirection',
'wanted.missing.selectedFilterKey',
'wanted.missing.columns',
'wanted.cutoffUnmet.pageSize',
'wanted.cutoffUnmet.sortKey',
'wanted.cutoffUnmet.sortDirection',
'wanted.cutoffUnmet.selectedFilterKey',
'wanted.cutoffUnmet.columns'
];
//
// Actions Types
export const FETCH_MISSING = 'wanted/missing/fetchMissing';
export const GOTO_FIRST_MISSING_PAGE = 'wanted/missing/gotoMissingFirstPage';
export const GOTO_PREVIOUS_MISSING_PAGE = 'wanted/missing/gotoMissingPreviousPage';
export const GOTO_NEXT_MISSING_PAGE = 'wanted/missing/gotoMissingNextPage';
export const GOTO_LAST_MISSING_PAGE = 'wanted/missing/gotoMissingLastPage';
export const GOTO_MISSING_PAGE = 'wanted/missing/gotoMissingPage';
export const SET_MISSING_SORT = 'wanted/missing/setMissingSort';
export const SET_MISSING_FILTER = 'wanted/missing/setMissingFilter';
export const SET_MISSING_TABLE_OPTION = 'wanted/missing/setMissingTableOption';
export const CLEAR_MISSING = 'wanted/missing/clearMissing';
export const BATCH_TOGGLE_MISSING_MOVIES = 'wanted/missing/batchToggleMissingMovies';
export const FETCH_CUTOFF_UNMET = 'wanted/cutoffUnmet/fetchCutoffUnmet';
export const GOTO_FIRST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFirstPage';
export const GOTO_PREVIOUS_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPreviousPage';
export const GOTO_NEXT_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetNextPage';
export const GOTO_LAST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFastPage';
export const GOTO_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPage';
export const SET_CUTOFF_UNMET_SORT = 'wanted/cutoffUnmet/setCutoffUnmetSort';
export const SET_CUTOFF_UNMET_FILTER = 'wanted/cutoffUnmet/setCutoffUnmetFilter';
export const SET_CUTOFF_UNMET_TABLE_OPTION = 'wanted/cutoffUnmet/setCutoffUnmetTableOption';
export const CLEAR_CUTOFF_UNMET = 'wanted/cutoffUnmet/clearCutoffUnmet';
export const BATCH_TOGGLE_CUTOFF_UNMET_MOVIES = 'wanted/cutoffUnmet/batchToggleCutoffUnmetMovies';
//
// Action Creators
export const fetchMissing = createThunk(FETCH_MISSING);
export const gotoMissingFirstPage = createThunk(GOTO_FIRST_MISSING_PAGE);
export const gotoMissingPreviousPage = createThunk(GOTO_PREVIOUS_MISSING_PAGE);
export const gotoMissingNextPage = createThunk(GOTO_NEXT_MISSING_PAGE);
export const gotoMissingLastPage = createThunk(GOTO_LAST_MISSING_PAGE);
export const gotoMissingPage = createThunk(GOTO_MISSING_PAGE);
export const setMissingSort = createThunk(SET_MISSING_SORT);
export const setMissingFilter = createThunk(SET_MISSING_FILTER);
export const setMissingTableOption = createAction(SET_MISSING_TABLE_OPTION);
export const clearMissing = createAction(CLEAR_MISSING);
export const batchToggleMissingMovies = createThunk(BATCH_TOGGLE_MISSING_MOVIES);
export const fetchCutoffUnmet = createThunk(FETCH_CUTOFF_UNMET);
export const gotoCutoffUnmetFirstPage = createThunk(GOTO_FIRST_CUTOFF_UNMET_PAGE);
export const gotoCutoffUnmetPreviousPage = createThunk(GOTO_PREVIOUS_CUTOFF_UNMET_PAGE);
export const gotoCutoffUnmetNextPage = createThunk(GOTO_NEXT_CUTOFF_UNMET_PAGE);
export const gotoCutoffUnmetLastPage = createThunk(GOTO_LAST_CUTOFF_UNMET_PAGE);
export const gotoCutoffUnmetPage = createThunk(GOTO_CUTOFF_UNMET_PAGE);
export const setCutoffUnmetSort = createThunk(SET_CUTOFF_UNMET_SORT);
export const setCutoffUnmetFilter = createThunk(SET_CUTOFF_UNMET_FILTER);
export const setCutoffUnmetTableOption = createAction(SET_CUTOFF_UNMET_TABLE_OPTION);
export const clearCutoffUnmet = createAction(CLEAR_CUTOFF_UNMET);
export const batchToggleCutoffUnmetMovies = createThunk(BATCH_TOGGLE_CUTOFF_UNMET_MOVIES);
//
// Action Handlers
export const actionHandlers = handleThunks({
...createServerSideCollectionHandlers(
'wanted.missing',
'/wanted/missing',
fetchMissing,
{
[serverSideCollectionHandlers.FETCH]: FETCH_MISSING,
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_MISSING_PAGE,
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_MISSING_PAGE,
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_MISSING_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_MISSING_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_MISSING_PAGE,
[serverSideCollectionHandlers.SORT]: SET_MISSING_SORT,
[serverSideCollectionHandlers.FILTER]: SET_MISSING_FILTER
}
),
[BATCH_TOGGLE_MISSING_MOVIES]: createBatchToggleMovieMonitoredHandler('wanted.missing', fetchMissing),
...createServerSideCollectionHandlers(
'wanted.cutoffUnmet',
'/wanted/cutoff',
fetchCutoffUnmet,
{
[serverSideCollectionHandlers.FETCH]: FETCH_CUTOFF_UNMET,
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_CUTOFF_UNMET_PAGE,
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_CUTOFF_UNMET_PAGE,
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_CUTOFF_UNMET_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_CUTOFF_UNMET_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_CUTOFF_UNMET_PAGE,
[serverSideCollectionHandlers.SORT]: SET_CUTOFF_UNMET_SORT,
[serverSideCollectionHandlers.FILTER]: SET_CUTOFF_UNMET_FILTER
}
),
[BATCH_TOGGLE_CUTOFF_UNMET_MOVIES]: createBatchToggleMovieMonitoredHandler('wanted.cutoffUnmet', fetchCutoffUnmet)
});
//
// Reducers
export const reducers = createHandleActions({
[SET_MISSING_TABLE_OPTION]: createSetTableOptionReducer('wanted.missing'),
[SET_CUTOFF_UNMET_TABLE_OPTION]: createSetTableOptionReducer('wanted.cutoffUnmet'),
[CLEAR_MISSING]: createClearReducer(
'wanted.missing',
{
isFetching: false,
isPopulated: false,
error: null,
items: [],
totalPages: 0,
totalRecords: 0
}
),
[CLEAR_CUTOFF_UNMET]: createClearReducer(
'wanted.cutoffUnmet',
{
isFetching: false,
isPopulated: false,
error: null,
items: [],
totalPages: 0,
totalRecords: 0
}
)
}, defaultState, section);

View File

@ -1,4 +1,6 @@
import _ from 'lodash';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import movieEntities from 'Movie/movieEntities';
export function createMovieSelectorForHook(movieId) { export function createMovieSelectorForHook(movieId) {
return createSelector( return createSelector(
@ -11,6 +13,16 @@ export function createMovieSelectorForHook(movieId) {
); );
} }
export function createMovieByEntitySelector() {
return createSelector(
(state, { movieId }) => movieId,
(state, { movieEntity = movieEntities.MOVIES }) => _.get(state, movieEntity, { items: [] }),
(movieId, movies) => {
return _.find(movies.items, { id: movieId });
}
);
}
function createMovieSelector() { function createMovieSelector() {
return createSelector( return createSelector(
(state, { movieId }) => movieId, (state, { movieId }) => movieId,

View File

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

View File

@ -2,7 +2,6 @@ module.exports = {
// Families // Families
defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif', defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif',
monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;', monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;',
passwordFamily: 'text-security-disc',
// Sizes // Sizes
extraSmallFontSize: '11px', extraSmallFontSize: '11px',

View File

@ -6,6 +6,22 @@ import createMultiMoviesSelector from 'Store/Selectors/createMultiMoviesSelector
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './QueuedTaskRowNameCell.css'; import styles from './QueuedTaskRowNameCell.css';
function formatTitles(titles: string[]) {
if (!titles) {
return null;
}
if (titles.length > 11) {
return (
<span title={titles.join(', ')}>
{titles.slice(0, 10).join(', ')}, {titles.length - 10} more
</span>
);
}
return <span>{titles.join(', ')}</span>;
}
export interface QueuedTaskRowNameCellProps { export interface QueuedTaskRowNameCellProps {
commandName: string; commandName: string;
body: CommandBody; body: CommandBody;
@ -32,7 +48,7 @@ export default function QueuedTaskRowNameCell(
<span className={styles.commandName}> <span className={styles.commandName}>
{commandName} {commandName}
{sortedMovies.length ? ( {sortedMovies.length ? (
<span> - {sortedMovies.map((m) => m.title).join(', ')}</span> <span> - {formatTitles(sortedMovies.map((m) => m.title))}</span>
) : null} ) : null}
</span> </span>

View File

@ -1,9 +1,9 @@
import _ from 'lodash'; import _ from 'lodash';
import { update } from 'Store/Actions/baseActions'; import { update } from 'Store/Actions/baseActions';
function updateEpisodes(section, episodes, episodeIds, options) { function updateMovies(section, movies, movieIds, options) {
const data = _.reduce(episodes, (result, item) => { const data = _.reduce(movies, (result, item) => {
if (episodeIds.indexOf(item.id) > -1) { if (movieIds.indexOf(item.id) > -1) {
result.push({ result.push({
...item, ...item,
...options ...options
@ -18,4 +18,4 @@ function updateEpisodes(section, episodes, episodeIds, options) {
return update({ section, data }); return update({ section, data });
} }
export default updateEpisodes; export default updateMovies;

View File

@ -0,0 +1,301 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import { align, icons, kinds } from 'Helpers/Props';
import getFilterValue from 'Utilities/Filter/getFilterValue';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import CutoffUnmetRow from './CutoffUnmetRow';
function getMonitoredValue(props) {
const {
filters,
selectedFilterKey
} = props;
return getFilterValue(filters, selectedFilterKey, 'monitored', false);
}
class CutoffUnmet extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
isConfirmSearchAllCutoffUnmetModalOpen: false,
isInteractiveImportModalOpen: false
};
}
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
this.setState((state) => {
return removeOldSelectedState(state, prevProps.items);
});
}
}
//
// Control
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
};
//
// Listeners
onFilterMenuItemPress = (filterKey, filterValue) => {
this.props.onFilterSelect(filterKey, filterValue);
};
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
};
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
};
onSearchSelectedPress = () => {
const selected = this.getSelectedIds();
this.props.onSearchSelectedPress(selected);
};
onToggleSelectedPress = () => {
const movieIds = this.getSelectedIds();
this.props.batchToggleCutoffUnmetMovies({
movieIds,
monitored: !getMonitoredValue(this.props)
});
};
onSearchAllCutoffUnmetPress = () => {
this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: true });
};
onSearchAllCutoffUnmetConfirmed = () => {
const {
selectedFilterKey,
onSearchAllCutoffUnmetPress
} = this.props;
// TODO: Custom filters will need to check whether there is a monitored
// filter once implemented.
onSearchAllCutoffUnmetPress(selectedFilterKey === 'monitored');
this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false });
};
onConfirmSearchAllCutoffUnmetModalClose = () => {
this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false });
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
selectedFilterKey,
filters,
columns,
totalRecords,
isSearchingForCutoffUnmetMovies,
isSaving,
onFilterSelect,
...otherProps
} = this.props;
const {
allSelected,
allUnselected,
selectedState,
isConfirmSearchAllCutoffUnmetModalOpen
} = this.state;
const itemsSelected = !!this.getSelectedIds().length;
const isShowingMonitored = getMonitoredValue(this.props);
return (
<PageContent title={translate('CutoffUnmet')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('SearchSelected')}
iconName={icons.SEARCH}
isDisabled={!itemsSelected || isSearchingForCutoffUnmetMovies}
onPress={this.onSearchSelectedPress}
/>
<PageToolbarButton
label={isShowingMonitored ? translate('UnmonitorSelected') : translate('MonitorSelected')}
iconName={icons.MONITORED}
isDisabled={!itemsSelected}
isSpinning={isSaving}
onPress={this.onToggleSelectedPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('SearchAll')}
iconName={icons.SEARCH}
isDisabled={!items.length}
isSpinning={isSearchingForCutoffUnmetMovies}
onPress={this.onSearchAllCutoffUnmetPress}
/>
<PageToolbarSeparator />
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={[]}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && error &&
<Alert kind={kinds.DANGER}>
{translate('CutoffUnmetLoadError')}
</Alert>
}
{
isPopulated && !error && !items.length &&
<Alert kind={kinds.INFO}>
{translate('CutoffUnmetNoItems')}
</Alert>
}
{
isPopulated && !error && !!items.length &&
<div>
<Table
columns={columns}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
{...otherProps}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<CutoffUnmetRow
key={item.id}
isSelected={selectedState[item.id]}
columns={columns}
{...item}
onSelectedChange={this.onSelectedChange}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isFetching}
{...otherProps}
/>
<ConfirmModal
isOpen={isConfirmSearchAllCutoffUnmetModalOpen}
kind={kinds.DANGER}
title={translate('SearchForCutoffUnmetMovies')}
message={
<div>
<div>
{translate('SearchForCutoffUnmetMoviesConfirmationCount', { totalRecords })}
</div>
<div>
{translate('MassSearchCancelWarning')}
</div>
</div>
}
confirmLabel={translate('Search')}
onConfirm={this.onSearchAllCutoffUnmetConfirmed}
onCancel={this.onConfirmSearchAllCutoffUnmetModalClose}
/>
</div>
}
</PageContentBody>
</PageContent>
);
}
}
CutoffUnmet.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number,
isSearchingForCutoffUnmetMovies: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onSearchSelectedPress: PropTypes.func.isRequired,
batchToggleCutoffUnmetMovies: PropTypes.func.isRequired,
onSearchAllCutoffUnmetPress: PropTypes.func.isRequired
};
export default CutoffUnmet;

View File

@ -0,0 +1,185 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import withCurrentPage from 'Components/withCurrentPage';
import { executeCommand } from 'Store/Actions/commandActions';
import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
import * as wantedActions from 'Store/Actions/wantedActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import CutoffUnmet from './CutoffUnmet';
function createMapStateToProps() {
return createSelector(
(state) => state.wanted.cutoffUnmet,
createCommandExecutingSelector(commandNames.CUTOFF_UNMET_MOVIES_SEARCH),
(cutoffUnmet, isSearchingForCutoffUnmetMovies) => {
return {
isSearchingForCutoffUnmetMovies,
isSaving: cutoffUnmet.items.filter((m) => m.isSaving).length > 1,
...cutoffUnmet
};
}
);
}
const mapDispatchToProps = {
...wantedActions,
executeCommand,
fetchQueueDetails,
clearQueueDetails,
fetchMovieFiles,
clearMovieFiles
};
class CutoffUnmetConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
useCurrentPage,
fetchCutoffUnmet,
gotoCutoffUnmetFirstPage
} = this.props;
registerPagePopulator(this.repopulate, ['movieFileUpdated', 'movieFileDeleted']);
if (useCurrentPage) {
fetchCutoffUnmet();
} else {
gotoCutoffUnmetFirstPage();
}
}
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
const movieIds = selectUniqueIds(this.props.items, 'id');
const movieFileIds = selectUniqueIds(this.props.items, 'movieFileId');
this.props.fetchQueueDetails({ movieIds });
if (movieFileIds.length) {
this.props.fetchMovieFiles({ movieFileIds });
}
}
}
componentWillUnmount() {
unregisterPagePopulator(this.repopulate);
this.props.clearCutoffUnmet();
this.props.clearQueueDetails();
this.props.clearMovieFiles();
}
//
// Control
repopulate = () => {
this.props.fetchCutoffUnmet();
};
//
// Listeners
onFirstPagePress = () => {
this.props.gotoCutoffUnmetFirstPage();
};
onPreviousPagePress = () => {
this.props.gotoCutoffUnmetPreviousPage();
};
onNextPagePress = () => {
this.props.gotoCutoffUnmetNextPage();
};
onLastPagePress = () => {
this.props.gotoCutoffUnmetLastPage();
};
onPageSelect = (page) => {
this.props.gotoCutoffUnmetPage({ page });
};
onSortPress = (sortKey) => {
this.props.setCutoffUnmetSort({ sortKey });
};
onFilterSelect = (selectedFilterKey) => {
this.props.setCutoffUnmetFilter({ selectedFilterKey });
};
onTableOptionChange = (payload) => {
this.props.setCutoffUnmetTableOption(payload);
if (payload.pageSize) {
this.props.gotoCutoffUnmetFirstPage();
}
};
onSearchSelectedPress = (selected) => {
this.props.executeCommand({
name: commandNames.MOVIE_SEARCH,
movieIds: selected
});
};
onSearchAllCutoffUnmetPress = (monitored) => {
this.props.executeCommand({
name: commandNames.CUTOFF_UNMET_MOVIES_SEARCH,
monitored
});
};
//
// Render
render() {
return (
<CutoffUnmet
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
onSearchSelectedPress={this.onSearchSelectedPress}
onToggleSelectedPress={this.onToggleSelectedPress}
onSearchAllCutoffUnmetPress={this.onSearchAllCutoffUnmetPress}
{...this.props}
/>
);
}
}
CutoffUnmetConnector.propTypes = {
useCurrentPage: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchCutoffUnmet: PropTypes.func.isRequired,
gotoCutoffUnmetFirstPage: PropTypes.func.isRequired,
gotoCutoffUnmetPreviousPage: PropTypes.func.isRequired,
gotoCutoffUnmetNextPage: PropTypes.func.isRequired,
gotoCutoffUnmetLastPage: PropTypes.func.isRequired,
gotoCutoffUnmetPage: PropTypes.func.isRequired,
setCutoffUnmetSort: PropTypes.func.isRequired,
setCutoffUnmetFilter: PropTypes.func.isRequired,
setCutoffUnmetTableOption: PropTypes.func.isRequired,
clearCutoffUnmet: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired,
fetchQueueDetails: PropTypes.func.isRequired,
clearQueueDetails: PropTypes.func.isRequired,
fetchMovieFiles: PropTypes.func.isRequired,
clearMovieFiles: PropTypes.func.isRequired
};
export default withCurrentPage(
connect(createMapStateToProps, mapDispatchToProps)(CutoffUnmetConnector)
);

View File

@ -0,0 +1,6 @@
.languages,
.status {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 100px;
}

View File

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

View File

@ -0,0 +1,120 @@
import PropTypes from 'prop-types';
import React from 'react';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import movieEntities from 'Movie/movieEntities';
import MovieSearchCellConnector from 'Movie/MovieSearchCellConnector';
import MovieStatusConnector from 'Movie/MovieStatusConnector';
import MovieTitleLink from 'Movie/MovieTitleLink';
import MovieFileLanguageConnector from 'MovieFile/MovieFileLanguageConnector';
import styles from './CutoffUnmetRow.css';
function CutoffUnmetRow(props) {
const {
id,
movieFileId,
year,
title,
titleSlug,
isSelected,
columns,
onSelectedChange
} = props;
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'movieMetadata.sortTitle') {
return (
<TableRowCell key={name}>
<MovieTitleLink
titleSlug={titleSlug}
title={title}
/>
</TableRowCell>
);
}
if (name === 'movieMetadata.year') {
return (
<TableRowCell key={name}>
{year}
</TableRowCell>
);
}
if (name === 'languages') {
return (
<TableRowCell
key={name}
className={styles.languages}
>
<MovieFileLanguageConnector
movieFileId={movieFileId}
/>
</TableRowCell>
);
}
if (name === 'status') {
return (
<TableRowCell
key={name}
className={styles.status}
>
<MovieStatusConnector
movieId={id}
movieFileId={movieFileId}
movieEntity={movieEntities.WANTED_CUTOFF_UNMET}
/>
</TableRowCell>
);
}
if (name === 'actions') {
return (
<MovieSearchCellConnector
key={name}
movieId={id}
movieTitle={title}
movieEntity={movieEntities.WANTED_CUTOFF_UNMET}
/>
);
}
return null;
})
}
</TableRow>
);
}
CutoffUnmetRow.propTypes = {
id: PropTypes.number.isRequired,
movieFileId: PropTypes.number,
title: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
titleSlug: PropTypes.string.isRequired,
isSelected: PropTypes.bool,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onSelectedChange: PropTypes.func.isRequired
};
export default CutoffUnmetRow;

View File

@ -0,0 +1,319 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import { align, icons, kinds } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import getFilterValue from 'Utilities/Filter/getFilterValue';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import MissingRow from './MissingRow';
function getMonitoredValue(props) {
const {
filters,
selectedFilterKey
} = props;
return getFilterValue(filters, selectedFilterKey, 'monitored', false);
}
class Missing extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
isConfirmSearchAllMissingModalOpen: false,
isInteractiveImportModalOpen: false
};
}
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
this.setState((state) => {
return removeOldSelectedState(state, prevProps.items);
});
}
}
//
// Control
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
};
//
// Listeners
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
};
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
};
onSearchSelectedPress = () => {
const selected = this.getSelectedIds();
this.props.onSearchSelectedPress(selected);
};
onToggleSelectedPress = () => {
const movieIds = this.getSelectedIds();
this.props.batchToggleMissingMovies({
movieIds,
monitored: !getMonitoredValue(this.props)
});
};
onSearchAllMissingPress = () => {
this.setState({ isConfirmSearchAllMissingModalOpen: true });
};
onSearchAllMissingConfirmed = () => {
const {
selectedFilterKey,
onSearchAllMissingPress
} = this.props;
// TODO: Custom filters will need to check whether there is a monitored
// filter once implemented.
onSearchAllMissingPress(selectedFilterKey === 'monitored');
this.setState({ isConfirmSearchAllMissingModalOpen: false });
};
onConfirmSearchAllMissingModalClose = () => {
this.setState({ isConfirmSearchAllMissingModalOpen: false });
};
onInteractiveImportPress = () => {
this.setState({ isInteractiveImportModalOpen: true });
};
onInteractiveImportModalClose = () => {
this.setState({ isInteractiveImportModalOpen: false });
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
selectedFilterKey,
filters,
columns,
totalRecords,
isSearchingForMissingMovies,
isSaving,
onFilterSelect,
...otherProps
} = this.props;
const {
allSelected,
allUnselected,
selectedState,
isConfirmSearchAllMissingModalOpen,
isInteractiveImportModalOpen
} = this.state;
const itemsSelected = !!this.getSelectedIds().length;
const isShowingMonitored = getMonitoredValue(this.props);
return (
<PageContent title={translate('Missing')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('SearchSelected')}
iconName={icons.SEARCH}
isDisabled={!itemsSelected || isSearchingForMissingMovies}
onPress={this.onSearchSelectedPress}
/>
<PageToolbarButton
label={isShowingMonitored ? translate('UnmonitorSelected') : translate('MonitorSelected')}
iconName={icons.MONITORED}
isDisabled={!itemsSelected}
isSpinning={isSaving}
onPress={this.onToggleSelectedPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('SearchAll')}
iconName={icons.SEARCH}
isDisabled={!items.length}
isSpinning={isSearchingForMissingMovies}
onPress={this.onSearchAllMissingPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('ManualImport')}
iconName={icons.INTERACTIVE}
onPress={this.onInteractiveImportPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={[]}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && error &&
<Alert kind={kinds.DANGER}>
{translate('MissingLoadError')}
</Alert>
}
{
isPopulated && !error && !items.length &&
<Alert kind={kinds.INFO}>
{translate('MissingNoItems')}
</Alert>
}
{
isPopulated && !error && !!items.length &&
<div>
<Table
columns={columns}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
{...otherProps}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<MissingRow
key={item.id}
isSelected={selectedState[item.id]}
columns={columns}
{...item}
onSelectedChange={this.onSelectedChange}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isFetching}
{...otherProps}
/>
<ConfirmModal
isOpen={isConfirmSearchAllMissingModalOpen}
kind={kinds.DANGER}
title={translate('SearchForAllMissingMovies')}
message={
<div>
<div>
{translate('SearchForAllMissingMoviesConfirmationCount', { totalRecords })}
</div>
<div>
{translate('MassSearchCancelWarning')}
</div>
</div>
}
confirmLabel={translate('Search')}
onConfirm={this.onSearchAllMissingConfirmed}
onCancel={this.onConfirmSearchAllMissingModalClose}
/>
</div>
}
<InteractiveImportModal
isOpen={isInteractiveImportModalOpen}
onModalClose={this.onInteractiveImportModalClose}
/>
</PageContentBody>
</PageContent>
);
}
}
Missing.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number,
isSearchingForMissingMovies: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onSearchSelectedPress: PropTypes.func.isRequired,
batchToggleMissingMovies: PropTypes.func.isRequired,
onSearchAllMissingPress: PropTypes.func.isRequired
};
export default Missing;

View File

@ -0,0 +1,173 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import withCurrentPage from 'Components/withCurrentPage';
import { executeCommand } from 'Store/Actions/commandActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
import * as wantedActions from 'Store/Actions/wantedActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import Missing from './Missing';
function createMapStateToProps() {
return createSelector(
(state) => state.wanted.missing,
createCommandExecutingSelector(commandNames.MISSING_MOVIES_SEARCH),
(missing, isSearchingForMissingMovies) => {
return {
isSearchingForMissingMovies,
isSaving: missing.items.filter((m) => m.isSaving).length > 1,
...missing
};
}
);
}
const mapDispatchToProps = {
...wantedActions,
executeCommand,
fetchQueueDetails,
clearQueueDetails
};
class MissingConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
useCurrentPage,
fetchMissing,
gotoMissingFirstPage
} = this.props;
registerPagePopulator(this.repopulate, ['movieFileUpdated', 'movieFileDeleted']);
if (useCurrentPage) {
fetchMissing();
} else {
gotoMissingFirstPage();
}
}
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
const movieIds = selectUniqueIds(this.props.items, 'id');
this.props.fetchQueueDetails({ movieIds });
}
}
componentWillUnmount() {
unregisterPagePopulator(this.repopulate);
this.props.clearMissing();
this.props.clearQueueDetails();
}
//
// Control
repopulate = () => {
this.props.fetchMissing();
};
//
// Listeners
onFirstPagePress = () => {
this.props.gotoMissingFirstPage();
};
onPreviousPagePress = () => {
this.props.gotoMissingPreviousPage();
};
onNextPagePress = () => {
this.props.gotoMissingNextPage();
};
onLastPagePress = () => {
this.props.gotoMissingLastPage();
};
onPageSelect = (page) => {
this.props.gotoMissingPage({ page });
};
onSortPress = (sortKey) => {
this.props.setMissingSort({ sortKey });
};
onFilterSelect = (selectedFilterKey) => {
this.props.setMissingFilter({ selectedFilterKey });
};
onTableOptionChange = (payload) => {
this.props.setMissingTableOption(payload);
if (payload.pageSize) {
this.props.gotoMissingFirstPage();
}
};
onSearchSelectedPress = (selected) => {
this.props.executeCommand({
name: commandNames.MOVIE_SEARCH,
movieIds: selected
});
};
onSearchAllMissingPress = (monitored) => {
this.props.executeCommand({
name: commandNames.MISSING_MOVIES_SEARCH,
monitored
});
};
//
// Render
render() {
return (
<Missing
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
onSearchSelectedPress={this.onSearchSelectedPress}
onSearchAllMissingPress={this.onSearchAllMissingPress}
{...this.props}
/>
);
}
}
MissingConnector.propTypes = {
useCurrentPage: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchMissing: PropTypes.func.isRequired,
gotoMissingFirstPage: PropTypes.func.isRequired,
gotoMissingPreviousPage: PropTypes.func.isRequired,
gotoMissingNextPage: PropTypes.func.isRequired,
gotoMissingLastPage: PropTypes.func.isRequired,
gotoMissingPage: PropTypes.func.isRequired,
setMissingSort: PropTypes.func.isRequired,
setMissingFilter: PropTypes.func.isRequired,
setMissingTableOption: PropTypes.func.isRequired,
clearMissing: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired,
fetchQueueDetails: PropTypes.func.isRequired,
clearQueueDetails: PropTypes.func.isRequired
};
export default withCurrentPage(
connect(createMapStateToProps, mapDispatchToProps)(MissingConnector)
);

View File

@ -0,0 +1,5 @@
.status {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 100px;
}

View File

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

View File

@ -0,0 +1,110 @@
import PropTypes from 'prop-types';
import React from 'react';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import movieEntities from 'Movie/movieEntities';
import MovieSearchCellConnector from 'Movie/MovieSearchCellConnector';
import MovieStatusConnector from 'Movie/MovieStatusConnector';
import MovieTitleLink from 'Movie/MovieTitleLink';
import styles from './MissingRow.css';
function MissingRow(props) {
const {
id,
movieFileId,
year,
title,
titleSlug,
isSelected,
columns,
onSelectedChange
} = props;
if (!title) {
return null;
}
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'movieMetadata.sortTitle') {
return (
<TableRowCell key={name}>
<MovieTitleLink
titleSlug={titleSlug}
title={title}
/>
</TableRowCell>
);
}
if (name === 'movieMetadata.year') {
return (
<TableRowCell key={name}>
{year}
</TableRowCell>
);
}
if (name === 'status') {
return (
<TableRowCell
key={name}
className={styles.status}
>
<MovieStatusConnector
movieId={id}
movieFileId={movieFileId}
movieEntity={movieEntities.WANTED_MISSING}
/>
</TableRowCell>
);
}
if (name === 'actions') {
return (
<MovieSearchCellConnector
key={name}
movieId={id}
movieTitle={title}
movieEntity={movieEntities.WANTED_MISSING}
/>
);
}
return null;
})
}
</TableRow>
);
}
MissingRow.propTypes = {
id: PropTypes.number.isRequired,
movieFileId: PropTypes.number,
title: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
titleSlug: PropTypes.string.isRequired,
isSelected: PropTypes.bool,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onSelectedChange: PropTypes.func.isRequired
};
export default MissingRow;

View File

@ -57,8 +57,8 @@
<style> <style>
body { body {
background-color: #f5f7fa; background-color: var(--pageBackground);
color: #656565; color: var(--textColor);
font-family: "Roboto", "open sans", "Helvetica Neue", Helvetica, Arial, font-family: "Roboto", "open sans", "Helvetica Neue", Helvetica, Arial,
sans-serif; sans-serif;
} }
@ -88,14 +88,14 @@
padding: 10px; padding: 10px;
border-top-left-radius: 4px; border-top-left-radius: 4px;
border-top-right-radius: 4px; border-top-right-radius: 4px;
background-color: #464b51; background-color: var(--themeDarkColor);
} }
.panel-body { .panel-body {
padding: 20px; padding: 20px;
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
background-color: #fff; background-color: var(--panelBackground);
} }
.sign-in { .sign-in {
@ -112,16 +112,18 @@
padding: 6px 16px; padding: 6px 16px;
width: 100%; width: 100%;
height: 35px; height: 35px;
border: 1px solid #dde6e9; background-color: var(--inputBackgroundColor);
border: 1px solid var(--inputBorderColor);
border-radius: 4px; border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); box-shadow: inset 0 1px 1px var(--inputBoxShadowColor);
color: var(--textColor);
} }
.form-input:focus { .form-input:focus {
outline: 0; outline: 0;
border-color: #66afe9; border-color: var(--inputFocusBorderColor);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), box-shadow: inset 0 1px 1px var(--inputBoxShadowColor),
0 0 8px rgba(102, 175, 233, 0.6); 0 0 8px var(--inputFocusBoxShadowColor);
} }
.button { .button {
@ -130,10 +132,10 @@
padding: 10px 0; padding: 10px 0;
width: 100%; width: 100%;
border: 1px solid; border: 1px solid;
border-color: #5899eb; border-color: var(--primaryBorderColor);
border-radius: 4px; border-radius: 4px;
background-color: #5d9cec; background-color: var(--primaryBackgroundColor);
color: #fff; color: var(--white);
vertical-align: middle; vertical-align: middle;
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
@ -141,9 +143,9 @@
} }
.button:hover { .button:hover {
border-color: #3483e7; border-color: var(--primaryHoverBorderColor);
background-color: #4b91ea; background-color: var(--primaryHoverBackgroundColor);
color: #fff; color: var(--white);
text-decoration: none; text-decoration: none;
} }
@ -165,24 +167,24 @@
.forgot-password { .forgot-password {
margin-left: auto; margin-left: auto;
color: #909fa7; color: var(--forgotPasswordColor);
text-decoration: none; text-decoration: none;
font-size: 13px; font-size: 13px;
} }
.forgot-password:focus, .forgot-password:focus,
.forgot-password:hover { .forgot-password:hover {
color: #748690; color: var(--forgotPasswordAltColor);
text-decoration: underline; text-decoration: underline;
} }
.forgot-password:visited { .forgot-password:visited {
color: #748690; color: var(--forgotPasswordAltColor);
} }
.login-failed { .login-failed {
margin-top: 20px; margin-top: 20px;
color: #f05050; color: var(--failedColor);
font-size: 14px; font-size: 14px;
} }
@ -291,5 +293,59 @@
loginFailedDiv.classList.remove("hidden"); loginFailedDiv.classList.remove("hidden");
} }
var light = {
white: '#fff',
pageBackground: '#f5f7fa',
textColor: '#515253',
themeDarkColor: '#464b51',
panelBackground: '#fff',
inputBackgroundColor: '#fff',
inputBorderColor: '#dde6e9',
inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)',
inputFocusBorderColor: '#66afe9',
inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)',
primaryBackgroundColor: '#5d9cec',
primaryBorderColor: '#5899eb',
primaryHoverBackgroundColor: '#4b91ea',
primaryHoverBorderColor: '#3483e7',
failedColor: '#f05050',
forgotPasswordColor: '#909fa7',
forgotPasswordAltColor: '#748690'
};
var dark = {
white: '#fff',
pageBackground: '#202020',
textColor: '#ccc',
themeDarkColor: '#494949',
panelBackground: '#111',
inputBackgroundColor: '#333',
inputBorderColor: '#dde6e9',
inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)',
inputFocusBorderColor: '#66afe9',
inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)',
primaryBackgroundColor: '#5d9cec',
primaryBorderColor: '#5899eb',
primaryHoverBackgroundColor: '#4b91ea',
primaryHoverBorderColor: '#3483e7',
failedColor: '#f05050',
forgotPasswordColor: '#737d83',
forgotPasswordAltColor: '#546067'
};
var theme = "_THEME_";
var defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var finalTheme = theme === 'dark' || (theme === 'auto' && defaultDark) ?
dark :
light;
Object.entries(finalTheme).forEach(([key, value]) => {
document.documentElement.style.setProperty(
`--${key}`,
value
);
});
</script> </script>
</html> </html>

View File

@ -0,0 +1,16 @@
import ModelBase from 'App/ModelBase';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
interface Blocklist extends ModelBase {
languages: Language[];
quality: QualityModel;
customFormats: CustomFormat[];
title: string;
date?: string;
protocol: string;
movieId?: number;
}
export default Blocklist;

View File

@ -1,5 +1,5 @@
export interface UiSettings { export interface UiSettings {
theme: string; theme: 'auto' | 'dark' | 'light';
showRelativeDates: boolean; showRelativeDates: boolean;
shortDateFormat: string; shortDateFormat: string;
longDateFormat: string; longDateFormat: string;

View File

@ -29,11 +29,11 @@
"@fortawesome/react-fontawesome": "0.2.0", "@fortawesome/react-fontawesome": "0.2.0",
"@juggle/resize-observer": "3.4.0", "@juggle/resize-observer": "3.4.0",
"@microsoft/signalr": "6.0.25", "@microsoft/signalr": "6.0.25",
"@sentry/browser": "7.51.2", "@sentry/browser": "7.100.0",
"@sentry/integrations": "7.51.2", "@sentry/integrations": "7.100.0",
"@types/node": "18.16.8", "@types/node": "18.19.31",
"@types/react": "18.2.6", "@types/react": "18.2.79",
"@types/react-dom": "18.2.4", "@types/react-dom": "18.2.25",
"classnames": "2.3.2", "classnames": "2.3.2",
"clipboard": "2.0.11", "clipboard": "2.0.11",
"connected-react-router": "6.9.3", "connected-react-router": "6.9.3",
@ -84,16 +84,16 @@
"reselect": "4.1.8", "reselect": "4.1.8",
"stacktrace-js": "2.0.2", "stacktrace-js": "2.0.2",
"swiper": "8.3.2", "swiper": "8.3.2",
"typescript": "4.9.5" "typescript": "5.1.6"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.22.11", "@babel/core": "7.24.4",
"@babel/eslint-parser": "7.22.11", "@babel/eslint-parser": "7.24.1",
"@babel/plugin-proposal-export-default-from": "7.22.5", "@babel/plugin-proposal-export-default-from": "7.24.1",
"@babel/plugin-syntax-dynamic-import": "7.8.3", "@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/preset-env": "7.22.14", "@babel/preset-env": "7.24.4",
"@babel/preset-react": "7.22.5", "@babel/preset-react": "7.24.1",
"@babel/preset-typescript": "7.22.11", "@babel/preset-typescript": "7.24.1",
"@types/lodash": "4.14.195", "@types/lodash": "4.14.195",
"@types/react-lazyload": "3.2.0", "@types/react-lazyload": "3.2.0",
"@types/react-router-dom": "5.3.3", "@types/react-router-dom": "5.3.3",
@ -101,31 +101,31 @@
"@types/react-window": "1.8.5", "@types/react-window": "1.8.5",
"@types/redux-actions": "2.6.2", "@types/redux-actions": "2.6.2",
"@types/webpack-livereload-plugin": "2.3.3", "@types/webpack-livereload-plugin": "2.3.3",
"@typescript-eslint/eslint-plugin": "5.59.5", "@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "5.59.5", "@typescript-eslint/parser": "6.21.0",
"autoprefixer": "10.4.14", "autoprefixer": "10.4.14",
"babel-loader": "9.1.3", "babel-loader": "9.1.3",
"babel-plugin-inline-classnames": "2.0.1", "babel-plugin-inline-classnames": "2.0.1",
"babel-plugin-transform-react-remove-prop-types": "0.4.24", "babel-plugin-transform-react-remove-prop-types": "0.4.24",
"core-js": "3.32.1", "core-js": "3.37.0",
"css-loader": "6.7.3", "css-loader": "6.7.3",
"css-modules-typescript-loader": "4.0.1", "css-modules-typescript-loader": "4.0.1",
"eslint": "8.45.0", "eslint": "8.57.0",
"eslint-config-prettier": "8.8.0", "eslint-config-prettier": "8.10.0",
"eslint-plugin-filenames": "1.3.2", "eslint-plugin-filenames": "1.3.2",
"eslint-plugin-import": "2.27.5", "eslint-plugin-import": "2.29.1",
"eslint-plugin-json": "3.1.0", "eslint-plugin-json": "3.1.0",
"eslint-plugin-prettier": "4.2.1", "eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.32.2", "eslint-plugin-react": "7.34.1",
"eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-simple-import-sort": "10.0.0", "eslint-plugin-simple-import-sort": "12.1.0",
"file-loader": "6.2.0", "file-loader": "6.2.0",
"filemanager-webpack-plugin": "8.0.0", "filemanager-webpack-plugin": "8.0.0",
"fork-ts-checker-webpack-plugin": "8.0.0", "fork-ts-checker-webpack-plugin": "8.0.0",
"html-webpack-plugin": "5.5.3", "html-webpack-plugin": "5.5.3",
"loader-utils": "^3.2.1", "loader-utils": "^3.2.1",
"mini-css-extract-plugin": "2.7.6", "mini-css-extract-plugin": "2.7.6",
"postcss": "8.4.23", "postcss": "8.4.38",
"postcss-color-function": "4.1.0", "postcss-color-function": "4.1.0",
"postcss-loader": "7.3.0", "postcss-loader": "7.3.0",
"postcss-mixins": "9.0.4", "postcss-mixins": "9.0.4",

View File

@ -99,10 +99,39 @@
<RootNamespace Condition="'$(RadarrProject)'=='true'">$(MSBuildProjectName.Replace('Radarr','NzbDrone'))</RootNamespace> <RootNamespace Condition="'$(RadarrProject)'=='true'">$(MSBuildProjectName.Replace('Radarr','NzbDrone'))</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup Condition="'$(TestProject)'!='true'">
<!-- Annotates .NET assemblies with repository information including SHA -->
<!-- Sentry uses this to link directly to GitHub at the exact version/file/line -->
<!-- This is built-in on .NET 8 and can be removed once the project is updated -->
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
</ItemGroup>
<!-- Sentry specific configuration: Only in Release mode -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<!-- https://docs.sentry.io/platforms/dotnet/configuration/msbuild/ -->
<!-- OrgSlug, ProjectSlug and AuthToken are required.
They can be set below, via argument to 'msbuild -p:' or environment variable -->
<SentryOrg></SentryOrg>
<SentryProject></SentryProject>
<SentryUrl></SentryUrl> <!-- If empty, assumed to be sentry.io -->
<SentryAuthToken></SentryAuthToken> <!-- Use env var instead: SENTRY_AUTH_TOKEN -->
<!-- Upload PDBs to Sentry, enabling stack traces with line numbers and file paths
without the need to deploy the application with PDBs -->
<SentryUploadSymbols>true</SentryUploadSymbols>
<!-- Source Link settings -->
<!-- https://github.com/dotnet/sourcelink/blob/main/docs/README.md#publishrepositoryurl -->
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<!-- Embeds all source code in the respective PDB. This can make it a bit bigger but since it'll be uploaded
to Sentry and not distributed to run on the server, it helps debug crashes while making releases smaller -->
<EmbedAllSources>true</EmbedAllSources>
</PropertyGroup>
<!-- Standard testing packages --> <!-- Standard testing packages -->
<ItemGroup Condition="'$(TestProject)'=='true'"> <ItemGroup Condition="'$(TestProject)'=='true'">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="NUnit" Version="3.13.3" /> <PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="NunitXml.TestLogger" Version="3.0.131" /> <PackageReference Include="NunitXml.TestLogger" Version="3.0.131" />
</ItemGroup> </ItemGroup>
@ -147,16 +176,46 @@
</Otherwise> </Otherwise>
</Choose> </Choose>
<!--
Set architecture to RuntimeInformation.ProcessArchitecture if not specified -->
<Choose>
<When Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'X64'">
<PropertyGroup>
<Architecture>x64</Architecture>
</PropertyGroup>
</When>
<When Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'X86'">
<PropertyGroup>
<Architecture>x86</Architecture>
</PropertyGroup>
</When>
<When Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'Arm64'">
<PropertyGroup>
<Architecture>arm64</Architecture>
</PropertyGroup>
</When>
<When Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'Arm'">
<PropertyGroup>
<Architecture>arm</Architecture>
</PropertyGroup>
</When>
<Otherwise>
<PropertyGroup>
<Architecture></Architecture>
</PropertyGroup>
</Otherwise>
</Choose>
<PropertyGroup Condition="'$(IsWindows)' == 'true' and <PropertyGroup Condition="'$(IsWindows)' == 'true' and
'$(RuntimeIdentifier)' == ''"> '$(RuntimeIdentifier)' == ''">
<_UsingDefaultRuntimeIdentifier>true</_UsingDefaultRuntimeIdentifier> <_UsingDefaultRuntimeIdentifier>true</_UsingDefaultRuntimeIdentifier>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-$(Architecture)</RuntimeIdentifier>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(IsLinux)' == 'true' and <PropertyGroup Condition="'$(IsLinux)' == 'true' and
'$(RuntimeIdentifier)' == ''"> '$(RuntimeIdentifier)' == ''">
<_UsingDefaultRuntimeIdentifier>true</_UsingDefaultRuntimeIdentifier> <_UsingDefaultRuntimeIdentifier>true</_UsingDefaultRuntimeIdentifier>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier> <RuntimeIdentifier>linux-$(Architecture)</RuntimeIdentifier>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(IsOSX)' == 'true' and <PropertyGroup Condition="'$(IsOSX)' == 'true' and

View File

@ -55,6 +55,16 @@ namespace NzbDrone.Automation.Test
_page.Find(By.LinkText("Blocklist")).Should().NotBeNull(); _page.Find(By.LinkText("Blocklist")).Should().NotBeNull();
} }
[Test]
public void wanted_page()
{
_page.WantedNavIcon.Click();
_page.WaitForNoSpinner();
_page.Find(By.LinkText("Missing")).Should().NotBeNull();
_page.Find(By.LinkText("Cutoff Unmet")).Should().NotBeNull();
}
[Test] [Test]
public void system_page() public void system_page()
{ {

View File

@ -57,6 +57,8 @@ namespace NzbDrone.Automation.Test.PageModel
public IWebElement ActivityNavIcon => Find(By.LinkText("Activity")); public IWebElement ActivityNavIcon => Find(By.LinkText("Activity"));
public IWebElement WantedNavIcon => Find(By.LinkText("Wanted"));
public IWebElement SettingNavIcon => Find(By.LinkText("Settings")); public IWebElement SettingNavIcon => Find(By.LinkText("Settings"));
public IWebElement SystemNavIcon => Find(By.PartialLinkText("System")); public IWebElement SystemNavIcon => Find(By.PartialLinkText("System"));

View File

@ -1,10 +1,12 @@
using System.Collections.Generic; using System.Collections.Generic;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.Options;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Options;
using NzbDrone.Core.Authentication; using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Test.Common; using NzbDrone.Test.Common;
@ -43,6 +45,26 @@ namespace NzbDrone.Common.Test
Mocker.GetMock<IDiskProvider>() Mocker.GetMock<IDiskProvider>()
.Setup(v => v.WriteAllText(configFile, It.IsAny<string>())) .Setup(v => v.WriteAllText(configFile, It.IsAny<string>()))
.Callback<string, string>((p, t) => _configFileContents = t); .Callback<string, string>((p, t) => _configFileContents = t);
Mocker.GetMock<IOptions<AuthOptions>>()
.Setup(v => v.Value)
.Returns(new AuthOptions());
Mocker.GetMock<IOptions<AppOptions>>()
.Setup(v => v.Value)
.Returns(new AppOptions());
Mocker.GetMock<IOptions<ServerOptions>>()
.Setup(v => v.Value)
.Returns(new ServerOptions());
Mocker.GetMock<IOptions<LogOptions>>()
.Setup(v => v.Value)
.Returns(new LogOptions());
Mocker.GetMock<IOptions<UpdateOptions>>()
.Setup(v => v.Value)
.Returns(new UpdateOptions());
} }
[Test] [Test]

View File

@ -4,6 +4,7 @@ using System.Linq;
using FluentAssertions; using FluentAssertions;
using NLog; using NLog;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Instrumentation.Sentry; using NzbDrone.Common.Instrumentation.Sentry;
using NzbDrone.Test.Common; using NzbDrone.Test.Common;
@ -43,7 +44,7 @@ namespace NzbDrone.Common.Test.InstrumentationTests
[SetUp] [SetUp]
public void Setup() public void Setup()
{ {
_subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111"); _subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111", Mocker.GetMock<IAppFolderInfo>().Object);
} }
private LogEventInfo GivenLogEvent(LogLevel level, Exception ex, string message) private LogEventInfo GivenLogEvent(LogLevel level, Exception ex, string message)

View File

@ -10,6 +10,7 @@ using NUnit.Framework;
using NzbDrone.Common.Composition.Extensions; using NzbDrone.Common.Composition.Extensions;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Common.Options;
using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore;
using NzbDrone.Core.Datastore.Extensions; using NzbDrone.Core.Datastore.Extensions;
using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Lifecycle;
@ -29,10 +30,16 @@ namespace NzbDrone.Common.Test
.AddNzbDroneLogger() .AddNzbDroneLogger()
.AutoAddServices(Bootstrap.ASSEMBLIES) .AutoAddServices(Bootstrap.ASSEMBLIES)
.AddDummyDatabase() .AddDummyDatabase()
.AddDummyLogDatabase()
.AddStartupContext(new StartupContext("first", "second")); .AddStartupContext(new StartupContext("first", "second"));
container.RegisterInstance(new Mock<IHostLifetime>().Object); container.RegisterInstance(new Mock<IHostLifetime>().Object);
container.RegisterInstance(new Mock<IOptions<PostgresOptions>>().Object); container.RegisterInstance(new Mock<IOptions<PostgresOptions>>().Object);
container.RegisterInstance(new Mock<IOptions<AppOptions>>().Object);
container.RegisterInstance(new Mock<IOptions<AuthOptions>>().Object);
container.RegisterInstance(new Mock<IOptions<ServerOptions>>().Object);
container.RegisterInstance(new Mock<IOptions<LogOptions>>().Object);
container.RegisterInstance(new Mock<IOptions<UpdateOptions>>().Object);
var serviceProvider = container.GetServiceProvider(); var serviceProvider = container.GetServiceProvider();

View File

@ -41,7 +41,7 @@ namespace NzbDrone.Common.Instrumentation
RegisterDebugger(); RegisterDebugger();
} }
RegisterSentry(updateApp); RegisterSentry(updateApp, appFolderInfo);
if (updateApp) if (updateApp)
{ {
@ -62,7 +62,7 @@ namespace NzbDrone.Common.Instrumentation
LogManager.ReconfigExistingLoggers(); LogManager.ReconfigExistingLoggers();
} }
private static void RegisterSentry(bool updateClient) private static void RegisterSentry(bool updateClient, IAppFolderInfo appFolderInfo)
{ {
string dsn; string dsn;
@ -77,7 +77,7 @@ namespace NzbDrone.Common.Instrumentation
: "https://998b4673d4c849ccb5277b5966ed5bc2@sentry.servarr.com/10"; : "https://998b4673d4c849ccb5277b5966ed5bc2@sentry.servarr.com/10";
} }
var target = new SentryTarget(dsn) var target = new SentryTarget(dsn, appFolderInfo)
{ {
Name = "sentryTarget", Name = "sentryTarget",
Layout = "${message}" Layout = "${message}"

View File

@ -10,6 +10,7 @@ using NLog.Common;
using NLog.Targets; using NLog.Targets;
using Npgsql; using Npgsql;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using Sentry; using Sentry;
namespace NzbDrone.Common.Instrumentation.Sentry namespace NzbDrone.Common.Instrumentation.Sentry
@ -105,7 +106,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
public bool FilterEvents { get; set; } public bool FilterEvents { get; set; }
public bool SentryEnabled { get; set; } public bool SentryEnabled { get; set; }
public SentryTarget(string dsn) public SentryTarget(string dsn, IAppFolderInfo appFolderInfo)
{ {
_sdk = SentrySdk.Init(o => _sdk = SentrySdk.Init(o =>
{ {
@ -113,9 +114,33 @@ namespace NzbDrone.Common.Instrumentation.Sentry
o.AttachStacktrace = true; o.AttachStacktrace = true;
o.MaxBreadcrumbs = 200; o.MaxBreadcrumbs = 200;
o.Release = $"{BuildInfo.AppName}@{BuildInfo.Release}"; o.Release = $"{BuildInfo.AppName}@{BuildInfo.Release}";
o.BeforeSend = x => SentryCleanser.CleanseEvent(x); o.SetBeforeSend(x => SentryCleanser.CleanseEvent(x));
o.BeforeBreadcrumb = x => SentryCleanser.CleanseBreadcrumb(x); o.SetBeforeBreadcrumb(x => SentryCleanser.CleanseBreadcrumb(x));
o.Environment = BuildInfo.Branch; o.Environment = BuildInfo.Branch;
// Crash free run statistics (sends a ping for healthy and for crashes sessions)
o.AutoSessionTracking = true;
// Caches files in the event device is offline
// Sentry creates a 'sentry' sub directory, no need to concat here
o.CacheDirectoryPath = appFolderInfo.GetAppDataPath();
// default environment is production
if (!RuntimeInfo.IsProduction)
{
if (RuntimeInfo.IsDevelopment)
{
o.Environment = "development";
}
else if (RuntimeInfo.IsTesting)
{
o.Environment = "testing";
}
else
{
o.Environment = "other";
}
}
}); });
InitializeScope(); InitializeScope();
@ -133,7 +158,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
{ {
SentrySdk.ConfigureScope(scope => SentrySdk.ConfigureScope(scope =>
{ {
scope.User = new User scope.User = new SentryUser
{ {
Id = HashUtil.AnonymousToken() Id = HashUtil.AnonymousToken()
}; };
@ -336,13 +361,21 @@ namespace NzbDrone.Common.Instrumentation.Sentry
} }
} }
var level = LoggingLevelMap[logEvent.Level];
var sentryEvent = new SentryEvent(logEvent.Exception) var sentryEvent = new SentryEvent(logEvent.Exception)
{ {
Level = LoggingLevelMap[logEvent.Level], Level = level,
Logger = logEvent.LoggerName, Logger = logEvent.LoggerName,
Message = logEvent.FormattedMessage Message = logEvent.FormattedMessage
}; };
if (level is SentryLevel.Fatal && logEvent.Exception is not null)
{
// Usages of 'fatal' here indicates the process will crash. In Sentry this is represented with
// the 'unhandled' exception flag
logEvent.Exception.SetSentryMechanism("Logger.Fatal", "Logger.Fatal was called", false);
}
sentryEvent.SetExtras(extras); sentryEvent.SetExtras(extras);
sentryEvent.SetFingerprint(fingerPrint); sentryEvent.SetFingerprint(fingerPrint);

View File

@ -0,0 +1,8 @@
namespace NzbDrone.Common.Options;
public class AppOptions
{
public string InstanceName { get; set; }
public string Theme { get; set; }
public bool? LaunchBrowser { get; set; }
}

View File

@ -0,0 +1,9 @@
namespace NzbDrone.Common.Options;
public class AuthOptions
{
public string ApiKey { get; set; }
public bool? Enabled { get; set; }
public string Method { get; set; }
public string Required { get; set; }
}

View File

@ -0,0 +1,15 @@
namespace NzbDrone.Common.Options;
public class LogOptions
{
public string Level { get; set; }
public bool? FilterSentryEvents { get; set; }
public int? Rotate { get; set; }
public bool? Sql { get; set; }
public string ConsoleLevel { get; set; }
public bool? AnalyticsEnabled { get; set; }
public string SyslogServer { get; set; }
public int? SyslogPort { get; set; }
public string SyslogLevel { get; set; }
public bool? DbEnabled { get; set; }
}

View File

@ -0,0 +1,12 @@
namespace NzbDrone.Common.Options;
public class ServerOptions
{
public string UrlBase { get; set; }
public string BindAddress { get; set; }
public int? Port { get; set; }
public bool? EnableSsl { get; set; }
public int? SslPort { get; set; }
public string SslCertPath { get; set; }
public string SslCertPassword { get; set; }
}

View File

@ -0,0 +1,9 @@
namespace NzbDrone.Common.Options;
public class UpdateOptions
{
public string Mechanism { get; set; }
public bool? Automatically { get; set; }
public string ScriptPath { get; set; }
public string Branch { get; set; }
}

View File

@ -10,8 +10,8 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NLog" Version="5.2.3" /> <PackageReference Include="NLog" Version="5.2.3" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.3" /> <PackageReference Include="NLog.Extensions.Logging" Version="5.3.3" />
<PackageReference Include="Npgsql" Version="7.0.6" /> <PackageReference Include="Npgsql" Version="7.0.7" />
<PackageReference Include="Sentry" Version="3.23.1" /> <PackageReference Include="Sentry" Version="4.0.2" />
<PackageReference Include="NLog.Targets.Syslog" Version="7.0.0" /> <PackageReference Include="NLog.Targets.Syslog" Version="7.0.0" />
<PackageReference Include="SharpZipLib" Version="1.3.3" /> <PackageReference Include="SharpZipLib" Version="1.3.3" />
<PackageReference Include="System.Text.Json" Version="6.0.9" /> <PackageReference Include="System.Text.Json" Version="6.0.9" />

View File

@ -0,0 +1,71 @@
using System.Collections.Generic;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Movies;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.CustomFormats.Specifications.LanguageSpecification
{
[TestFixture]
public class MultiLanguageFixture : CoreTest<Core.CustomFormats.LanguageSpecification>
{
private CustomFormatInput _input;
[SetUp]
public void Setup()
{
_input = new CustomFormatInput
{
MovieInfo = Builder<ParsedMovieInfo>.CreateNew().Build(),
Movie = Builder<Movie>.CreateNew().With(m => m.MovieMetadata.Value.OriginalLanguage = Language.English).Build(),
Size = 100.Megabytes(),
Languages = new List<Language>
{
Language.English,
Language.French
},
Filename = "Movie.Title.2024"
};
}
[Test]
public void should_match_one_language()
{
Subject.Value = Language.French.Id;
Subject.Negate = false;
Subject.IsSatisfiedBy(_input).Should().BeTrue();
}
[Test]
public void should_not_match_different_language()
{
Subject.Value = Language.Spanish.Id;
Subject.Negate = false;
Subject.IsSatisfiedBy(_input).Should().BeFalse();
}
[Test]
public void should_not_match_negated_when_one_language_matches()
{
Subject.Value = Language.French.Id;
Subject.Negate = true;
Subject.IsSatisfiedBy(_input).Should().BeFalse();
}
[Test]
public void should_not_match_negated_when_all_languages_do_not_match()
{
Subject.Value = Language.Spanish.Id;
Subject.Negate = true;
Subject.IsSatisfiedBy(_input).Should().BeTrue();
}
}
}

View File

@ -0,0 +1,80 @@
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Movies;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.CustomFormats.Specifications.LanguageSpecification
{
[TestFixture]
public class OriginalLanguageFixture : CoreTest<Core.CustomFormats.LanguageSpecification>
{
private CustomFormatInput _input;
[SetUp]
public void Setup()
{
_input = new CustomFormatInput
{
MovieInfo = Builder<ParsedMovieInfo>.CreateNew().Build(),
Movie = Builder<Movie>.CreateNew().With(m => m.MovieMetadata.Value.OriginalLanguage = Language.English).Build(),
Size = 100.Megabytes(),
Languages = new List<Language>
{
Language.French
},
Filename = "Movie.Title.2024"
};
}
public void GivenLanguages(params Language[] languages)
{
_input.Languages = languages.ToList();
}
[Test]
public void should_match_same_single_language()
{
GivenLanguages(Language.English);
Subject.Value = Language.Original.Id;
Subject.Negate = false;
Subject.IsSatisfiedBy(_input).Should().BeTrue();
}
[Test]
public void should_not_match_different_single_language()
{
Subject.Value = Language.Original.Id;
Subject.Negate = false;
Subject.IsSatisfiedBy(_input).Should().BeFalse();
}
[Test]
public void should_not_match_negated_same_single_language()
{
GivenLanguages(Language.English);
Subject.Value = Language.Original.Id;
Subject.Negate = true;
Subject.IsSatisfiedBy(_input).Should().BeFalse();
}
[Test]
public void should_match_negated_different_single_language()
{
Subject.Value = Language.Original.Id;
Subject.Negate = true;
Subject.IsSatisfiedBy(_input).Should().BeTrue();
}
}
}

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