1
0
mirror of https://github.com/Radarr/Radarr.git synced 2026-03-25 17:35:35 -04:00

Compare commits

...

29 Commits

Author SHA1 Message Date
Bogdan
9ed2553883 Fixed: (RadarrImport) Treat redirects as HTTP errors 2023-06-03 21:20:31 +03:00
Bogdan
4fe5e5974e Fixed: Don't log handled exceptions in API 2023-05-31 06:46:19 +03:00
Bogdan
1ca66d0b29 Revert "Fixed: Don't log handled exceptions in API"
This reverts commit 1bf3302ec2.
2023-05-31 06:45:52 +03:00
Bogdan
4ab1cb393a Add style for default kind in ProgressBar
Fixes #8641
2023-05-31 04:00:52 +03:00
ItsME6969
fa1f07987c Fixed: Parse Multi Disk DVDs & MDVDR (#8639)
* Update QualityParser.cs

Added: Support for Multi Disk DVD releases
Added: Support for MDVDR

* Update QualityParserFixture.cs

Added: Support for Multi Disk DVD releases
Added: Support for MDVDR
2023-05-30 11:52:44 -05:00
Bogdan
b5a5530cb1 Improvements to movie index sorting fields 2023-05-30 02:12:57 +03:00
Bogdan
e0448f7213 Fixed: (UI) Show release dates when enabled 2023-05-30 02:12:57 +03:00
Weblate
8eee5a3b1d Translated using Weblate (Indonesian) [skip ci]
Currently translated at 6.7% (79 of 1175 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/id/
Translation: Servarr/Radarr
2023-05-29 21:08:39 +03:00
Bogdan
830f1aa10f Fix typo in IUpdateMediaInfo 2023-05-28 23:18:33 +03:00
Servarr
7666c7b1eb Automated API Docs update 2023-05-28 23:12:10 +03:00
JeWe37
0b4c12dd7b New: Option to Import via Script
(cherry picked from commit 9f1e2151206a077334a9c34a12a373b465752d87)
2023-05-28 22:49:23 +03:00
Qstick
53857083f2 Add a few more TMDB list filter languages
Fixes #8248
2023-05-28 13:58:34 -05:00
Servarr
ea9c77cf49 Automated API Docs update 2023-05-28 21:56:48 +03:00
Bogdan
43ed8d0c2b Add minimum length as const in ApiKeyValidationCheck
(cherry picked from commit b06269544cfa11015f3fb938fd8c2ef07d9cac4a)

Closes #8618
2023-05-28 21:39:57 +03:00
Bogdan
9df06b80bf Fixed: Enforce validation warnings
(cherry picked from commit 48ee1158ad4213fd0690842e2672f52d08f7ad26)

Closes #8628
2023-05-28 21:18:53 +03:00
Bogdan
713f984b26 Simplify ShouldHaveApiKey and HasErrors
(cherry picked from commit 7343616a47cd538bba4c9128d2c1094561f9b3a5)
2023-05-28 21:16:42 +03:00
Servarr
683d261a91 Automated API Docs update 2023-05-28 20:51:48 +03:00
Weblate
b33d9a9641 Translated using Weblate (Indonesian) [skip ci]
Currently translated at 6.5% (77 of 1173 strings)

Translated using Weblate (Croatian) [skip ci]

Currently translated at 21.9% (258 of 1173 strings)

Translated using Weblate (Ukrainian) [skip ci]

Currently translated at 98.5% (1156 of 1173 strings)

Translated using Weblate (Catalan) [skip ci]

Currently translated at 98.7% (1158 of 1173 strings)

Translated using Weblate (Arabic) [skip ci]

Currently translated at 89.2% (1047 of 1173 strings)

Translated using Weblate (Chinese (Simplified) (zh_CN)) [skip ci]

Currently translated at 99.4% (1166 of 1173 strings)

Translated using Weblate (Thai) [skip ci]

Currently translated at 88.7% (1041 of 1173 strings)

Translated using Weblate (Bulgarian) [skip ci]

Currently translated at 86.7% (1017 of 1173 strings)

Translated using Weblate (Hindi) [skip ci]

Currently translated at 88.7% (1041 of 1173 strings)

Translated using Weblate (Romanian) [skip ci]

Currently translated at 86.4% (1014 of 1173 strings)

Translated using Weblate (Vietnamese) [skip ci]

Currently translated at 88.7% (1041 of 1173 strings)

Translated using Weblate (Turkish) [skip ci]

Currently translated at 88.5% (1039 of 1173 strings)

Translated using Weblate (Swedish) [skip ci]

Currently translated at 89.5% (1051 of 1173 strings)

Translated using Weblate (Russian) [skip ci]

Currently translated at 98.7% (1158 of 1173 strings)

Translated using Weblate (Portuguese) [skip ci]

Currently translated at 98.7% (1158 of 1173 strings)

Translated using Weblate (Polish) [skip ci]

Currently translated at 97.6% (1145 of 1173 strings)

Translated using Weblate (Dutch) [skip ci]

Currently translated at 94.9% (1114 of 1173 strings)

Translated using Weblate (Japanese) [skip ci]

Currently translated at 88.7% (1041 of 1173 strings)

Translated using Weblate (Icelandic) [skip ci]

Currently translated at 88.7% (1041 of 1173 strings)

Translated using Weblate (Hungarian) [skip ci]

Currently translated at 98.7% (1158 of 1173 strings)

Translated using Weblate (Hebrew) [skip ci]

Currently translated at 91.7% (1076 of 1173 strings)

Translated using Weblate (Finnish) [skip ci]

Currently translated at 98.5% (1156 of 1173 strings)

Translated using Weblate (Greek) [skip ci]

Currently translated at 98.7% (1158 of 1173 strings)

Translated using Weblate (Danish) [skip ci]

Currently translated at 88.7% (1041 of 1173 strings)

Translated using Weblate (Czech) [skip ci]

Currently translated at 89.5% (1051 of 1173 strings)

Translated using Weblate (Italian) [skip ci]

Currently translated at 94.3% (1107 of 1173 strings)

Translated using Weblate (Spanish) [skip ci]

Currently translated at 98.8% (1159 of 1173 strings)

Translated using Weblate (German) [skip ci]

Currently translated at 99.0% (1162 of 1173 strings)

Translated using Weblate (Polish) [skip ci]

Currently translated at 97.4% (1143 of 1173 strings)

Translated using Weblate (Dutch) [skip ci]

Currently translated at 94.8% (1113 of 1173 strings)

Update translation files  [skip ci]

Updated by "Remove blank strings" hook in Weblate.

Translated using Weblate (Polish) [skip ci]

Currently translated at 97.3% (1142 of 1173 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Qstick <qstick@gmail.com>
Co-authored-by: Thijs Waalen <contact@thijswaalen.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: wgarbaczow <wladimir.garbaczow@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/el/
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/he/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/id/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/is/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ja/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/sv/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/th/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/vi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr
2023-05-28 12:41:15 -05:00
Qstick
c69cc20266 Cleanup react warnings from manage modals
(cherry picked from commit efab1b0793a7d990889a03703b9d9755c4741313)
2023-05-28 12:35:27 -05:00
Qstick
4fc1ee0aff Fix priority input for provider manage edit modal
(cherry picked from commit 593652a84a45aea7b542e682c08453f4ee88871c)
2023-05-28 12:35:27 -05:00
Qstick
1d4b6d4cad New: Bulk Manage Import Lists, Indexers, Clients
(cherry picked from commit 73ccab53d5194282de4b983354c9afa5a6d678fb)
2023-05-28 12:35:27 -05:00
ItsME6969
5baeba18cb Fixed: Correctly parse SCENE and P2P BluRay DISKS releases (#8595)
* Update QualityParserFixture.cs

Added Releases following P2P naming convention

* Update QualityParser.cs

Added support for P2P BR-DISK naming convention

* Update src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs

Co-authored-by: Bogdan <mynameisbogdan@users.noreply.github.com>

* Update QualityParserFixture.cs

---------

Co-authored-by: Bogdan <mynameisbogdan@users.noreply.github.com>
2023-05-28 12:26:48 -05:00
Matt Strapp
854b3045fe Fix: Use relative paths instead of absolute paths for webmanifest 2023-05-28 12:04:39 -05:00
Qstick
6b80c244bf Bump version to 4.6.0 2023-05-28 09:24:13 -05:00
Stepan Goremykin
044de922fa Use Array.Empty and fix a few multiple enumerations
(cherry picked from commit 11d91faaada0e70910c832ce405ddeed52a24172)
2023-05-28 16:43:46 +03:00
Bogdan
c987824174 Use 'var' instead of explicit type
(cherry picked from commit 12374f7f0038e5b25548f5ab3f71122410832393)
2023-05-28 16:41:52 +03:00
Bogdan
8762588ef0 Inline 'out' variable declarations
(cherry picked from commit 281add47de1d3940990156c841362125dea9cc7d)
2023-05-28 16:41:52 +03:00
Bogdan
ed68a944ea Standardize variable declaration
(cherry picked from commit 909f2ded6b75998fa8e1addd0dcf849279e7b120)
2023-05-28 16:41:52 +03:00
Bogdan
52c64080f2 Enforce rule IDE0005 on build
(cherry picked from commit 6b1e4ef81938d264a2ddc8b626b0502f799aa640)
2023-05-28 16:41:52 +03:00
294 changed files with 4508 additions and 624 deletions

View File

@@ -40,12 +40,18 @@ dotnet_naming_style.instance_field_style.capitalization = camel_case
dotnet_naming_style.instance_field_style.required_prefix = _
# Prefer "var" everywhere
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
csharp_style_var_for_built_in_types = true
csharp_style_var_when_type_is_apparent = true
csharp_style_var_elsewhere = true
# Prefer "out" variables to be declared inline
csharp_style_inlined_variable_declaration = true
# Using directive is unnecessary.
dotnet_diagnostic.IDE0005.severity = error
# Use var instead of explicit type
dotnet_diagnostic.IDE0007.severity = error
# Inline variable declaration
dotnet_diagnostic.IDE0018.severity = error
# Stylecop Rules
dotnet_diagnostic.SA0001.severity = none

View File

@@ -9,7 +9,7 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '4.5.2'
majorVersion: '4.6.0'
minorVersion: $[counter('minorVersion', 2000)]
radarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(radarrVersion)'

View File

@@ -1,14 +1,33 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
AppSectionSchemaState,
} from 'App/State/AppSectionState';
import Language from 'Language/Language';
import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList';
import Indexer from 'typings/Indexer';
import Notification from 'typings/Notification';
import QualityProfile from 'typings/QualityProfile';
import { UiSettings } from 'typings/UiSettings';
export interface DownloadClientAppState
extends AppSectionState<DownloadClient>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface ImportListAppState
extends AppSectionState<ImportList>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface IndexerAppState
extends AppSectionState<Indexer>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface NotificationAppState
extends AppSectionState<Notification>,
AppSectionDeleteState {}
export interface QualityProfilesAppState
@@ -20,6 +39,9 @@ export type UiSettingsAppState = AppSectionState<UiSettings>;
interface SettingsAppState {
downloadClients: DownloadClientAppState;
importLists: ImportListAppState;
indexers: IndexerAppState;
notifications: NotificationAppState;
language: LanguageSettingsAppState;
uiSettings: UiSettingsAppState;
qualityProfiles: QualityProfilesAppState;

View File

@@ -265,6 +265,8 @@ FormInputGroup.propTypes = {
values: PropTypes.arrayOf(PropTypes.any),
type: PropTypes.string.isRequired,
kind: PropTypes.oneOf(kinds.all),
min: PropTypes.number,
max: PropTypes.number,
unit: PropTypes.string,
buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
helpText: PropTypes.string,

View File

@@ -10,7 +10,7 @@ function parseValue(props, value) {
} = props;
if (value == null || value === '') {
return min;
return null;
}
let newValue = isFloat ? parseFloat(value) : parseInt(value);

View File

@@ -45,6 +45,10 @@
cursor: default;
}
.default {
background-color: var(--darkGray);
}
.primary {
background-color: var(--primaryColor);
}

View File

@@ -5,6 +5,7 @@ interface CssExports {
'backTextContainer': string;
'container': string;
'danger': string;
'default': string;
'frontText': string;
'frontTextContainer': string;
'info': string;

View File

@@ -2,16 +2,17 @@
"name": "Radarr",
"icons": [
{
"src": "/Content/Images/Icons/android-chrome-192x192.png",
"src": "android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/Content/Images/Icons/android-chrome-512x512.png",
"src": "android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "../../../../",
"theme_color": "#3a3f51",
"background_color": "#3a3f51",
"display": "standalone"

View File

@@ -71,6 +71,7 @@ import {
faLanguage as fasLanguage,
faLaptop as fasLaptop,
faLevelUpAlt as fasLevelUpAlt,
faListCheck as fasListCheck,
faMedkit as fasMedkit,
faMinus as fasMinus,
faPause as fasPause,
@@ -172,6 +173,7 @@ export const INFO = fasInfoCircle;
export const INTERACTIVE = fasUser;
export const KEYBOARD = farKeyboard;
export const LOGOUT = fasSignOutAlt;
export const MANAGE = fasListCheck;
export const MEDIA_INFO = farFileInvoice;
export const MISSING = fasExclamationTriangle;
export const MONITORED = fasBookmark;

View File

@@ -16,6 +16,7 @@ import MovieIndexPosterSelect from 'Movie/Index/Select/MovieIndexPosterSelect';
import MoviePoster from 'Movie/MoviePoster';
import { executeCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import translate from 'Utilities/String/translate';
import createMovieIndexItemSelector from '../createMovieIndexItemSelector';
import MovieIndexPosterInfo from './MovieIndexPosterInfo';
@@ -46,9 +47,8 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
showSearchAction,
} = useSelector(selectPosterOptions);
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } =
useSelector(createUISettingsSelector());
const {
title,
@@ -62,12 +62,17 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
isAvailable,
studio,
added,
year,
inCinemas,
physicalRelease,
digitalRelease,
path,
movieFile,
ratings,
sizeOnDisk,
certification,
originalTitle,
originalLanguage,
} = movie;
const dispatch = useDispatch();
@@ -125,6 +130,20 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
height: `${posterHeight}px`,
};
let releaseDate = '';
let releaseDateType = '';
if (physicalRelease && digitalRelease) {
releaseDate =
physicalRelease < digitalRelease ? physicalRelease : digitalRelease;
releaseDateType = physicalRelease < digitalRelease ? 'Released' : 'Digital';
} else if (physicalRelease && !digitalRelease) {
releaseDate = physicalRelease;
releaseDateType = 'Released';
} else if (digitalRelease && !physicalRelease) {
releaseDate = digitalRelease;
releaseDateType = 'Digital';
}
return (
<div className={styles.content}>
<div className={styles.posterContainer} title={title}>
@@ -211,25 +230,55 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
) : null}
{showQualityProfile ? (
<div className={styles.title}>{qualityProfile.name}</div>
<div className={styles.title} title={translate('QualityProfile')}>
{qualityProfile.name}
</div>
) : null}
{showCinemaRelease && inCinemas ? (
<div className={styles.title} title={translate('InCinemas')}>
<Icon name={icons.IN_CINEMAS} />{' '}
{getRelativeDate(inCinemas, shortDateFormat, showRelativeDates, {
timeFormat,
timeForToday: false,
})}
</div>
) : null}
{showReleaseDate && releaseDate ? (
<div className={styles.title}>
<Icon
name={releaseDateType === 'Digital' ? icons.MOVIE_FILE : icons.DISC}
/>{' '}
{getRelativeDate(releaseDate, shortDateFormat, showRelativeDates, {
timeFormat,
timeForToday: false,
})}
</div>
) : null}
<MovieIndexPosterInfo
studio={studio}
qualityProfile={qualityProfile}
added={added}
year={year}
showQualityProfile={showQualityProfile}
showCinemaRelease={showCinemaRelease}
showReleaseDate={showReleaseDate}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
inCinemas={inCinemas}
physicalRelease={physicalRelease}
digitalRelease={digitalRelease}
ratings={ratings}
sizeOnDisk={sizeOnDisk}
sortKey={sortKey}
path={path}
certification={certification}
originalTitle={originalTitle}
originalLanguage={originalLanguage}
/>
<EditMovieModalConnector

View File

@@ -3,3 +3,9 @@
text-align: center;
font-size: $smallFontSize;
}
.title {
@add-mixin truncate;
composes: info;
}

View File

@@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'info': string;
'title': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -1,7 +1,11 @@
import React from 'react';
import Icon from 'Components/Icon';
import ImdbRating from 'Components/ImdbRating';
import TmdbRating from 'Components/TmdbRating';
import { icons } from 'Helpers/Props';
import { Language, Ratings } from 'Movie/Movie';
import QualityProfile from 'typings/QualityProfile';
import formatDateTime from 'Utilities/Date/formatDateTime';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
@@ -12,17 +16,22 @@ interface MovieIndexPosterInfoProps {
showQualityProfile: boolean;
qualityProfile: QualityProfile;
added?: string;
year: number;
inCinemas?: string;
digitalRelease?: string;
physicalRelease?: string;
path: string;
ratings: Ratings;
certification: string;
originalTitle: string;
originalLanguage: Language;
sizeOnDisk?: number;
sortKey: string;
showRelativeDates: boolean;
showCinemaRelease: boolean;
showReleaseDate: boolean;
shortDateFormat: string;
longDateFormat: string;
timeFormat: string;
}
@@ -32,26 +41,39 @@ function MovieIndexPosterInfo(props: MovieIndexPosterInfoProps) {
showQualityProfile,
qualityProfile,
added,
year,
inCinemas,
digitalRelease,
physicalRelease,
path,
ratings,
certification,
originalTitle,
originalLanguage,
sizeOnDisk,
sortKey,
showRelativeDates,
showCinemaRelease,
showReleaseDate,
shortDateFormat,
longDateFormat,
timeFormat,
} = props;
if (sortKey === 'studio' && studio) {
return <div className={styles.info}>{studio}</div>;
return (
<div className={styles.info} title={translate('Studio')}>
{studio}
</div>
);
}
if (sortKey === 'qualityProfileId' && !showQualityProfile) {
return <div className={styles.info}>{qualityProfile.name}</div>;
return (
<div className={styles.info} title={translate('QualityProfile')}>
{qualityProfile.name}
</div>
);
}
if (sortKey === 'added' && added) {
@@ -66,13 +88,24 @@ function MovieIndexPosterInfo(props: MovieIndexPosterInfoProps) {
);
return (
<div className={styles.info}>
<div
className={styles.info}
title={formatDateTime(added, longDateFormat, timeFormat)}
>
{translate('Added')}: {addedDate}
</div>
);
}
if (sortKey === 'inCinemas' && inCinemas && showCinemaRelease) {
if (sortKey === 'year' && year) {
return (
<div className={styles.info} title={translate('Year')}>
{year}
</div>
);
}
if (sortKey === 'inCinemas' && inCinemas && !showCinemaRelease) {
const inCinemasDate = getRelativeDate(
inCinemas,
shortDateFormat,
@@ -84,13 +117,13 @@ function MovieIndexPosterInfo(props: MovieIndexPosterInfoProps) {
);
return (
<div className={styles.info}>
<div className={styles.info} title={translate('InCinemas')}>
<Icon name={icons.IN_CINEMAS} /> {inCinemasDate}
</div>
);
}
if (sortKey === 'digitalRelease' && digitalRelease && showReleaseDate) {
if (sortKey === 'digitalRelease' && digitalRelease && !showReleaseDate) {
const digitalReleaseDate = getRelativeDate(
digitalRelease,
shortDateFormat,
@@ -108,7 +141,7 @@ function MovieIndexPosterInfo(props: MovieIndexPosterInfoProps) {
);
}
if (sortKey === 'physicalRelease' && physicalRelease && showReleaseDate) {
if (sortKey === 'physicalRelease' && physicalRelease && !showReleaseDate) {
const physicalReleaseDate = getRelativeDate(
physicalRelease,
shortDateFormat,
@@ -126,18 +159,58 @@ function MovieIndexPosterInfo(props: MovieIndexPosterInfoProps) {
);
}
if (sortKey === 'imdbRating' && !!ratings.imdb) {
return (
<div className={styles.info}>
<ImdbRating ratings={ratings} iconSize={12} />
</div>
);
}
if (sortKey === 'tmdbRating' && !!ratings.tmdb) {
return (
<div className={styles.info}>
<TmdbRating ratings={ratings} iconSize={12} />
</div>
);
}
if (sortKey === 'path') {
return <div className={styles.info}>{path}</div>;
return (
<div className={styles.info} title={translate('Path')}>
{path}
</div>
);
}
if (sortKey === 'sizeOnDisk') {
return <div className={styles.info}>{formatBytes(sizeOnDisk)}</div>;
return (
<div className={styles.info} title={translate('SizeOnDisk')}>
{formatBytes(sizeOnDisk)}
</div>
);
}
if (sortKey === 'certification') {
return <div className={styles.info}>{certification}</div>;
}
if (sortKey === 'originalTitle' && originalTitle) {
return (
<div className={styles.title} title={originalTitle}>
{originalTitle}
</div>
);
}
if (sortKey === 'originalLanguage' && originalLanguage) {
return (
<div className={styles.info} title={translate('OriginalLanguage')}>
{originalLanguage.name}
</div>
);
}
return null;
}

View File

@@ -143,6 +143,7 @@ export default function MovieIndexPosters(props: MovieIndexPostersProps) {
showTitle,
showMonitored,
showQualityProfile,
showCinemaRelease,
showReleaseDate,
} = posterOptions;
@@ -167,6 +168,10 @@ export default function MovieIndexPosters(props: MovieIndexPostersProps) {
heights.push(19);
}
if (showCinemaRelease) {
heights.push(19);
}
if (showReleaseDate) {
heights.push(19);
}
@@ -174,8 +179,13 @@ export default function MovieIndexPosters(props: MovieIndexPostersProps) {
switch (sortKey) {
case 'studio':
case 'added':
case 'year':
case 'imdbRating':
case 'tmdbRating':
case 'path':
case 'sizeOnDisk':
case 'originalTitle':
case 'originalLanguage':
heights.push(19);
break;
case 'qualityProfileId':
@@ -183,6 +193,17 @@ export default function MovieIndexPosters(props: MovieIndexPostersProps) {
heights.push(19);
}
break;
case 'inCinemas':
if (!showCinemaRelease) {
heights.push(19);
}
break;
case 'digitalRelease':
case 'physicalRelease':
if (!showReleaseDate) {
heights.push(19);
}
break;
default:
// No need to add a height of 0
}

View File

@@ -16,6 +16,13 @@ export interface Collection {
title: string;
}
export interface Ratings {
imdb: object;
tmdb: object;
metacritic: object;
rottenTomatoes: object;
}
interface Movie extends ModelBase {
tmdbId: number;
imdbId: string;
@@ -41,7 +48,7 @@ interface Movie extends ModelBase {
path: string;
sizeOnDisk: number;
genres: string[];
ratings: object;
ratings: Ratings;
certification: string;
tags: number[];
images: Image[];

View File

@@ -8,6 +8,7 @@ import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector';
import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal';
import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector';
import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector';
@@ -23,7 +24,8 @@ class DownloadClientSettings extends Component {
this.state = {
isSaving: false,
hasPendingChanges: false
hasPendingChanges: false,
isManageDownloadClientsOpen: false
};
}
@@ -38,6 +40,14 @@ class DownloadClientSettings extends Component {
this.setState(payload);
};
onManageDownloadClientsPress = () => {
this.setState({ isManageDownloadClientsOpen: true });
};
onManageDownloadClientsModalClose = () => {
this.setState({ isManageDownloadClientsOpen: false });
};
onSavePress = () => {
if (this._saveCallback) {
this._saveCallback();
@@ -55,7 +65,8 @@ class DownloadClientSettings extends Component {
const {
isSaving,
hasPendingChanges
hasPendingChanges,
isManageDownloadClientsOpen
} = this.state;
return (
@@ -73,6 +84,12 @@ class DownloadClientSettings extends Component {
isSpinning={isTestingAll}
onPress={dispatchTestAllDownloadClients}
/>
<PageToolbarButton
label="Manage Clients"
iconName={icons.MANAGE}
onPress={this.onManageDownloadClientsPress}
/>
</Fragment>
}
onSavePress={this.onSavePress}
@@ -87,6 +104,11 @@ class DownloadClientSettings extends Component {
/>
<RemotePathMappingsConnector />
<ManageDownloadClientsModal
isOpen={isManageDownloadClientsOpen}
onModalClose={this.onManageDownloadClientsModalClose}
/>
</PageContentBody>
</PageContent>
);

View File

@@ -0,0 +1,28 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageDownloadClientsEditModalContent from './ManageDownloadClientsEditModalContent';
interface ManageDownloadClientsEditModalProps {
isOpen: boolean;
downloadClientIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
function ManageDownloadClientsEditModal(
props: ManageDownloadClientsEditModalProps
) {
const { isOpen, downloadClientIds, onSavePress, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageDownloadClientsEditModalContent
downloadClientIds={downloadClientIds}
onSavePress={onSavePress}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default ManageDownloadClientsEditModal;

View File

@@ -0,0 +1,16 @@
.modalFooter {
composes: modalFooter from '~Components/Modal/ModalFooter.css';
justify-content: space-between;
}
.selected {
font-weight: bold;
}
@media only screen and (max-width: $breakpointExtraSmall) {
.modalFooter {
flex-direction: column;
gap: 10px;
}
}

View File

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

View File

@@ -0,0 +1,180 @@
import React, { useCallback, useState } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ManageDownloadClientsEditModalContent.css';
interface SavePayload {
enable?: boolean;
removeCompletedDownloads?: boolean;
removeFailedDownloads?: boolean;
priority?: number;
}
interface ManageDownloadClientsEditModalContentProps {
downloadClientIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
const NO_CHANGE = 'noChange';
const enableOptions = [
{ key: NO_CHANGE, value: 'No Change', disabled: true },
{ key: 'enabled', value: 'Enabled' },
{ key: 'disabled', value: 'Disabled' },
];
function ManageDownloadClientsEditModalContent(
props: ManageDownloadClientsEditModalContentProps
) {
const { downloadClientIds, onSavePress, onModalClose } = props;
const [enable, setEnable] = useState(NO_CHANGE);
const [removeCompletedDownloads, setRemoveCompletedDownloads] =
useState(NO_CHANGE);
const [removeFailedDownloads, setRemoveFailedDownloads] = useState(NO_CHANGE);
const [priority, setPriority] = useState<null | string | number>(null);
const save = useCallback(() => {
let hasChanges = false;
const payload: SavePayload = {};
if (enable !== NO_CHANGE) {
hasChanges = true;
payload.enable = enable === 'enabled';
}
if (removeCompletedDownloads !== NO_CHANGE) {
hasChanges = true;
payload.removeCompletedDownloads = removeCompletedDownloads === 'enabled';
}
if (removeFailedDownloads !== NO_CHANGE) {
hasChanges = true;
payload.removeFailedDownloads = removeFailedDownloads === 'enabled';
}
if (priority !== null) {
hasChanges = true;
payload.priority = priority as number;
}
if (hasChanges) {
onSavePress(payload);
}
onModalClose();
}, [
enable,
priority,
removeCompletedDownloads,
removeFailedDownloads,
onSavePress,
onModalClose,
]);
const onInputChange = useCallback(
({ name, value }: { name: string; value: string }) => {
switch (name) {
case 'enable':
setEnable(value);
break;
case 'priority':
setPriority(value);
break;
case 'removeCompletedDownloads':
setRemoveCompletedDownloads(value);
break;
case 'removeFailedDownloads':
setRemoveFailedDownloads(value);
break;
default:
console.warn('EditDownloadClientsModalContent Unknown Input');
}
},
[]
);
const selectedCount = downloadClientIds.length;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('EditSelectedDownloadClients')}</ModalHeader>
<ModalBody>
<FormGroup>
<FormLabel>{translate('Enabled')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="enable"
value={enable}
values={enableOptions}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Priority')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="priority"
value={priority}
min={1}
max={50}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RemoveCompletedDownloads')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="removeCompletedDownloads"
value={removeCompletedDownloads}
values={enableOptions}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RemoveFailedDownloads')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="removeFailedDownloads"
value={removeFailedDownloads}
values={enableOptions}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('{count} download clients selected', {
count: selectedCount,
})}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={save}>{translate('Apply Changes')}</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
export default ManageDownloadClientsEditModalContent;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageDownloadClientsModalContent from './ManageDownloadClientsModalContent';
interface ManageDownloadClientsModalProps {
isOpen: boolean;
onModalClose(): void;
}
function ManageDownloadClientsModal(props: ManageDownloadClientsModalProps) {
const { isOpen, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageDownloadClientsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default ManageDownloadClientsModal;

View File

@@ -0,0 +1,16 @@
.leftButtons,
.rightButtons {
display: flex;
flex: 1 0 50%;
flex-wrap: wrap;
}
.rightButtons {
justify-content: flex-end;
}
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: 10px;
}

View File

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

View File

@@ -0,0 +1,241 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { DownloadClientAppState } from 'App/State/SettingsAppState';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import {
bulkDeleteDownloadClients,
bulkEditDownloadClients,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import ManageDownloadClientsEditModal from './Edit/ManageDownloadClientsEditModal';
import ManageDownloadClientsModalRow from './ManageDownloadClientsModalRow';
import styles from './ManageDownloadClientsModalContent.css';
// TODO: This feels janky to do, but not sure of a better way currently
type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageDownloadClientsModalRow
>['onSelectedChange'];
const COLUMNS = [
{
name: 'name',
label: 'Name',
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: 'Implementation',
isSortable: true,
isVisible: true,
},
{
name: 'enable',
label: 'Enabled',
isSortable: true,
isVisible: true,
},
{
name: 'priority',
label: 'Priority',
isSortable: true,
isVisible: true,
},
{
name: 'removeCompletedDownloads',
label: 'Remove Completed',
isSortable: true,
isVisible: true,
},
{
name: 'removeFailedDownloads',
label: 'Remove Failed',
isSortable: true,
isVisible: true,
},
];
interface ManageDownloadClientsModalContentProps {
onModalClose(): void;
}
function ManageDownloadClientsModalContent(
props: ManageDownloadClientsModalContentProps
) {
const { onModalClose } = props;
const {
isFetching,
isPopulated,
isDeleting,
isSaving,
error,
items,
}: DownloadClientAppState = useSelector(
createClientSideCollectionSelector('settings.downloadClients')
);
const dispatch = useDispatch();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [selectState, setSelectState] = useSelectState();
const { allSelected, allUnselected, selectedState } = selectState;
const selectedIds: number[] = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const selectedCount = selectedIds.length;
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
const onDeleteModalClose = useCallback(() => {
setIsDeleteModalOpen(false);
}, [setIsDeleteModalOpen]);
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onConfirmDelete = useCallback(() => {
dispatch(bulkDeleteDownloadClients({ ids: selectedIds }));
setIsDeleteModalOpen(false);
}, [selectedIds, dispatch]);
const onSavePress = useCallback(
(payload: object) => {
setIsEditModalOpen(false);
dispatch(
bulkEditDownloadClients({
ids: selectedIds,
...payload,
})
);
},
[selectedIds, dispatch]
);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const onSelectedChange = useCallback<OnSelectedChangeCallback>(
({ id, value, shiftKey = false }) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
);
const errorMessage = getErrorMessage(error, 'Unable to load import lists.');
const anySelected = selectedCount > 0;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Manage Import Lists</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table
columns={COLUMNS}
horizontalScroll={true}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
>
<TableBody>
{items.map((item) => {
return (
<ManageDownloadClientsModalRow
key={item.id}
isSelected={selectedState[item.id]}
{...item}
columns={COLUMNS}
onSelectedChange={onSelectedChange}
/>
);
})}
</TableBody>
</Table>
) : null}
</ModalBody>
<ModalFooter>
<div className={styles.leftButtons}>
<SpinnerButton
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!anySelected}
onPress={onDeletePress}
>
Delete
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving}
isDisabled={!anySelected}
onPress={onEditPress}
>
Edit
</SpinnerButton>
</div>
<Button onPress={onModalClose}>Close</Button>
</ModalFooter>
<ManageDownloadClientsEditModal
isOpen={isEditModalOpen}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
downloadClientIds={selectedIds}
/>
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title="Delete Download Clients(s)"
message={`Are you sure you want to delete ${selectedIds.length} download clients(s)?`}
confirmLabel="Delete"
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>
</ModalContent>
);
}
export default ManageDownloadClientsModalContent;

View File

@@ -0,0 +1,11 @@
.name,
.enable,
.tags,
.priority,
.removeCompletedDownloads,
.removeFailedDownloads,
.implementation {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
word-break: break-all;
}

View File

@@ -0,0 +1,13 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'enable': string;
'implementation': string;
'name': string;
'priority': string;
'removeCompletedDownloads': string;
'removeFailedDownloads': string;
'tags': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,77 @@
import React, { useCallback } from 'react';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import { SelectStateInputProps } from 'typings/props';
import styles from './ManageDownloadClientsModalRow.css';
interface ManageDownloadClientsModalRowProps {
id: number;
name: string;
enable: boolean;
priority: number;
removeCompletedDownloads: boolean;
removeFailedDownloads: boolean;
implementation: string;
columns: Column[];
isSelected?: boolean;
onSelectedChange(result: SelectStateInputProps): void;
}
function ManageDownloadClientsModalRow(
props: ManageDownloadClientsModalRowProps
) {
const {
id,
isSelected,
name,
enable,
priority,
removeCompletedDownloads,
removeFailedDownloads,
implementation,
onSelectedChange,
} = props;
const onSelectedChangeWrapper = useCallback(
(result: SelectStateInputProps) => {
onSelectedChange({
...result,
});
},
[onSelectedChange]
);
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChangeWrapper}
/>
<TableRowCell className={styles.name}>{name}</TableRowCell>
<TableRowCell className={styles.implementation}>
{implementation}
</TableRowCell>
<TableRowCell className={styles.enable}>
{enable ? 'Yes' : 'No'}
</TableRowCell>
<TableRowCell className={styles.priority}>{priority}</TableRowCell>
<TableRowCell className={styles.removeCompletedDownloads}>
{removeCompletedDownloads ? 'Yes' : 'No'}
</TableRowCell>
<TableRowCell className={styles.removeFailedDownloads}>
{removeFailedDownloads ? 'Yes' : 'No'}
</TableRowCell>
</TableRow>
);
}
export default ManageDownloadClientsModalRow;

View File

@@ -9,6 +9,7 @@ import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import ImportListExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector';
import ImportListsConnector from './ImportLists/ImportListsConnector';
import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal';
import ImportListOptionsConnector from './Options/ImportListOptionsConnector';
class ImportListSettings extends Component {
@@ -23,7 +24,8 @@ class ImportListSettings extends Component {
this.state = {
isSaving: false,
hasPendingChanges: false
hasPendingChanges: false,
isManageImportListsOpen: false
};
}
@@ -38,6 +40,14 @@ class ImportListSettings extends Component {
this.setState(payload);
};
onManageImportListsPress = () => {
this.setState({ isManageImportListsOpen: true });
};
onManageImportListsModalClose = () => {
this.setState({ isManageImportListsOpen: false });
};
onSavePress = () => {
if (this._saveCallback) {
this._saveCallback();
@@ -55,7 +65,8 @@ class ImportListSettings extends Component {
const {
isSaving,
hasPendingChanges
hasPendingChanges,
isManageImportListsOpen
} = this.state;
return (
@@ -73,6 +84,12 @@ class ImportListSettings extends Component {
isSpinning={isTestingAll}
onPress={dispatchTestAllImportList}
/>
<PageToolbarButton
label="Manage Lists"
iconName={icons.MANAGE}
onPress={this.onManageImportListsPress}
/>
</Fragment>
}
onSavePress={this.onSavePress}
@@ -88,6 +105,11 @@ class ImportListSettings extends Component {
<ImportListExclusionsConnector />
<ManageImportListsModal
isOpen={isManageImportListsOpen}
onModalClose={this.onManageImportListsModalClose}
/>
</PageContentBody>
</PageContent>
);

View File

@@ -0,0 +1,26 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageImportListsEditModalContent from './ManageImportListsEditModalContent';
interface ManageImportListsEditModalProps {
isOpen: boolean;
importListIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
function ManageImportListsEditModal(props: ManageImportListsEditModalProps) {
const { isOpen, importListIds, onSavePress, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageImportListsEditModalContent
importListIds={importListIds}
onSavePress={onSavePress}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default ManageImportListsEditModal;

View File

@@ -0,0 +1,16 @@
.modalFooter {
composes: modalFooter from '~Components/Modal/ModalFooter.css';
justify-content: space-between;
}
.selected {
font-weight: bold;
}
@media only screen and (max-width: $breakpointExtraSmall) {
.modalFooter {
flex-direction: column;
gap: 10px;
}
}

View File

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

View File

@@ -0,0 +1,152 @@
import React, { useCallback, useState } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ManageImportListsEditModalContent.css';
interface SavePayload {
enableAuto?: boolean;
qualityProfileId?: number;
rootFolderPath?: string;
}
interface ManageImportListsEditModalContentProps {
importListIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
const NO_CHANGE = 'noChange';
const autoAddOptions = [
{ key: NO_CHANGE, value: 'No Change', disabled: true },
{ key: 'enabled', value: 'Enabled' },
{ key: 'disabled', value: 'Disabled' },
];
function ManageImportListsEditModalContent(
props: ManageImportListsEditModalContentProps
) {
const { importListIds, onSavePress, onModalClose } = props;
const [enableAuto, setenableAuto] = useState(NO_CHANGE);
const [qualityProfileId, setQualityProfileId] = useState<string | number>(
NO_CHANGE
);
const [rootFolderPath, setRootFolderPath] = useState(NO_CHANGE);
const save = useCallback(() => {
let hasChanges = false;
const payload: SavePayload = {};
if (enableAuto !== NO_CHANGE) {
hasChanges = true;
payload.enableAuto = enableAuto === 'enabled';
}
if (qualityProfileId !== NO_CHANGE) {
hasChanges = true;
payload.qualityProfileId = qualityProfileId as number;
}
if (rootFolderPath !== NO_CHANGE) {
hasChanges = true;
payload.rootFolderPath = rootFolderPath;
}
if (hasChanges) {
onSavePress(payload);
}
onModalClose();
}, [enableAuto, qualityProfileId, rootFolderPath, onSavePress, onModalClose]);
const onInputChange = useCallback(
({ name, value }: { name: string; value: string }) => {
switch (name) {
case 'enableAuto':
setenableAuto(value);
break;
case 'qualityProfileId':
setQualityProfileId(value);
break;
case 'rootFolderPath':
setRootFolderPath(value);
break;
default:
console.warn('EditImportListModalContent Unknown Input');
}
},
[]
);
const selectedCount = importListIds.length;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('EditSelectedImportLists')}</ModalHeader>
<ModalBody>
<FormGroup>
<FormLabel>{translate('AutomaticAdd')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="enableAuto"
value={enableAuto}
values={autoAddOptions}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('QualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
includeNoChange={true}
includeNoChangeDisabled={false}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RootFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
value={rootFolderPath}
includeNoChange={true}
includeNoChangeDisabled={false}
selectedValueOptions={{ includeFreeSpace: false }}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('{count} import lists selected', { count: selectedCount })}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={save}>{translate('ApplyChanges')}</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
export default ManageImportListsEditModalContent;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageImportListsModalContent from './ManageImportListsModalContent';
interface ManageImportListsModalProps {
isOpen: boolean;
onModalClose(): void;
}
function ManageImportListsModal(props: ManageImportListsModalProps) {
const { isOpen, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageImportListsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default ManageImportListsModal;

View File

@@ -0,0 +1,16 @@
.leftButtons,
.rightButtons {
display: flex;
flex: 1 0 50%;
flex-wrap: wrap;
}
.rightButtons {
justify-content: flex-end;
}
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: 10px;
}

View File

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

View File

@@ -0,0 +1,283 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { ImportListAppState } from 'App/State/SettingsAppState';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import {
bulkDeleteImportLists,
bulkEditImportLists,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import ManageImportListsEditModal from './Edit/ManageImportListsEditModal';
import ManageImportListsModalRow from './ManageImportListsModalRow';
import TagsModal from './Tags/TagsModal';
import styles from './ManageImportListsModalContent.css';
// TODO: This feels janky to do, but not sure of a better way currently
type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageImportListsModalRow
>['onSelectedChange'];
const COLUMNS = [
{
name: 'name',
label: 'Name',
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: 'Implementation',
isSortable: true,
isVisible: true,
},
{
name: 'qualityProfileId',
label: 'Quality Profile',
isSortable: true,
isVisible: true,
},
{
name: 'rootFolderPath',
label: 'Root Folder',
isSortable: true,
isVisible: true,
},
{
name: 'enableAuto',
label: 'Auto Add',
isSortable: true,
isVisible: true,
},
{
name: 'tags',
label: 'Tags',
isSortable: true,
isVisible: true,
},
];
interface ManageImportListsModalContentProps {
onModalClose(): void;
}
function ManageImportListsModalContent(
props: ManageImportListsModalContentProps
) {
const { onModalClose } = props;
const {
isFetching,
isPopulated,
isDeleting,
isSaving,
error,
items,
}: ImportListAppState = useSelector(
createClientSideCollectionSelector('settings.importLists')
);
const dispatch = useDispatch();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
const [isSavingTags, setIsSavingTags] = useState(false);
const [selectState, setSelectState] = useSelectState();
const { allSelected, allUnselected, selectedState } = selectState;
const selectedIds: number[] = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const selectedCount = selectedIds.length;
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
const onDeleteModalClose = useCallback(() => {
setIsDeleteModalOpen(false);
}, [setIsDeleteModalOpen]);
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onConfirmDelete = useCallback(() => {
dispatch(bulkDeleteImportLists({ ids: selectedIds }));
setIsDeleteModalOpen(false);
}, [selectedIds, dispatch]);
const onSavePress = useCallback(
(payload: object) => {
setIsEditModalOpen(false);
dispatch(
bulkEditImportLists({
ids: selectedIds,
...payload,
})
);
},
[selectedIds, dispatch]
);
const onTagsPress = useCallback(() => {
setIsTagsModalOpen(true);
}, [setIsTagsModalOpen]);
const onTagsModalClose = useCallback(() => {
setIsTagsModalOpen(false);
}, [setIsTagsModalOpen]);
const onApplyTagsPress = useCallback(
(tags: number[], applyTags: string) => {
setIsSavingTags(true);
setIsTagsModalOpen(false);
dispatch(
bulkEditImportLists({
ids: selectedIds,
tags,
applyTags,
})
);
},
[selectedIds, dispatch]
);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const onSelectedChange = useCallback<OnSelectedChangeCallback>(
({ id, value, shiftKey = false }) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
);
const errorMessage = getErrorMessage(error, 'Unable to load import lists.');
const anySelected = selectedCount > 0;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Manage Import Lists</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table
columns={COLUMNS}
horizontalScroll={true}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
>
<TableBody>
{items.map((item) => {
return (
<ManageImportListsModalRow
key={item.id}
isSelected={selectedState[item.id]}
{...item}
columns={COLUMNS}
onSelectedChange={onSelectedChange}
/>
);
})}
</TableBody>
</Table>
) : null}
</ModalBody>
<ModalFooter>
<div className={styles.leftButtons}>
<SpinnerButton
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!anySelected}
onPress={onDeletePress}
>
Delete
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving}
isDisabled={!anySelected}
onPress={onEditPress}
>
Edit
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving && isSavingTags}
isDisabled={!anySelected}
onPress={onTagsPress}
>
Set Tags
</SpinnerButton>
</div>
<Button onPress={onModalClose}>Close</Button>
</ModalFooter>
<ManageImportListsEditModal
isOpen={isEditModalOpen}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
importListIds={selectedIds}
/>
<TagsModal
isOpen={isTagsModalOpen}
ids={selectedIds}
onApplyTagsPress={onApplyTagsPress}
onModalClose={onTagsModalClose}
/>
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title="Delete Import List(s)"
message={`Are you sure you want to delete ${selectedIds.length} import list(s)?`}
confirmLabel="Delete"
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>
</ModalContent>
);
}
export default ManageImportListsModalContent;

View File

@@ -0,0 +1,10 @@
.name,
.tags,
.enableAuto,
.qualityProfileId,
.rootFolderPath,
.implementation {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
word-break: break-all;
}

View File

@@ -0,0 +1,12 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'enableAuto': string;
'implementation': string;
'name': string;
'qualityProfileId': string;
'rootFolderPath': string;
'tags': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,84 @@
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import TagListConnector from 'Components/TagListConnector';
import { createQualityProfileSelectorForHook } from 'Store/Selectors/createQualityProfileSelector';
import { SelectStateInputProps } from 'typings/props';
import styles from './ManageImportListsModalRow.css';
interface ManageImportListsModalRowProps {
id: number;
name: string;
rootFolderPath: string;
qualityProfileId: number;
implementation: string;
tags: number[];
enableAuto: boolean;
columns: Column[];
isSelected?: boolean;
onSelectedChange(result: SelectStateInputProps): void;
}
function ManageImportListsModalRow(props: ManageImportListsModalRowProps) {
const {
id,
isSelected,
name,
rootFolderPath,
qualityProfileId,
implementation,
enableAuto,
tags,
onSelectedChange,
} = props;
const qualityProfile = useSelector(
createQualityProfileSelectorForHook(qualityProfileId)
);
const onSelectedChangeWrapper = useCallback(
(result: SelectStateInputProps) => {
onSelectedChange({
...result,
});
},
[onSelectedChange]
);
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChangeWrapper}
/>
<TableRowCell className={styles.name}>{name}</TableRowCell>
<TableRowCell className={styles.implementation}>
{implementation}
</TableRowCell>
<TableRowCell className={styles.qualityProfileId}>
{qualityProfile?.name ?? 'None'}
</TableRowCell>
<TableRowCell className={styles.rootFolderPath}>
{rootFolderPath}
</TableRowCell>
<TableRowCell className={styles.enableAuto}>
{enableAuto ? 'Yes' : 'No'}
</TableRowCell>
<TableRowCell className={styles.tags}>
<TagListConnector tags={tags} />
</TableRowCell>
</TableRow>
);
}
export default ManageImportListsModalRow;

View File

@@ -0,0 +1,22 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import TagsModalContent from './TagsModalContent';
interface TagsModalProps {
isOpen: boolean;
ids: number[];
onApplyTagsPress: (tags: number[], applyTags: string) => void;
onModalClose: () => void;
}
function TagsModal(props: TagsModalProps) {
const { isOpen, onModalClose, ...otherProps } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<TagsModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default TagsModal;

View File

@@ -0,0 +1,12 @@
.renameIcon {
margin-left: 5px;
}
.message {
margin-top: 20px;
margin-bottom: 10px;
}
.result {
padding-top: 4px;
}

View File

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

View File

@@ -0,0 +1,178 @@
import { uniq } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { ImportListAppState } from 'App/State/SettingsAppState';
import { Tag } from 'App/State/TagsAppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Label from 'Components/Label';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import ImportList from 'typings/ImportList';
import styles from './TagsModalContent.css';
interface TagsModalContentProps {
ids: number[];
onApplyTagsPress: (tags: number[], applyTags: string) => void;
onModalClose: () => void;
}
function TagsModalContent(props: TagsModalContentProps) {
const { ids, onModalClose, onApplyTagsPress } = props;
const allImportLists: ImportListAppState = useSelector(
(state: AppState) => state.settings.importLists
);
const tagList: Tag[] = useSelector(createTagsSelector());
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');
const seriesTags = useMemo(() => {
const tags = ids.reduce((acc: number[], id) => {
const s = allImportLists.items.find((s: ImportList) => s.id === id);
if (s) {
acc.push(...s.tags);
}
return acc;
}, []);
return uniq(tags);
}, [ids, allImportLists]);
const onTagsChange = useCallback(
({ value }: { value: number[] }) => {
setTags(value);
},
[setTags]
);
const onApplyTagsChange = useCallback(
({ value }: { value: string }) => {
setApplyTags(value);
},
[setApplyTags]
);
const onApplyPress = useCallback(() => {
onApplyTagsPress(tags, applyTags);
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions = [
{ key: 'add', value: 'Add' },
{ key: 'remove', value: 'Remove' },
{ key: 'replace', value: 'Replace' },
];
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Tags</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>Tags</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
value={tags}
onChange={onTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Apply Tags</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="applyTags"
value={applyTags}
values={applyTagsOptions}
helpTexts={[
'How to apply tags to the selected list',
'Add: Add the tags the existing list of tags',
'Remove: Remove the entered tags',
'Replace: Replace the tags with the entered tags (enter no tags to clear all tags)',
]}
onChange={onApplyTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Result</FormLabel>
<div className={styles.result}>
{seriesTags.map((id) => {
const tag = tagList.find((t) => t.id === id);
if (!tag) {
return null;
}
const removeTag =
(applyTags === 'remove' && tags.indexOf(id) > -1) ||
(applyTags === 'replace' && tags.indexOf(id) === -1);
return (
<Label
key={tag.id}
title={removeTag ? 'Removing tag' : 'Existing tag'}
kind={removeTag ? kinds.INVERSE : kinds.INFO}
size={sizes.LARGE}
>
{tag.label}
</Label>
);
})}
{(applyTags === 'add' || applyTags === 'replace') &&
tags.map((id) => {
const tag = tagList.find((t) => t.id === id);
if (!tag) {
return null;
}
if (seriesTags.indexOf(id) > -1) {
return null;
}
return (
<Label
key={tag.id}
title={'Adding tag'}
kind={kinds.SUCCESS}
size={sizes.LARGE}
>
{tag.label}
</Label>
);
})}
</div>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
<Button kind={kinds.PRIMARY} onPress={onApplyPress}>
Apply
</Button>
</ModalFooter>
</ModalContent>
);
}
export default TagsModalContent;

View File

@@ -8,6 +8,7 @@ import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import IndexersConnector from './Indexers/IndexersConnector';
import ManageIndexersModal from './Indexers/Manage/ManageIndexersModal';
import IndexerOptionsConnector from './Options/IndexerOptionsConnector';
import RestrictionsConnector from './Restrictions/RestrictionsConnector';
@@ -23,7 +24,8 @@ class IndexerSettings extends Component {
this.state = {
isSaving: false,
hasPendingChanges: false
hasPendingChanges: false,
isManageIndexersOpen: false
};
}
@@ -38,6 +40,14 @@ class IndexerSettings extends Component {
this.setState(payload);
};
onManageIndexersPress = () => {
this.setState({ isManageIndexersOpen: true });
};
onManageIndexersModalClose = () => {
this.setState({ isManageIndexersOpen: false });
};
onSavePress = () => {
if (this._saveCallback) {
this._saveCallback();
@@ -55,7 +65,8 @@ class IndexerSettings extends Component {
const {
isSaving,
hasPendingChanges
hasPendingChanges,
isManageIndexersOpen
} = this.state;
return (
@@ -73,6 +84,12 @@ class IndexerSettings extends Component {
isSpinning={isTestingAll}
onPress={dispatchTestAllIndexers}
/>
<PageToolbarButton
label="Manage Indexers"
iconName={icons.MANAGE}
onPress={this.onManageIndexersPress}
/>
</Fragment>
}
onSavePress={this.onSavePress}
@@ -87,6 +104,11 @@ class IndexerSettings extends Component {
/>
<RestrictionsConnector />
<ManageIndexersModal
isOpen={isManageIndexersOpen}
onModalClose={this.onManageIndexersModalClose}
/>
</PageContentBody>
</PageContent>
);

View File

@@ -0,0 +1,26 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageIndexersEditModalContent from './ManageIndexersEditModalContent';
interface ManageIndexersEditModalProps {
isOpen: boolean;
indexerIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
function ManageIndexersEditModal(props: ManageIndexersEditModalProps) {
const { isOpen, indexerIds, onSavePress, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageIndexersEditModalContent
indexerIds={indexerIds}
onSavePress={onSavePress}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default ManageIndexersEditModal;

View File

@@ -0,0 +1,16 @@
.modalFooter {
composes: modalFooter from '~Components/Modal/ModalFooter.css';
justify-content: space-between;
}
.selected {
font-weight: bold;
}
@media only screen and (max-width: $breakpointExtraSmall) {
.modalFooter {
flex-direction: column;
gap: 10px;
}
}

View File

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

View File

@@ -0,0 +1,178 @@
import React, { useCallback, useState } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ManageIndexersEditModalContent.css';
interface SavePayload {
enableRss?: boolean;
enableAutomaticSearch?: boolean;
enableInteractiveSearch?: boolean;
priority?: number;
}
interface ManageIndexersEditModalContentProps {
indexerIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
const NO_CHANGE = 'noChange';
const enableOptions = [
{ key: NO_CHANGE, value: 'No Change', disabled: true },
{ key: 'enabled', value: 'Enabled' },
{ key: 'disabled', value: 'Disabled' },
];
function ManageIndexersEditModalContent(
props: ManageIndexersEditModalContentProps
) {
const { indexerIds, onSavePress, onModalClose } = props;
const [enableRss, setEnableRss] = useState(NO_CHANGE);
const [enableAutomaticSearch, setEnableAutomaticSearch] = useState(NO_CHANGE);
const [enableInteractiveSearch, setEnableInteractiveSearch] =
useState(NO_CHANGE);
const [priority, setPriority] = useState<null | string | number>(null);
const save = useCallback(() => {
let hasChanges = false;
const payload: SavePayload = {};
if (enableRss !== NO_CHANGE) {
hasChanges = true;
payload.enableRss = enableRss === 'enabled';
}
if (enableAutomaticSearch !== NO_CHANGE) {
hasChanges = true;
payload.enableAutomaticSearch = enableAutomaticSearch === 'enabled';
}
if (enableInteractiveSearch !== NO_CHANGE) {
hasChanges = true;
payload.enableInteractiveSearch = enableInteractiveSearch === 'enabled';
}
if (priority !== null) {
hasChanges = true;
payload.priority = priority as number;
}
if (hasChanges) {
onSavePress(payload);
}
onModalClose();
}, [
enableRss,
enableAutomaticSearch,
enableInteractiveSearch,
priority,
onSavePress,
onModalClose,
]);
const onInputChange = useCallback(
({ name, value }: { name: string; value: string }) => {
switch (name) {
case 'enableRss':
setEnableRss(value);
break;
case 'enableAutomaticSearch':
setEnableAutomaticSearch(value);
break;
case 'enableInteractiveSearch':
setEnableInteractiveSearch(value);
break;
case 'priority':
setPriority(value);
break;
default:
console.warn('EditIndexersModalContent Unknown Input');
}
},
[]
);
const selectedCount = indexerIds.length;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('EditSelectedIndexers')}</ModalHeader>
<ModalBody>
<FormGroup>
<FormLabel>{translate('EnableRss')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="enableRss"
value={enableRss}
values={enableOptions}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('EnableAutomaticSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="enableAutomaticSearch"
value={enableAutomaticSearch}
values={enableOptions}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('EnableInteractiveSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="enableInteractiveSearch"
value={enableInteractiveSearch}
values={enableOptions}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Priority')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="priority"
value={priority}
min={1}
max={50}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('{count} indexers selected', { count: selectedCount })}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={save}>{translate('Apply Changes')}</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
export default ManageIndexersEditModalContent;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageIndexersModalContent from './ManageIndexersModalContent';
interface ManageIndexersModalProps {
isOpen: boolean;
onModalClose(): void;
}
function ManageIndexersModal(props: ManageIndexersModalProps) {
const { isOpen, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageIndexersModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default ManageIndexersModal;

View File

@@ -0,0 +1,16 @@
.leftButtons,
.rightButtons {
display: flex;
flex: 1 0 50%;
flex-wrap: wrap;
}
.rightButtons {
justify-content: flex-end;
}
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: 10px;
}

View File

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

View File

@@ -0,0 +1,287 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IndexerAppState } from 'App/State/SettingsAppState';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import {
bulkDeleteIndexers,
bulkEditIndexers,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import ManageIndexersEditModal from './Edit/ManageIndexersEditModal';
import ManageIndexersModalRow from './ManageIndexersModalRow';
import TagsModal from './Tags/TagsModal';
import styles from './ManageIndexersModalContent.css';
// TODO: This feels janky to do, but not sure of a better way currently
type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageIndexersModalRow
>['onSelectedChange'];
const COLUMNS = [
{
name: 'name',
label: 'Name',
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: 'Implementation',
isSortable: true,
isVisible: true,
},
{
name: 'enableRss',
label: 'Enable RSS',
isSortable: true,
isVisible: true,
},
{
name: 'enableAutomaticSearch',
label: 'Enable Automatic Search',
isSortable: true,
isVisible: true,
},
{
name: 'enableInteractiveSearch',
label: 'Enable Interactive Search',
isSortable: true,
isVisible: true,
},
{
name: 'priority',
label: 'Priority',
isSortable: true,
isVisible: true,
},
{
name: 'tags',
label: 'Tags',
isSortable: true,
isVisible: true,
},
];
interface ManageIndexersModalContentProps {
onModalClose(): void;
}
function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
const { onModalClose } = props;
const {
isFetching,
isPopulated,
isDeleting,
isSaving,
error,
items,
}: IndexerAppState = useSelector(
createClientSideCollectionSelector('settings.indexers')
);
const dispatch = useDispatch();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
const [isSavingTags, setIsSavingTags] = useState(false);
const [selectState, setSelectState] = useSelectState();
const { allSelected, allUnselected, selectedState } = selectState;
const selectedIds: number[] = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const selectedCount = selectedIds.length;
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
const onDeleteModalClose = useCallback(() => {
setIsDeleteModalOpen(false);
}, [setIsDeleteModalOpen]);
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onConfirmDelete = useCallback(() => {
dispatch(bulkDeleteIndexers({ ids: selectedIds }));
setIsDeleteModalOpen(false);
}, [selectedIds, dispatch]);
const onSavePress = useCallback(
(payload: object) => {
setIsEditModalOpen(false);
dispatch(
bulkEditIndexers({
ids: selectedIds,
...payload,
})
);
},
[selectedIds, dispatch]
);
const onTagsPress = useCallback(() => {
setIsTagsModalOpen(true);
}, [setIsTagsModalOpen]);
const onTagsModalClose = useCallback(() => {
setIsTagsModalOpen(false);
}, [setIsTagsModalOpen]);
const onApplyTagsPress = useCallback(
(tags: number[], applyTags: string) => {
setIsSavingTags(true);
setIsTagsModalOpen(false);
dispatch(
bulkEditIndexers({
ids: selectedIds,
tags,
applyTags,
})
);
},
[selectedIds, dispatch]
);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const onSelectedChange = useCallback<OnSelectedChangeCallback>(
({ id, value, shiftKey = false }) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
);
const errorMessage = getErrorMessage(error, 'Unable to load import lists.');
const anySelected = selectedCount > 0;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Manage Import Lists</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table
columns={COLUMNS}
horizontalScroll={true}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
>
<TableBody>
{items.map((item) => {
return (
<ManageIndexersModalRow
key={item.id}
isSelected={selectedState[item.id]}
{...item}
columns={COLUMNS}
onSelectedChange={onSelectedChange}
/>
);
})}
</TableBody>
</Table>
) : null}
</ModalBody>
<ModalFooter>
<div className={styles.leftButtons}>
<SpinnerButton
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!anySelected}
onPress={onDeletePress}
>
Delete
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving}
isDisabled={!anySelected}
onPress={onEditPress}
>
Edit
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving && isSavingTags}
isDisabled={!anySelected}
onPress={onTagsPress}
>
Set Tags
</SpinnerButton>
</div>
<Button onPress={onModalClose}>Close</Button>
</ModalFooter>
<ManageIndexersEditModal
isOpen={isEditModalOpen}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
indexerIds={selectedIds}
/>
<TagsModal
isOpen={isTagsModalOpen}
ids={selectedIds}
onApplyTagsPress={onApplyTagsPress}
onModalClose={onTagsModalClose}
/>
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title="Delete Import List(s)"
message={`Are you sure you want to delete ${selectedIds.length} import list(s)?`}
confirmLabel="Delete"
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>
</ModalContent>
);
}
export default ManageIndexersModalContent;

View File

@@ -0,0 +1,11 @@
.name,
.tags,
.enableRss,
.enableAutomaticSearch,
.enableInteractiveSearch,
.priority,
.implementation {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
word-break: break-all;
}

View File

@@ -0,0 +1,13 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'enableAutomaticSearch': string;
'enableInteractiveSearch': string;
'enableRss': string;
'implementation': string;
'name': string;
'priority': string;
'tags': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,82 @@
import React, { useCallback } from 'react';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import TagListConnector from 'Components/TagListConnector';
import { SelectStateInputProps } from 'typings/props';
import styles from './ManageIndexersModalRow.css';
interface ManageIndexersModalRowProps {
id: number;
name: string;
enableRss: boolean;
enableAutomaticSearch: boolean;
enableInteractiveSearch: boolean;
priority: number;
implementation: string;
tags: number[];
columns: Column[];
isSelected?: boolean;
onSelectedChange(result: SelectStateInputProps): void;
}
function ManageIndexersModalRow(props: ManageIndexersModalRowProps) {
const {
id,
isSelected,
name,
enableRss,
enableAutomaticSearch,
enableInteractiveSearch,
priority,
implementation,
tags,
onSelectedChange,
} = props;
const onSelectedChangeWrapper = useCallback(
(result: SelectStateInputProps) => {
onSelectedChange({
...result,
});
},
[onSelectedChange]
);
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChangeWrapper}
/>
<TableRowCell className={styles.name}>{name}</TableRowCell>
<TableRowCell className={styles.implementation}>
{implementation}
</TableRowCell>
<TableRowCell className={styles.enableRss}>
{enableRss ? 'Yes' : 'No'}
</TableRowCell>
<TableRowCell className={styles.enableAutomaticSearch}>
{enableAutomaticSearch ? 'Yes' : 'No'}
</TableRowCell>
<TableRowCell className={styles.enableInteractiveSearch}>
{enableInteractiveSearch ? 'Yes' : 'No'}
</TableRowCell>
<TableRowCell className={styles.priority}>{priority}</TableRowCell>
<TableRowCell className={styles.tags}>
<TagListConnector tags={tags} />
</TableRowCell>
</TableRow>
);
}
export default ManageIndexersModalRow;

View File

@@ -0,0 +1,22 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import TagsModalContent from './TagsModalContent';
interface TagsModalProps {
isOpen: boolean;
ids: number[];
onApplyTagsPress: (tags: number[], applyTags: string) => void;
onModalClose: () => void;
}
function TagsModal(props: TagsModalProps) {
const { isOpen, onModalClose, ...otherProps } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<TagsModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default TagsModal;

View File

@@ -0,0 +1,12 @@
.renameIcon {
margin-left: 5px;
}
.message {
margin-top: 20px;
margin-bottom: 10px;
}
.result {
padding-top: 4px;
}

View File

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

View File

@@ -0,0 +1,178 @@
import { uniq } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { IndexerAppState } from 'App/State/SettingsAppState';
import { Tag } from 'App/State/TagsAppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Label from 'Components/Label';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import Indexer from 'typings/Indexer';
import styles from './TagsModalContent.css';
interface TagsModalContentProps {
ids: number[];
onApplyTagsPress: (tags: number[], applyTags: string) => void;
onModalClose: () => void;
}
function TagsModalContent(props: TagsModalContentProps) {
const { ids, onModalClose, onApplyTagsPress } = props;
const allIndexers: IndexerAppState = useSelector(
(state: AppState) => state.settings.indexers
);
const tagList: Tag[] = useSelector(createTagsSelector());
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');
const seriesTags = useMemo(() => {
const tags = ids.reduce((acc: number[], id) => {
const s = allIndexers.items.find((s: Indexer) => s.id === id);
if (s) {
acc.push(...s.tags);
}
return acc;
}, []);
return uniq(tags);
}, [ids, allIndexers]);
const onTagsChange = useCallback(
({ value }: { value: number[] }) => {
setTags(value);
},
[setTags]
);
const onApplyTagsChange = useCallback(
({ value }: { value: string }) => {
setApplyTags(value);
},
[setApplyTags]
);
const onApplyPress = useCallback(() => {
onApplyTagsPress(tags, applyTags);
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions = [
{ key: 'add', value: 'Add' },
{ key: 'remove', value: 'Remove' },
{ key: 'replace', value: 'Replace' },
];
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Tags</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>Tags</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
value={tags}
onChange={onTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Apply Tags</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="applyTags"
value={applyTags}
values={applyTagsOptions}
helpTexts={[
'How to apply tags to the selected indexer(s)',
'Add: Add the tags the existing list of tags',
'Remove: Remove the entered tags',
'Replace: Replace the tags with the entered tags (enter no tags to clear all tags)',
]}
onChange={onApplyTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Result</FormLabel>
<div className={styles.result}>
{seriesTags.map((id) => {
const tag = tagList.find((t) => t.id === id);
if (!tag) {
return null;
}
const removeTag =
(applyTags === 'remove' && tags.indexOf(id) > -1) ||
(applyTags === 'replace' && tags.indexOf(id) === -1);
return (
<Label
key={tag.id}
title={removeTag ? 'Removing tag' : 'Existing tag'}
kind={removeTag ? kinds.INVERSE : kinds.INFO}
size={sizes.LARGE}
>
{tag.label}
</Label>
);
})}
{(applyTags === 'add' || applyTags === 'replace') &&
tags.map((id) => {
const tag = tagList.find((t) => t.id === id);
if (!tag) {
return null;
}
if (seriesTags.indexOf(id) > -1) {
return null;
}
return (
<Label
key={tag.id}
title={'Adding tag'}
kind={kinds.SUCCESS}
size={sizes.LARGE}
>
{tag.label}
</Label>
);
})}
</div>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
<Button kind={kinds.PRIMARY} onPress={onApplyPress}>
Apply
</Button>
</ModalFooter>
</ModalContent>
);
}
export default TagsModalContent;

View File

@@ -63,29 +63,29 @@ class MediaManagement extends Component {
<NamingConnector />
{
isFetching &&
isFetching ?
<FieldSet legend={translate('NamingSettings')}>
<LoadingIndicator />
</FieldSet>
</FieldSet> : null
}
{
!isFetching && error &&
!isFetching && error ?
<FieldSet legend={translate('NamingSettings')}>
<div>
{translate('UnableToLoadMediaManagementSettings')}
</div>
</FieldSet>
</FieldSet> : null
}
{
hasSettings && !isFetching && !error &&
hasSettings && !isFetching && !error ?
<Form
id="mediaManagementSettings"
{...otherProps}
>
{
advancedSettings &&
advancedSettings ?
<FieldSet legend={translate('Folders')}>
<FormGroup
advancedSettings={advancedSettings}
@@ -120,11 +120,11 @@ class MediaManagement extends Component {
{...settings.deleteEmptyFolders}
/>
</FormGroup>
</FieldSet>
</FieldSet> : null
}
{
advancedSettings &&
advancedSettings ?
<FieldSet
legend={translate('Importing')}
>
@@ -181,6 +181,41 @@ class MediaManagement extends Component {
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('ImportUsingScript')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="useScriptImport"
helpText={translate('UseScriptImportHelpText')}
onChange={onInputChange}
{...settings.useScriptImport}
/>
</FormGroup>
{
settings.useScriptImport.value ?
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>{translate('ImportScriptPath')}</FormLabel>
<FormInputGroup
type={inputTypes.PATH}
includeFiles={true}
name="scriptImportPath"
helpText={translate('ScriptImportPathHelpText')}
onChange={onInputChange}
{...settings.scriptImportPath}
/>
</FormGroup> : null
}
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('ImportExtraFiles')}</FormLabel>
@@ -194,7 +229,7 @@ class MediaManagement extends Component {
</FormGroup>
{
settings.importExtraFiles.value &&
settings.importExtraFiles.value ?
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
@@ -211,9 +246,9 @@ class MediaManagement extends Component {
onChange={onInputChange}
{...settings.extraFileExtensions}
/>
</FormGroup>
</FormGroup> : null
}
</FieldSet>
</FieldSet> : null
}
<FieldSet
@@ -339,7 +374,7 @@ class MediaManagement extends Component {
</FieldSet>
{
advancedSettings && !isWindows &&
advancedSettings && !isWindows ?
<FieldSet
legend={translate('Permissions')}
>
@@ -392,9 +427,9 @@ class MediaManagement extends Component {
{...settings.chownGroup}
/>
</FormGroup>
</FieldSet>
</FieldSet> : null
}
</Form>
</Form> : null
}
<FieldSet legend={translate('RootFolders')}>

View File

@@ -0,0 +1,54 @@
import { batchActions } from 'redux-batched-actions';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { set, updateItem } from '../baseActions';
function createBulkEditItemHandler(section, url) {
return function(getState, payload, dispatch) {
dispatch(set({ section, isSaving: true }));
const ajaxOptions = {
url: `${url}`,
method: 'PUT',
data: JSON.stringify(payload),
dataType: 'json'
};
const promise = createAjaxRequest(ajaxOptions).request;
promise.done((data) => {
dispatch(batchActions([
set({
section,
isSaving: false,
saveError: null
}),
...data.map((provider) => {
const {
...propsToUpdate
} = provider;
return updateItem({
id: provider.id,
section,
...propsToUpdate
});
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isSaving: false,
saveError: xhr
}));
});
return promise;
};
}
export default createBulkEditItemHandler;

View File

@@ -0,0 +1,48 @@
import { batchActions } from 'redux-batched-actions';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { removeItem, set } from '../baseActions';
function createBulkRemoveItemHandler(section, url) {
return function(getState, payload, dispatch) {
const {
ids
} = payload;
dispatch(set({ section, isDeleting: true }));
const ajaxOptions = {
url: `${url}`,
method: 'DELETE',
data: JSON.stringify(payload),
dataType: 'json'
};
const promise = createAjaxRequest(ajaxOptions).request;
promise.done((data) => {
dispatch(batchActions([
set({
section,
isDeleting: false,
deleteError: null
}),
...ids.map((id) => {
return removeItem({ section, id });
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isDeleting: false,
deleteError: xhr
}));
});
return promise;
};
}
export default createBulkRemoveItemHandler;

View File

@@ -32,9 +32,9 @@ function createSaveProviderHandler(section, url, options = {}) {
const params = { ...queryParams };
// If the user is re-saving the same provider without changes
// force it to be saved. Only applies to editing existing providers.
// force it to be saved.
if (id && _.isEqual(saveData, lastSaveData)) {
if (_.isEqual(saveData, lastSaveData)) {
params.forceSave = true;
}

View File

@@ -1,4 +1,6 @@
import { createAction } from 'redux-actions';
import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
@@ -30,6 +32,9 @@ export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient
export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient';
export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients';
export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients';
export const BULK_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients';
//
// Action Creators
@@ -44,6 +49,9 @@ export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT);
export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT);
export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS);
export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS);
export const bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS);
export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => {
return {
section,
@@ -77,6 +85,8 @@ export default {
selectedSchema: {},
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
isTesting: false,
isTestingAll: false,
items: [],
@@ -95,7 +105,9 @@ export default {
[DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'),
[TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'),
[CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section),
[TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient')
[TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient'),
[BULK_DELETE_DOWNLOAD_CLIENTS]: createBulkRemoveItemHandler(section, '/downloadclient/bulk'),
[BULK_EDIT_DOWNLOAD_CLIENTS]: createBulkEditItemHandler(section, '/downloadclient/bulk')
},
//

View File

@@ -1,4 +1,6 @@
import { createAction } from 'redux-actions';
import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
@@ -30,6 +32,9 @@ export const TEST_IMPORT_LIST = 'settings/importLists/testImportList';
export const CANCEL_TEST_IMPORT_LIST = 'settings/importLists/cancelTestImportList';
export const TEST_ALL_IMPORT_LIST = 'settings/importLists/testAllImportList';
export const BULK_DELETE_IMPORT_LISTS = 'settings/importlists/bulkDeleteImportLists';
export const BULK_EDIT_IMPORT_LISTS = 'settings/importlists/bulkEditImportLists';
//
// Action Creators
@@ -44,6 +49,9 @@ export const testImportList = createThunk(TEST_IMPORT_LIST);
export const cancelTestImportList = createThunk(CANCEL_TEST_IMPORT_LIST);
export const testAllImportList = createThunk(TEST_ALL_IMPORT_LIST);
export const bulkDeleteImportLists = createThunk(BULK_DELETE_IMPORT_LISTS);
export const bulkEditImportLists = createThunk(BULK_EDIT_IMPORT_LISTS);
export const setImportListValue = createAction(SET_IMPORT_LIST_VALUE, (payload) => {
return {
section,
@@ -77,6 +85,8 @@ export default {
selectedSchema: {},
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
isTesting: false,
isTestingAll: false,
items: [],
@@ -95,7 +105,10 @@ export default {
[DELETE_IMPORT_LIST]: createRemoveItemHandler(section, '/importlist'),
[TEST_IMPORT_LIST]: createTestProviderHandler(section, '/importlist'),
[CANCEL_TEST_IMPORT_LIST]: createCancelTestProviderHandler(section),
[TEST_ALL_IMPORT_LIST]: createTestAllProvidersHandler(section, '/importlist')
[TEST_ALL_IMPORT_LIST]: createTestAllProvidersHandler(section, '/importlist'),
[BULK_DELETE_IMPORT_LISTS]: createBulkRemoveItemHandler(section, '/importlist/bulk'),
[BULK_EDIT_IMPORT_LISTS]: createBulkEditItemHandler(section, '/importlist/bulk')
},
//

View File

@@ -11,6 +11,8 @@ import { createThunk } from 'Store/thunks';
import getSectionState from 'Utilities/State/getSectionState';
import selectProviderSchema from 'Utilities/State/selectProviderSchema';
import updateSectionState from 'Utilities/State/updateSectionState';
import createBulkEditItemHandler from '../Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from '../Creators/createBulkRemoveItemHandler';
//
// Variables
@@ -33,6 +35,9 @@ export const TEST_INDEXER = 'settings/indexers/testIndexer';
export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer';
export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers';
export const BULK_DELETE_INDEXERS = 'settings/indexers/bulkDeleteIndexers';
export const BULK_EDIT_INDEXERS = 'settings/indexers/bulkEditIndexers';
//
// Action Creators
@@ -48,6 +53,9 @@ export const testIndexer = createThunk(TEST_INDEXER);
export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER);
export const testAllIndexers = createThunk(TEST_ALL_INDEXERS);
export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS);
export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS);
export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => {
return {
section,
@@ -81,6 +89,8 @@ export default {
selectedSchema: {},
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
isTesting: false,
isTestingAll: false,
items: [],
@@ -99,7 +109,10 @@ export default {
[DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'),
[TEST_INDEXER]: createTestProviderHandler(section, '/indexer'),
[CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section),
[TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer')
[TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer'),
[BULK_DELETE_INDEXERS]: createBulkRemoveItemHandler(section, '/indexer/bulk'),
[BULK_EDIT_INDEXERS]: createBulkEditItemHandler(section, '/indexer/bulk')
},
//

View File

@@ -1,5 +1,16 @@
import { createSelector } from 'reselect';
export function createQualityProfileSelectorForHook(qualityProfileId) {
return createSelector(
(state) => state.settings.qualityProfiles.items,
(qualityProfiles) => {
return qualityProfiles.find((profile) => {
return profile.id === qualityProfileId;
});
}
);
}
function createQualityProfileSelector() {
return createSelector(
(state, { qualityProfileId }) => qualityProfileId,

View File

@@ -0,0 +1,27 @@
import ModelBase from 'App/ModelBase';
export interface Field {
order: number;
name: string;
label: string;
value: boolean | number | string;
type: string;
advanced: boolean;
privacy: string;
}
interface ImportList extends ModelBase {
enable: boolean;
enableAuto: boolean;
qualityProfileId: number;
rootFolderPath: string;
name: string;
fields: Field[];
implementationName: string;
implementation: string;
configContract: string;
infoLink: string;
tags: number[];
}
export default ImportList;

View File

@@ -0,0 +1,28 @@
import ModelBase from 'App/ModelBase';
export interface Field {
order: number;
name: string;
label: string;
value: boolean | number | string;
type: string;
advanced: boolean;
privacy: string;
}
interface Indexer extends ModelBase {
enableRss: boolean;
enableAutomaticSearch: boolean;
enableInteractiveSearch: boolean;
protocol: string;
priority: number;
name: string;
fields: Field[];
implementationName: string;
implementation: string;
configContract: string;
infoLink: string;
tags: number[];
}
export default Indexer;

View File

@@ -0,0 +1,24 @@
import ModelBase from 'App/ModelBase';
export interface Field {
order: number;
name: string;
label: string;
value: boolean | number | string;
type: string;
advanced: boolean;
privacy: string;
}
interface Notification extends ModelBase {
enable: boolean;
name: string;
fields: Field[];
implementationName: string;
implementation: string;
configContract: string;
infoLink: string;
tags: number[];
}
export default Notification;

View File

@@ -30,6 +30,13 @@
<!-- A test project gets the test sdk packages automatically added -->
<TestProject>false</TestProject>
<TestProject Condition="$(MSBuildProjectName.EndsWith('.Test'))">true</TestProject>
<!-- XML documentation comments are needed to enforce rule IDE0005 on build -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!--
CS1591: Missing XML comment for publicly visible type or member 'Type_or_Member'
-->
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<PropertyGroup>

View File

@@ -68,7 +68,7 @@ namespace NzbDrone.Automation.Test
{
try
{
Screenshot image = ((ITakesScreenshot)driver).GetScreenshot();
var image = ((ITakesScreenshot)driver).GetScreenshot();
image.SaveAsFile($"./{name}_test_screenshot.png", ScreenshotImageFormat.Png);
}
catch (Exception ex)

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading;
using OpenQA.Selenium;
using OpenQA.Selenium.Remote;
@@ -37,7 +37,7 @@ namespace NzbDrone.Automation.Test.PageModel
{
try
{
IWebElement element = d.FindElement(By.ClassName("followingBalls"));
var element = d.FindElement(By.ClassName("followingBalls"));
return !element.Displayed;
}
catch (NoSuchElementException)

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading;
using FluentAssertions;
using NUnit.Framework;
@@ -65,9 +65,9 @@ namespace NzbDrone.Common.Test.CacheTests
[Test]
public void should_store_null()
{
int hitCount = 0;
var hitCount = 0;
for (int i = 0; i < 10; i++)
for (var i = 0; i < 10; i++)
{
_cachedString.Get("key", () =>
{
@@ -83,10 +83,10 @@ namespace NzbDrone.Common.Test.CacheTests
[Platform(Exclude = "MacOsX")]
public void should_honor_ttl()
{
int hitCount = 0;
var hitCount = 0;
_cachedString = new Cached<string>();
for (int i = 0; i < 10; i++)
for (var i = 0; i < 10; i++)
{
_cachedString.Get("key",
() =>

View File

@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using FluentAssertions;
using Moq;
using NUnit.Framework;
@@ -142,7 +142,7 @@ namespace NzbDrone.Common.Test
[Test]
public void SaveDictionary_should_save_proper_value()
{
int port = 20555;
var port = 20555;
var dic = Subject.GetConfigDictionary();
dic["Port"] = 20555;
@@ -155,9 +155,9 @@ namespace NzbDrone.Common.Test
[Test]
public void SaveDictionary_should_only_save_specified_values()
{
int port = 20555;
int origSslPort = 20551;
int sslPort = 20552;
var port = 20555;
var origSslPort = 20551;
var sslPort = 20552;
var dic = Subject.GetConfigDictionary();
dic["Port"] = port;

View File

@@ -42,7 +42,7 @@ namespace NzbDrone.Common.Test.DiskTests
[Test]
public void should_not_contain_recycling_bin_for_root_of_drive()
{
string root = @"C:\".AsOsAgnostic();
var root = @"C:\".AsOsAgnostic();
SetupFolders(root);
Mocker.GetMock<IDiskProvider>()
@@ -55,7 +55,7 @@ namespace NzbDrone.Common.Test.DiskTests
[Test]
public void should_not_contain_system_volume_information()
{
string root = @"C:\".AsOsAgnostic();
var root = @"C:\".AsOsAgnostic();
SetupFolders(root);
Mocker.GetMock<IDiskProvider>()
@@ -68,7 +68,7 @@ namespace NzbDrone.Common.Test.DiskTests
[Test]
public void should_not_contain_recycling_bin_or_system_volume_information_for_root_of_drive()
{
string root = @"C:\".AsOsAgnostic();
var root = @"C:\".AsOsAgnostic();
SetupFolders(root);
Mocker.GetMock<IDiskProvider>()

View File

@@ -786,7 +786,7 @@ namespace NzbDrone.Common.Test.Http
try
{
// the date is bad in the below - should be 13-Jul-2026
string malformedCookie = @"__cfduid=d29e686a9d65800021c66faca0a29b4261436890790; expires=Mon, 13-Jul-26 16:19:50 GMT; path=/; HttpOnly";
var malformedCookie = @"__cfduid=d29e686a9d65800021c66faca0a29b4261436890790; expires=Mon, 13-Jul-26 16:19:50 GMT; path=/; HttpOnly";
var requestSet = new HttpRequestBuilder($"https://{_httpBinHost}/response-headers")
.AddQueryParam("Set-Cookie", malformedCookie)
.Build();
@@ -820,7 +820,7 @@ namespace NzbDrone.Common.Test.Http
{
try
{
string url = $"https://{_httpBinHost}/response-headers?Set-Cookie={Uri.EscapeDataString(malformedCookie)}";
var url = $"https://{_httpBinHost}/response-headers?Set-Cookie={Uri.EscapeDataString(malformedCookie)}";
var requestSet = new HttpRequest(url);
requestSet.AllowAutoRedirect = false;

View File

@@ -74,17 +74,17 @@ namespace NzbDrone.Common
continue; // Ignore directories
}
string entryFileName = zipEntry.Name;
var entryFileName = zipEntry.Name;
// to remove the folder from the entry:- entryFileName = Path.GetFileName(entryFileName);
// Optionally match entrynames against a selection list here to skip as desired.
// The unpacked length is available in the zipEntry.Size property.
byte[] buffer = new byte[4096]; // 4K is optimum
Stream zipStream = zipFile.GetInputStream(zipEntry);
var buffer = new byte[4096]; // 4K is optimum
var zipStream = zipFile.GetInputStream(zipEntry);
// Manipulate the output filename here as desired.
string fullZipToPath = Path.Combine(destination, entryFileName);
string directoryName = Path.GetDirectoryName(fullZipToPath);
var fullZipToPath = Path.Combine(destination, entryFileName);
var directoryName = Path.GetDirectoryName(fullZipToPath);
if (directoryName.Length > 0)
{
Directory.CreateDirectory(directoryName);
@@ -93,7 +93,7 @@ namespace NzbDrone.Common
// Unzip file in buffered chunks. This is just as fast as unpacking to a buffer the full size
// of the file, but does not waste memory.
// The "using" will close the stream even if an exception occurs.
using (FileStream streamWriter = File.Create(fullZipToPath))
using (var streamWriter = File.Create(fullZipToPath))
{
StreamUtils.Copy(zipStream, streamWriter, buffer);
}
@@ -106,7 +106,7 @@ namespace NzbDrone.Common
Stream inStream = File.OpenRead(compressedFile);
Stream gzipStream = new GZipInputStream(inStream);
TarArchive tarArchive = TarArchive.CreateInputTarArchive(gzipStream, null);
var tarArchive = TarArchive.CreateInputTarArchive(gzipStream, null);
tarArchive.ExtractContents(destination);
tarArchive.Close();

View File

@@ -47,8 +47,7 @@ namespace NzbDrone.Common.Cache
public T Find(string key)
{
CacheItem cacheItem;
if (!_store.TryGetValue(key, out cacheItem))
if (!_store.TryGetValue(key, out var cacheItem))
{
return default(T);
}
@@ -76,8 +75,7 @@ namespace NzbDrone.Common.Cache
public void Remove(string key)
{
CacheItem value;
_store.TryRemove(key, out value);
_store.TryRemove(key, out _);
}
public int Count => _store.Count;
@@ -88,9 +86,7 @@ namespace NzbDrone.Common.Cache
lifeTime = lifeTime ?? _defaultLifeTime;
CacheItem cacheItem;
if (_store.TryGetValue(key, out cacheItem) && !cacheItem.IsExpired())
if (_store.TryGetValue(key, out var cacheItem) && !cacheItem.IsExpired())
{
if (_rollingExpiry && lifeTime.HasValue)
{

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
@@ -86,9 +86,7 @@ namespace NzbDrone.Common.Cache
{
RefreshIfExpired();
TValue result;
if (!_items.TryGetValue(key, out result))
if (!_items.TryGetValue(key, out var result))
{
throw new KeyNotFoundException(string.Format("Item {0} not found in cache.", key));
}
@@ -100,9 +98,7 @@ namespace NzbDrone.Common.Cache
{
RefreshIfExpired();
TValue result;
_items.TryGetValue(key, out result);
_items.TryGetValue(key, out var result);
return result;
}
@@ -128,8 +124,7 @@ namespace NzbDrone.Common.Cache
public void Remove(string key)
{
TValue item;
_items.TryRemove(key, out item);
_items.TryRemove(key, out _);
}
}
}

View File

@@ -16,7 +16,7 @@ namespace NzbDrone.Common.Composition
AppDomain.CurrentDomain.AssemblyResolve += new ResolveEventHandler(ContainerResolveEventHandler);
}
public static IEnumerable<Assembly> Load(IEnumerable<string> assemblyNames)
public static IList<Assembly> Load(IList<string> assemblyNames)
{
var toLoad = assemblyNames.ToList();
toLoad.Add("Radarr.Common");
@@ -28,8 +28,9 @@ namespace NzbDrone.Common.Composition
var startupPath = AppDomain.CurrentDomain.BaseDirectory;
return toLoad.Select(x =>
AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.Combine(startupPath, $"{x}.dll")));
return toLoad
.Select(x => AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.Combine(startupPath, $"{x}.dll")))
.ToList();
}
private static Assembly ContainerResolveEventHandler(object sender, ResolveEventArgs args)

View File

@@ -1,4 +1,4 @@
namespace NzbDrone.Common
namespace NzbDrone.Common
{
public static class ConvertBase32
{
@@ -6,17 +6,17 @@
public static byte[] FromBase32String(string str)
{
int numBytes = str.Length * 5 / 8;
byte[] bytes = new byte[numBytes];
var numBytes = str.Length * 5 / 8;
var bytes = new byte[numBytes];
// all UPPERCASE chars
str = str.ToUpper();
int bitBuffer = 0;
int bitBufferCount = 0;
int index = 0;
var bitBuffer = 0;
var bitBufferCount = 0;
var index = 0;
for (int i = 0; i < str.Length; i++)
for (var i = 0; i < str.Length; i++)
{
bitBuffer = (bitBuffer << 5) | ValidChars.IndexOf(str[i]);
bitBufferCount += 5;

View File

@@ -255,7 +255,7 @@ namespace NzbDrone.Common.Disk
var stringComparison = (Kind == OsPathKind.Windows || other.Kind == OsPathKind.Windows) ? StringComparison.InvariantCultureIgnoreCase : StringComparison.InvariantCulture;
for (int i = 0; i < leftFragments.Length; i++)
for (var i = 0; i < leftFragments.Length; i++)
{
if (!string.Equals(leftFragments[i], rightFragments[i], stringComparison))
{
@@ -372,12 +372,12 @@ namespace NzbDrone.Common.Disk
var newFragments = new List<string>();
for (int j = i; j < rightFragments.Length; j++)
for (var j = i; j < rightFragments.Length; j++)
{
newFragments.Add("..");
}
for (int j = i; j < leftFragments.Length; j++)
for (var j = i; j < leftFragments.Length; j++)
{
newFragments.Add(leftFragments[j]);
}

View File

@@ -126,9 +126,9 @@ namespace NzbDrone.Common.Extensions
private static IEnumerable<T> InternalDropLast<T>(IEnumerable<T> source, int n)
{
Queue<T> buffer = new Queue<T>(n + 1);
var buffer = new Queue<T>(n + 1);
foreach (T x in source)
foreach (var x in source)
{
buffer.Enqueue(x);

View File

@@ -21,7 +21,7 @@ namespace NzbDrone.Common.Extensions
return text.Length * costDelete;
}
int[] matrix = new int[other.Length + 1];
var matrix = new int[other.Length + 1];
for (var i = 1; i < matrix.Length; i++)
{
@@ -30,13 +30,13 @@ namespace NzbDrone.Common.Extensions
for (var i = 0; i < text.Length; i++)
{
int topLeft = matrix[0];
var topLeft = matrix[0];
matrix[0] = matrix[0] + costDelete;
for (var j = 0; j < other.Length; j++)
{
int top = matrix[j];
int left = matrix[j + 1];
var top = matrix[j];
var left = matrix[j + 1];
var sumIns = top + costInsert;
var sumDel = left + costDelete;

View File

@@ -245,13 +245,13 @@ namespace NzbDrone.Common.Extensions
var firstPath = paths.First();
var length = firstPath.Length;
for (int i = 1; i < paths.Count; i++)
for (var i = 1; i < paths.Count; i++)
{
var path = paths[i];
length = Math.Min(length, path.Length);
for (int characterIndex = 0; characterIndex < length; characterIndex++)
for (var characterIndex = 0; characterIndex < length; characterIndex++)
{
if (path[characterIndex] != firstPath[characterIndex])
{

View File

@@ -6,9 +6,7 @@ namespace NzbDrone.Common.Extensions
{
public static int? ParseInt32(this string source)
{
int result;
if (int.TryParse(source, out result))
if (int.TryParse(source, out var result))
{
return result;
}
@@ -18,9 +16,7 @@ namespace NzbDrone.Common.Extensions
public static long? ParseInt64(this string source)
{
long result;
if (long.TryParse(source, out result))
if (long.TryParse(source, out var result))
{
return result;
}
@@ -30,9 +26,7 @@ namespace NzbDrone.Common.Extensions
public static double? ParseDouble(this string source)
{
double result;
if (double.TryParse(source.Replace(',', '.'), NumberStyles.Number, CultureInfo.InvariantCulture, out result))
if (double.TryParse(source.Replace(',', '.'), NumberStyles.Number, CultureInfo.InvariantCulture, out var result))
{
return result;
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Text;
namespace NzbDrone.Common
@@ -7,9 +7,9 @@ namespace NzbDrone.Common
{
public static string CalculateCrc(string input)
{
uint mCrc = 0xffffffff;
byte[] bytes = Encoding.UTF8.GetBytes(input);
foreach (byte myByte in bytes)
var mCrc = 0xffffffff;
var bytes = Encoding.UTF8.GetBytes(input);
foreach (var myByte in bytes)
{
mCrc ^= (uint)myByte << 24;
for (var i = 0; i < 8; i++)

View File

@@ -22,7 +22,7 @@ namespace NzbDrone.Common.Http
public HttpUri(string scheme, string host, int? port, string path, string query, string fragment)
{
StringBuilder builder = new StringBuilder();
var builder = new StringBuilder();
if (scheme.IsNotNullOrWhiteSpace())
{

View File

@@ -31,7 +31,7 @@ namespace NzbDrone.Common.Http.Proxy
if (!string.IsNullOrWhiteSpace(BypassFilter))
{
var hostlist = BypassFilter.Split(',');
for (int i = 0; i < hostlist.Length; i++)
for (var i = 0; i < hostlist.Length; i++)
{
if (hostlist[i].StartsWith("*"))
{

View File

@@ -12,14 +12,12 @@ namespace NzbDrone.Common.Http
if (response.Headers.ContainsKey("Retry-After"))
{
var retryAfter = response.Headers["Retry-After"].ToString();
int seconds;
DateTime date;
if (int.TryParse(retryAfter, out seconds))
if (int.TryParse(retryAfter, out var seconds))
{
RetryAfter = TimeSpan.FromSeconds(seconds);
}
else if (DateTime.TryParse(retryAfter, out date))
else if (DateTime.TryParse(retryAfter, out var date))
{
RetryAfter = date.ToUniversalTime() - DateTime.UtcNow;
}

View File

@@ -1,4 +1,4 @@
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Linq;
using NzbDrone.Common.Serializer;
namespace NzbDrone.Common.Instrumentation
@@ -16,7 +16,7 @@ namespace NzbDrone.Common.Instrumentation
}
}
foreach (JToken token in json)
foreach (var token in json)
{
Visit(token);
}

View File

@@ -94,7 +94,7 @@ namespace NzbDrone.Common.Instrumentation
private static void RegisterDebugger()
{
DebuggerTarget target = new DebuggerTarget();
var target = new DebuggerTarget();
target.Name = "debuggerLogger";
target.Layout = "[${level}] [${threadid}] ${logger}: ${message} ${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}${exception:format=Data}${newline}}";
@@ -108,9 +108,10 @@ namespace NzbDrone.Common.Instrumentation
{
LogManager.Setup().LoadConfiguration(c =>
{
c.ForLogger("System.*").WriteToNil(LogLevel.Warn);
c.ForLogger("Microsoft.*").WriteToNil(LogLevel.Warn);
c.ForLogger("Microsoft.Hosting.Lifetime*").WriteToNil(LogLevel.Info);
c.ForLogger("System*").WriteToNil(LogLevel.Warn);
c.ForLogger("Microsoft*").WriteToNil(LogLevel.Warn);
c.ForLogger("Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware").WriteToNil(LogLevel.Fatal);
});
}

View File

@@ -29,7 +29,7 @@ namespace NzbDrone.Common.OAuth
public virtual string Version { get; set; }
public virtual string SessionHandle { get; set; }
/// <seealso cref="http://oauth.net/core/1.0#request_urls"/>
/// <seealso href="http://oauth.net/core/1.0#request_urls"/>
public virtual string RequestUrl { get; set; }
#if !WINRT

View File

@@ -14,14 +14,14 @@ namespace NzbDrone.Common.OAuth
{
get
{
var parameters = this.Where(p => p.Name.Equals(name));
var parameters = this.Where(p => p.Name.Equals(name)).ToArray();
if (!parameters.Any())
{
return null;
}
if (parameters.Count() == 1)
if (parameters.Length == 1)
{
return parameters.Single();
}

View File

@@ -1,4 +1,4 @@
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Linq;
namespace NzbDrone.Common.Serializer
{
@@ -60,7 +60,7 @@ namespace NzbDrone.Common.Serializer
public virtual void Visit(JArray json)
{
foreach (JToken token in json)
foreach (var token in json)
{
Visit(token);
}
@@ -72,7 +72,7 @@ namespace NzbDrone.Common.Serializer
public virtual void Visit(JObject json)
{
foreach (JProperty property in json.Properties())
foreach (var property in json.Properties())
{
Visit(property);
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Text;
using Newtonsoft.Json;
@@ -42,7 +42,7 @@ namespace NzbDrone.Common.Serializer
var enumText = value.ToString();
var builder = new StringBuilder(enumText.Length + 4);
builder.Append(char.ToLower(enumText[0]));
for (int i = 1; i < enumText.Length; i++)
for (var i = 1; i < enumText.Length; i++)
{
if (char.IsUpper(enumText[i]))
{

View File

@@ -18,7 +18,7 @@ namespace NzbDrone.Common.Serializer
{
try
{
Version v = new Version(reader.GetString());
var v = new Version(reader.GetString());
return v;
}
catch (Exception)

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -137,7 +137,7 @@ namespace NzbDrone.Common.TPL
/// <returns>An enumerable of the tasks currently scheduled.</returns>
protected sealed override IEnumerable<Task> GetScheduledTasks()
{
bool lockTaken = false;
var lockTaken = false;
try
{
Monitor.TryEnter(_tasks, ref lockTaken);

View File

@@ -110,7 +110,7 @@ namespace NzbDrone.Console
}
System.Console.WriteLine("Non-recoverable failure, waiting for user intervention...");
for (int i = 0; i < 3600; i++)
for (var i = 0; i < 3600; i++)
{
System.Threading.Thread.Sleep(1000);
if (!System.Console.IsInputRedirected && System.Console.KeyAvailable)

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