1
0
mirror of https://github.com/Radarr/Radarr.git synced 2024-08-16 23:39:44 +02:00

New: Bulk Manage Import Lists, Indexers, Clients

(cherry picked from commit 73ccab53d5194282de4b983354c9afa5a6d678fb)
This commit is contained in:
Qstick 2023-05-24 23:13:27 -05:00
parent 5baeba18cb
commit 1d4b6d4cad
71 changed files with 3058 additions and 116 deletions

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector'; import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector';
import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal';
import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector'; import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector';
import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector'; import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector';
@ -23,7 +24,8 @@ class DownloadClientSettings extends Component {
this.state = { this.state = {
isSaving: false, isSaving: false,
hasPendingChanges: false hasPendingChanges: false,
isManageDownloadClientsOpen: false
}; };
} }
@ -38,6 +40,14 @@ class DownloadClientSettings extends Component {
this.setState(payload); this.setState(payload);
}; };
onManageDownloadClientsPress = () => {
this.setState({ isManageDownloadClientsOpen: true });
};
onManageDownloadClientsModalClose = () => {
this.setState({ isManageDownloadClientsOpen: false });
};
onSavePress = () => { onSavePress = () => {
if (this._saveCallback) { if (this._saveCallback) {
this._saveCallback(); this._saveCallback();
@ -55,7 +65,8 @@ class DownloadClientSettings extends Component {
const { const {
isSaving, isSaving,
hasPendingChanges hasPendingChanges,
isManageDownloadClientsOpen
} = this.state; } = this.state;
return ( return (
@ -73,6 +84,12 @@ class DownloadClientSettings extends Component {
isSpinning={isTestingAll} isSpinning={isTestingAll}
onPress={dispatchTestAllDownloadClients} onPress={dispatchTestAllDownloadClients}
/> />
<PageToolbarButton
label="Manage Clients"
iconName={icons.MANAGE}
onPress={this.onManageDownloadClientsPress}
/>
</Fragment> </Fragment>
} }
onSavePress={this.onSavePress} onSavePress={this.onSavePress}
@ -87,6 +104,11 @@ class DownloadClientSettings extends Component {
/> />
<RemotePathMappingsConnector /> <RemotePathMappingsConnector />
<ManageDownloadClientsModal
isOpen={isManageDownloadClientsOpen}
onModalClose={this.onManageDownloadClientsModalClose}
/>
</PageContentBody> </PageContentBody>
</PageContent> </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<string | number>(NO_CHANGE);
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 !== NO_CHANGE) {
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,87 @@
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} title={name}>
{name}
</TableRowCell>
<TableRowCell className={styles.implementation} title={implementation}>
{implementation}
</TableRowCell>
<TableRowCell className={styles.enable} title={enable}>
{enable ? 'Yes' : 'No'}
</TableRowCell>
<TableRowCell className={styles.priority} title={priority}>
{priority}
</TableRowCell>
<TableRowCell
className={styles.removeCompletedDownloads}
title={removeCompletedDownloads}
>
{removeCompletedDownloads ? 'Yes' : 'No'}
</TableRowCell>
<TableRowCell
className={styles.removeFailedDownloads}
title={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 translate from 'Utilities/String/translate';
import ImportListExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector'; import ImportListExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector';
import ImportListsConnector from './ImportLists/ImportListsConnector'; import ImportListsConnector from './ImportLists/ImportListsConnector';
import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal';
import ImportListOptionsConnector from './Options/ImportListOptionsConnector'; import ImportListOptionsConnector from './Options/ImportListOptionsConnector';
class ImportListSettings extends Component { class ImportListSettings extends Component {
@ -23,7 +24,8 @@ class ImportListSettings extends Component {
this.state = { this.state = {
isSaving: false, isSaving: false,
hasPendingChanges: false hasPendingChanges: false,
isManageImportListsOpen: false
}; };
} }
@ -38,6 +40,14 @@ class ImportListSettings extends Component {
this.setState(payload); this.setState(payload);
}; };
onManageImportListsPress = () => {
this.setState({ isManageImportListsOpen: true });
};
onManageImportListsModalClose = () => {
this.setState({ isManageImportListsOpen: false });
};
onSavePress = () => { onSavePress = () => {
if (this._saveCallback) { if (this._saveCallback) {
this._saveCallback(); this._saveCallback();
@ -55,7 +65,8 @@ class ImportListSettings extends Component {
const { const {
isSaving, isSaving,
hasPendingChanges hasPendingChanges,
isManageImportListsOpen
} = this.state; } = this.state;
return ( return (
@ -73,6 +84,12 @@ class ImportListSettings extends Component {
isSpinning={isTestingAll} isSpinning={isTestingAll}
onPress={dispatchTestAllImportList} onPress={dispatchTestAllImportList}
/> />
<PageToolbarButton
label="Manage Lists"
iconName={icons.MANAGE}
onPress={this.onManageImportListsPress}
/>
</Fragment> </Fragment>
} }
onSavePress={this.onSavePress} onSavePress={this.onSavePress}
@ -88,6 +105,11 @@ class ImportListSettings extends Component {
<ImportListExclusionsConnector /> <ImportListExclusionsConnector />
<ManageImportListsModal
isOpen={isManageImportListsOpen}
onModalClose={this.onManageImportListsModalClose}
/>
</PageContentBody> </PageContentBody>
</PageContent> </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,89 @@
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} title={name}>
{name}
</TableRowCell>
<TableRowCell className={styles.implementation} title={implementation}>
{implementation}
</TableRowCell>
<TableRowCell className={styles.qualityProfileId}>
{qualityProfile?.name ?? 'None'}
</TableRowCell>
<TableRowCell className={styles.rootFolderPath} title={rootFolderPath}>
{rootFolderPath}
</TableRowCell>
<TableRowCell
className={styles.enableAuto}
title={enableAuto}
>
{enableAuto ? 'Yes' : 'No'}
</TableRowCell>
<TableRowCell className={styles.tags} title={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 SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import IndexersConnector from './Indexers/IndexersConnector'; import IndexersConnector from './Indexers/IndexersConnector';
import ManageIndexersModal from './Indexers/Manage/ManageIndexersModal';
import IndexerOptionsConnector from './Options/IndexerOptionsConnector'; import IndexerOptionsConnector from './Options/IndexerOptionsConnector';
import RestrictionsConnector from './Restrictions/RestrictionsConnector'; import RestrictionsConnector from './Restrictions/RestrictionsConnector';
@ -23,7 +24,8 @@ class IndexerSettings extends Component {
this.state = { this.state = {
isSaving: false, isSaving: false,
hasPendingChanges: false hasPendingChanges: false,
isManageIndexersOpen: false
}; };
} }
@ -38,6 +40,14 @@ class IndexerSettings extends Component {
this.setState(payload); this.setState(payload);
}; };
onManageIndexersPress = () => {
this.setState({ isManageIndexersOpen: true });
};
onManageIndexersModalClose = () => {
this.setState({ isManageIndexersOpen: false });
};
onSavePress = () => { onSavePress = () => {
if (this._saveCallback) { if (this._saveCallback) {
this._saveCallback(); this._saveCallback();
@ -55,7 +65,8 @@ class IndexerSettings extends Component {
const { const {
isSaving, isSaving,
hasPendingChanges hasPendingChanges,
isManageIndexersOpen
} = this.state; } = this.state;
return ( return (
@ -73,6 +84,12 @@ class IndexerSettings extends Component {
isSpinning={isTestingAll} isSpinning={isTestingAll}
onPress={dispatchTestAllIndexers} onPress={dispatchTestAllIndexers}
/> />
<PageToolbarButton
label="Manage Indexers"
iconName={icons.MANAGE}
onPress={this.onManageIndexersPress}
/>
</Fragment> </Fragment>
} }
onSavePress={this.onSavePress} onSavePress={this.onSavePress}
@ -87,6 +104,11 @@ class IndexerSettings extends Component {
/> />
<RestrictionsConnector /> <RestrictionsConnector />
<ManageIndexersModal
isOpen={isManageIndexersOpen}
onModalClose={this.onManageIndexersModalClose}
/>
</PageContentBody> </PageContentBody>
</PageContent> </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<string | number>(NO_CHANGE);
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 !== NO_CHANGE) {
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,92 @@
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} title={name}>
{name}
</TableRowCell>
<TableRowCell className={styles.implementation} title={implementation}>
{implementation}
</TableRowCell>
<TableRowCell className={styles.enableRss} title={enableRss}>
{enableRss ? 'Yes' : 'No'}
</TableRowCell>
<TableRowCell
className={styles.enableAutomaticSearch}
title={enableAutomaticSearch}
>
{enableAutomaticSearch ? 'Yes' : 'No'}
</TableRowCell>
<TableRowCell
className={styles.enableInteractiveSearch}
title={enableInteractiveSearch}
>
{enableInteractiveSearch ? 'Yes' : 'No'}
</TableRowCell>
<TableRowCell className={styles.priority} title={priority}>
{priority}
</TableRowCell>
<TableRowCell className={styles.tags} title={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

@ -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

@ -1,4 +1,6 @@
import { createAction } from 'redux-actions'; 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 createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; 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 CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient';
export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients'; 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 // Action Creators
@ -44,6 +49,9 @@ export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT);
export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT); export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT);
export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS); 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) => { export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => {
return { return {
section, section,
@ -95,7 +103,9 @@ export default {
[DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'), [DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'),
[TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'), [TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'),
[CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section), [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 { 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 createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; 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 CANCEL_TEST_IMPORT_LIST = 'settings/importLists/cancelTestImportList';
export const TEST_ALL_IMPORT_LIST = 'settings/importLists/testAllImportList'; 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 // Action Creators
@ -44,6 +49,9 @@ export const testImportList = createThunk(TEST_IMPORT_LIST);
export const cancelTestImportList = createThunk(CANCEL_TEST_IMPORT_LIST); export const cancelTestImportList = createThunk(CANCEL_TEST_IMPORT_LIST);
export const testAllImportList = createThunk(TEST_ALL_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) => { export const setImportListValue = createAction(SET_IMPORT_LIST_VALUE, (payload) => {
return { return {
section, section,
@ -95,7 +103,10 @@ export default {
[DELETE_IMPORT_LIST]: createRemoveItemHandler(section, '/importlist'), [DELETE_IMPORT_LIST]: createRemoveItemHandler(section, '/importlist'),
[TEST_IMPORT_LIST]: createTestProviderHandler(section, '/importlist'), [TEST_IMPORT_LIST]: createTestProviderHandler(section, '/importlist'),
[CANCEL_TEST_IMPORT_LIST]: createCancelTestProviderHandler(section), [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 getSectionState from 'Utilities/State/getSectionState';
import selectProviderSchema from 'Utilities/State/selectProviderSchema'; import selectProviderSchema from 'Utilities/State/selectProviderSchema';
import updateSectionState from 'Utilities/State/updateSectionState'; import updateSectionState from 'Utilities/State/updateSectionState';
import createBulkEditItemHandler from '../Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from '../Creators/createBulkRemoveItemHandler';
// //
// Variables // Variables
@ -33,6 +35,9 @@ export const TEST_INDEXER = 'settings/indexers/testIndexer';
export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer'; export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer';
export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers'; 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 // Action Creators
@ -48,6 +53,9 @@ export const testIndexer = createThunk(TEST_INDEXER);
export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER);
export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); 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) => { export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => {
return { return {
section, section,
@ -99,7 +107,10 @@ export default {
[DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'), [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'),
[TEST_INDEXER]: createTestProviderHandler(section, '/indexer'), [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'),
[CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section), [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'; 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() { function createQualityProfileSelector() {
return createSelector( return createSelector(
(state, { qualityProfileId }) => qualityProfileId, (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

@ -7,9 +7,12 @@
"AddCustomFormat": "Add Custom Format", "AddCustomFormat": "Add Custom Format",
"AddDelayProfile": "Add Delay Profile", "AddDelayProfile": "Add Delay Profile",
"AddDownloadClient": "Add Download Client", "AddDownloadClient": "Add Download Client",
"Added": "Added",
"AddedToDownloadQueue": "Added to download queue",
"AddExclusion": "Add Exclusion", "AddExclusion": "Add Exclusion",
"AddImportExclusionHelpText": "Prevent movie from being added to Radarr by lists", "AddImportExclusionHelpText": "Prevent movie from being added to Radarr by lists",
"AddIndexer": "Add Indexer", "AddIndexer": "Add Indexer",
"AddingTag": "Adding tag",
"AddList": "Add List", "AddList": "Add List",
"AddListExclusion": "Add List Exclusion", "AddListExclusion": "Add List Exclusion",
"AddMovie": "Add Movie", "AddMovie": "Add Movie",
@ -24,21 +27,18 @@
"AddRestriction": "Add Restriction", "AddRestriction": "Add Restriction",
"AddRootFolder": "Add Root Folder", "AddRootFolder": "Add Root Folder",
"AddToDownloadQueue": "Add to download queue", "AddToDownloadQueue": "Add to download queue",
"Added": "Added",
"AddedToDownloadQueue": "Added to download queue",
"AddingTag": "Adding tag",
"AfterManualRefresh": "After Manual Refresh", "AfterManualRefresh": "After Manual Refresh",
"Age": "Age", "Age": "Age",
"AgeWhenGrabbed": "Age (when grabbed)",
"Agenda": "Agenda", "Agenda": "Agenda",
"AgeWhenGrabbed": "Age (when grabbed)",
"All": "All", "All": "All",
"AllCollectionsHiddenDueToFilter": "All collections are hidden due to applied filter.", "AllCollectionsHiddenDueToFilter": "All collections are hidden due to applied filter.",
"AllFiles": "All Files", "AllFiles": "All Files",
"AllMoviesHiddenDueToFilter": "All movies are hidden due to applied filter.", "AllMoviesHiddenDueToFilter": "All movies are hidden due to applied filter.",
"AllMoviesInPathHaveBeenImported": "All movies in {0} have been imported", "AllMoviesInPathHaveBeenImported": "All movies in {0} have been imported",
"AllResultsHiddenFilter": "All results are hidden by the applied filter",
"AllowHardcodedSubs": "Allow Hardcoded Subs", "AllowHardcodedSubs": "Allow Hardcoded Subs",
"AllowHardcodedSubsHelpText": "Detected hardcoded subs will be automatically downloaded", "AllowHardcodedSubsHelpText": "Detected hardcoded subs will be automatically downloaded",
"AllResultsHiddenFilter": "All results are hidden by the applied filter",
"AlreadyInYourLibrary": "Already in your library", "AlreadyInYourLibrary": "Already in your library",
"AlternativeTitle": "Alternative Title", "AlternativeTitle": "Alternative Title",
"Always": "Always", "Always": "Always",
@ -72,14 +72,14 @@
"AsAllDayHelpText": "Events will appear as all-day events in your calendar", "AsAllDayHelpText": "Events will appear as all-day events in your calendar",
"AudioInfo": "Audio Info", "AudioInfo": "Audio Info",
"AuthBasic": "Basic (Browser Popup)", "AuthBasic": "Basic (Browser Popup)",
"AuthForm": "Forms (Login Page)",
"Authentication": "Authentication", "Authentication": "Authentication",
"AuthenticationMethodHelpText": "Require Username and Password to access Radarr", "AuthenticationMethodHelpText": "Require Username and Password to access Radarr",
"AuthForm": "Forms (Login Page)",
"Auto": "Auto", "Auto": "Auto",
"AutoRedownloadFailedHelpText": "Automatically search for and attempt to download a different release",
"AutoUnmonitorPreviouslyDownloadedMoviesHelpText": "Movies deleted from the disk are automatically unmonitored in Radarr",
"Automatic": "Automatic", "Automatic": "Automatic",
"AutomaticSearch": "Automatic Search", "AutomaticSearch": "Automatic Search",
"AutoRedownloadFailedHelpText": "Automatically search for and attempt to download a different release",
"AutoUnmonitorPreviouslyDownloadedMoviesHelpText": "Movies deleted from the disk are automatically unmonitored in Radarr",
"AvailabilityDelay": "Availability Delay", "AvailabilityDelay": "Availability Delay",
"AvailabilityDelayHelpText": "Amount of time before or after available date to search for Movie", "AvailabilityDelayHelpText": "Amount of time before or after available date to search for Movie",
"Backup": "Backup", "Backup": "Backup",
@ -92,10 +92,10 @@
"BindAddress": "Bind Address", "BindAddress": "Bind Address",
"BindAddressHelpText": "Valid IP address, localhost or '*' for all interfaces", "BindAddressHelpText": "Valid IP address, localhost or '*' for all interfaces",
"Blocklist": "Blocklist", "Blocklist": "Blocklist",
"Blocklisted": "Blocklisted",
"BlocklistHelpText": "Prevents Radarr from automatically grabbing this release again", "BlocklistHelpText": "Prevents Radarr from automatically grabbing this release again",
"BlocklistRelease": "Blocklist Release", "BlocklistRelease": "Blocklist Release",
"BlocklistReleases": "Blocklist Releases", "BlocklistReleases": "Blocklist Releases",
"Blocklisted": "Blocklisted",
"Branch": "Branch", "Branch": "Branch",
"BranchUpdate": "Branch to use to update Radarr", "BranchUpdate": "Branch to use to update Radarr",
"BranchUpdateMechanism": "Branch used by external update mechanism", "BranchUpdateMechanism": "Branch used by external update mechanism",
@ -110,12 +110,12 @@
"CancelProcessing": "Cancel Processing", "CancelProcessing": "Cancel Processing",
"CantFindMovie": "Why can't I find my movie?", "CantFindMovie": "Why can't I find my movie?",
"Cast": "Cast", "Cast": "Cast",
"CertValidationNoLocal": "Disabled for Local Addresses",
"CertificateValidation": "Certificate Validation", "CertificateValidation": "Certificate Validation",
"CertificateValidationHelpText": "Change how strict HTTPS certification validation is. Do not change unless you understand the risks.", "CertificateValidationHelpText": "Change how strict HTTPS certification validation is. Do not change unless you understand the risks.",
"Certification": "Certification", "Certification": "Certification",
"CertificationCountry": "Certification Country", "CertificationCountry": "Certification Country",
"CertificationCountryHelpText": "Select Country for Movie Certifications", "CertificationCountryHelpText": "Select Country for Movie Certifications",
"CertValidationNoLocal": "Disabled for Local Addresses",
"ChangeFileDate": "Change File Date", "ChangeFileDate": "Change File Date",
"ChangeHasNotBeenSavedYet": "Change has not been saved yet", "ChangeHasNotBeenSavedYet": "Change has not been saved yet",
"CheckDownloadClientForDetails": "check download client for more details", "CheckDownloadClientForDetails": "check download client for more details",
@ -143,10 +143,10 @@
"CloseCurrentModal": "Close Current Modal", "CloseCurrentModal": "Close Current Modal",
"Collection": "Collection", "Collection": "Collection",
"CollectionOptions": "Collection Options", "CollectionOptions": "Collection Options",
"Collections": "Collections",
"CollectionShowDetailsHelpText": "Show collection status and properties", "CollectionShowDetailsHelpText": "Show collection status and properties",
"CollectionShowOverviewsHelpText": "Show collection overviews", "CollectionShowOverviewsHelpText": "Show collection overviews",
"CollectionShowPostersHelpText": "Show Collection item posters", "CollectionShowPostersHelpText": "Show Collection item posters",
"Collections": "Collections",
"CollectionsSelectedInterp": "{0} Collections(s) Selected", "CollectionsSelectedInterp": "{0} Collections(s) Selected",
"ColonReplacement": "Colon Replacement", "ColonReplacement": "Colon Replacement",
"ColonReplacementFormatHelpText": "Change how Radarr handles colon replacement", "ColonReplacementFormatHelpText": "Change how Radarr handles colon replacement",
@ -155,13 +155,13 @@
"Component": "Component", "Component": "Component",
"Conditions": "Conditions", "Conditions": "Conditions",
"Connect": "Connect", "Connect": "Connect",
"ConnectSettings": "Connect Settings",
"ConnectSettingsSummary": "Notifications, connections to media servers/players, and custom scripts",
"Connection": "Connection", "Connection": "Connection",
"ConnectionLost": "Connection Lost", "ConnectionLost": "Connection Lost",
"ConnectionLostAutomaticMessage": "Radarr will try to connect automatically, or you can click reload below.", "ConnectionLostAutomaticMessage": "Radarr will try to connect automatically, or you can click reload below.",
"ConnectionLostMessage": "Radarr has lost its connection to the backend and will need to be reloaded to restore functionality.", "ConnectionLostMessage": "Radarr has lost its connection to the backend and will need to be reloaded to restore functionality.",
"Connections": "Connections", "Connections": "Connections",
"ConnectSettings": "Connect Settings",
"ConnectSettingsSummary": "Notifications, connections to media servers/players, and custom scripts",
"ConsideredAvailable": "Considered Available", "ConsideredAvailable": "Considered Available",
"CopyToClipboard": "Copy to Clipboard", "CopyToClipboard": "Copy to Clipboard",
"CopyUsingHardlinksHelpText": "Hardlinks allow Radarr to import seeding torrents to the movie folder without taking extra disk space or copying the entire contents of the file. Hardlinks will only work if the source and destination are on the same volume", "CopyUsingHardlinksHelpText": "Hardlinks allow Radarr to import seeding torrents to the movie folder without taking extra disk space or copying the entire contents of the file. Hardlinks will only work if the source and destination are on the same volume",
@ -178,33 +178,35 @@
"CustomFormat": "Custom Format", "CustomFormat": "Custom Format",
"CustomFormatHelpText": "Radarr scores each release using the sum of scores for matching custom formats. If a new release would improve the score, at the same or better quality, then Radarr will grab it.", "CustomFormatHelpText": "Radarr scores each release using the sum of scores for matching custom formats. If a new release would improve the score, at the same or better quality, then Radarr will grab it.",
"CustomFormatJSON": "Custom Format JSON", "CustomFormatJSON": "Custom Format JSON",
"CustomFormatScore": "Custom Format Score",
"CustomFormatUnknownCondition": "Unknown Custom Format condition '{0}'",
"CustomFormatUnknownConditionOption": "Unknown option '{0}' for condition '{1}'",
"CustomFormats": "Custom Formats", "CustomFormats": "Custom Formats",
"CustomFormatScore": "Custom Format Score",
"CustomFormatsSettings": "Custom Formats Settings", "CustomFormatsSettings": "Custom Formats Settings",
"CustomFormatsSettingsSummary": "Custom Formats and Settings", "CustomFormatsSettingsSummary": "Custom Formats and Settings",
"CustomFormatUnknownCondition": "Unknown Custom Format condition '{0}'",
"CustomFormatUnknownConditionOption": "Unknown option '{0}' for condition '{1}'",
"Cutoff": "Cutoff", "Cutoff": "Cutoff",
"CutoffFormatScoreHelpText": "Once this custom format score is reached Radarr will no longer download movies", "CutoffFormatScoreHelpText": "Once this custom format score is reached Radarr will no longer download movies",
"CutoffHelpText": "Once this quality is reached Radarr will no longer download movies", "CutoffHelpText": "Once this quality is reached Radarr will no longer download movies",
"CutoffUnmet": "Cut-off Unmet", "CutoffUnmet": "Cut-off Unmet",
"DBMigration": "DB Migration",
"Database": "Database", "Database": "Database",
"Date": "Date", "Date": "Date",
"Dates": "Dates", "Dates": "Dates",
"Day": "Day", "Day": "Day",
"Days": "Days", "Days": "Days",
"DBMigration": "DB Migration",
"Debug": "Debug", "Debug": "Debug",
"DefaultCase": "Default Case", "DefaultCase": "Default Case",
"DefaultDelayProfile": "This is the default profile. It applies to all movies that don't have an explicit profile.", "DefaultDelayProfile": "This is the default profile. It applies to all movies that don't have an explicit profile.",
"DelayingDownloadUntilInterp": "Delaying download until {0} at {1}",
"DelayProfile": "Delay Profile", "DelayProfile": "Delay Profile",
"DelayProfiles": "Delay Profiles", "DelayProfiles": "Delay Profiles",
"DelayingDownloadUntilInterp": "Delaying download until {0} at {1}",
"Delete": "Delete", "Delete": "Delete",
"DeleteBackup": "Delete Backup", "DeleteBackup": "Delete Backup",
"DeleteBackupMessageText": "Are you sure you want to delete the backup '{0}'?", "DeleteBackupMessageText": "Are you sure you want to delete the backup '{0}'?",
"DeleteCustomFormat": "Delete Custom Format", "DeleteCustomFormat": "Delete Custom Format",
"Deleted": "Deleted",
"DeleteDelayProfile": "Delete Delay Profile", "DeleteDelayProfile": "Delete Delay Profile",
"DeletedMsg": "Movie was deleted from TMDb",
"DeleteDownloadClient": "Delete Download Client", "DeleteDownloadClient": "Delete Download Client",
"DeleteDownloadClientMessageText": "Are you sure you want to delete the download client '{0}'?", "DeleteDownloadClientMessageText": "Are you sure you want to delete the download client '{0}'?",
"DeleteEmptyFolders": "Delete empty folders", "DeleteEmptyFolders": "Delete empty folders",
@ -232,8 +234,6 @@
"DeleteTag": "Delete Tag", "DeleteTag": "Delete Tag",
"DeleteTagMessageText": "Are you sure you want to delete the tag '{0}'?", "DeleteTagMessageText": "Are you sure you want to delete the tag '{0}'?",
"DeleteTheMovieFolder": "The movie folder '{0}' and all its content will be deleted.", "DeleteTheMovieFolder": "The movie folder '{0}' and all its content will be deleted.",
"Deleted": "Deleted",
"DeletedMsg": "Movie was deleted from TMDb",
"DestinationPath": "Destination Path", "DestinationPath": "Destination Path",
"DestinationRelativePath": "Destination Relative Path", "DestinationRelativePath": "Destination Relative Path",
"DetailedProgressBar": "Detailed Progress Bar", "DetailedProgressBar": "Detailed Progress Bar",
@ -245,37 +245,37 @@
"DiscordUrlInSlackNotification": "You have a Discord notification setup as a Slack notification. Set this up as a Discord notification for better functionality. The effected notifications are: {0}", "DiscordUrlInSlackNotification": "You have a Discord notification setup as a Slack notification. Set this up as a Discord notification for better functionality. The effected notifications are: {0}",
"Discover": "Discover", "Discover": "Discover",
"DiskSpace": "Disk Space", "DiskSpace": "Disk Space",
"DoNotPrefer": "Do Not Prefer",
"DoNotUpgradeAutomatically": "Do not Upgrade Automatically",
"Docker": "Docker", "Docker": "Docker",
"DockerUpdater": "update the docker container to receive the update", "DockerUpdater": "update the docker container to receive the update",
"Donations": "Donations", "Donations": "Donations",
"DoneEditingGroups": "Done Editing Groups", "DoneEditingGroups": "Done Editing Groups",
"DoNotPrefer": "Do Not Prefer",
"DoNotUpgradeAutomatically": "Do not Upgrade Automatically",
"Download": "Download", "Download": "Download",
"DownloadClient": "Download Client", "DownloadClient": "Download Client",
"DownloadClientCheckDownloadingToRoot": "Download client {0} places downloads in the root folder {1}. You should not download to a root folder.", "DownloadClientCheckDownloadingToRoot": "Download client {0} places downloads in the root folder {1}. You should not download to a root folder.",
"DownloadClientCheckNoneAvailableMessage": "No download client is available", "DownloadClientCheckNoneAvailableMessage": "No download client is available",
"DownloadClientCheckUnableToCommunicateMessage": "Unable to communicate with {0}.", "DownloadClientCheckUnableToCommunicateMessage": "Unable to communicate with {0}.",
"DownloadClients": "Download Clients",
"DownloadClientSettings": "Download Client Settings", "DownloadClientSettings": "Download Client Settings",
"DownloadClientSortingCheckMessage": "Download client {0} has {1} sorting enabled for Radarr's category. You should disable sorting in your download client to avoid import issues.", "DownloadClientSortingCheckMessage": "Download client {0} has {1} sorting enabled for Radarr's category. You should disable sorting in your download client to avoid import issues.",
"DownloadClientsSettingsSummary": "Download clients, download handling and remote path mappings",
"DownloadClientStatusCheckAllClientMessage": "All download clients are unavailable due to failures", "DownloadClientStatusCheckAllClientMessage": "All download clients are unavailable due to failures",
"DownloadClientStatusCheckSingleClientMessage": "Download clients unavailable due to failures: {0}", "DownloadClientStatusCheckSingleClientMessage": "Download clients unavailable due to failures: {0}",
"DownloadClientUnavailable": "Download client is unavailable", "DownloadClientUnavailable": "Download client is unavailable",
"DownloadClients": "Download Clients", "Downloaded": "Downloaded",
"DownloadClientsSettingsSummary": "Download clients, download handling and remote path mappings", "DownloadedAndMonitored": "Downloaded (Monitored)",
"DownloadedButNotMonitored": "Downloaded (Unmonitored)",
"DownloadFailed": "Download failed", "DownloadFailed": "Download failed",
"DownloadFailedCheckDownloadClientForMoreDetails": "Download failed: check download client for more details", "DownloadFailedCheckDownloadClientForMoreDetails": "Download failed: check download client for more details",
"DownloadFailedInterp": "Download failed: {0}", "DownloadFailedInterp": "Download failed: {0}",
"Downloading": "Downloading",
"DownloadPropersAndRepacks": "Propers and Repacks", "DownloadPropersAndRepacks": "Propers and Repacks",
"DownloadPropersAndRepacksHelpText1": "Whether or not to automatically upgrade to Propers/Repacks", "DownloadPropersAndRepacksHelpText1": "Whether or not to automatically upgrade to Propers/Repacks",
"DownloadPropersAndRepacksHelpText2": "Use 'Do not Prefer' to sort by custom format score over Propers/Repacks", "DownloadPropersAndRepacksHelpText2": "Use 'Do not Prefer' to sort by custom format score over Propers/Repacks",
"DownloadPropersAndRepacksHelpTextWarning": "Use custom formats for automatic upgrades to Propers/Repacks", "DownloadPropersAndRepacksHelpTextWarning": "Use custom formats for automatic upgrades to Propers/Repacks",
"DownloadWarning": "Download warning: {0}", "DownloadWarning": "Download warning: {0}",
"DownloadWarningCheckDownloadClientForMoreDetails": "Download warning: check download client for more details", "DownloadWarningCheckDownloadClientForMoreDetails": "Download warning: check download client for more details",
"Downloaded": "Downloaded",
"DownloadedAndMonitored": "Downloaded (Monitored)",
"DownloadedButNotMonitored": "Downloaded (Unmonitored)",
"Downloading": "Downloading",
"Duration": "Duration", "Duration": "Duration",
"Edit": "Edit", "Edit": "Edit",
"EditCollection": "Edit Collection", "EditCollection": "Edit Collection",
@ -283,6 +283,7 @@
"EditDelayProfile": "Edit Delay Profile", "EditDelayProfile": "Edit Delay Profile",
"EditGroups": "Edit Groups", "EditGroups": "Edit Groups",
"EditIndexer": "Edit Indexer", "EditIndexer": "Edit Indexer",
"Edition": "Edition",
"EditListExclusion": "Edit List Exclusion", "EditListExclusion": "Edit List Exclusion",
"EditMovie": "Edit Movie", "EditMovie": "Edit Movie",
"EditMovieFile": "Edit Movie File", "EditMovieFile": "Edit Movie File",
@ -292,7 +293,6 @@
"EditRemotePathMapping": "Edit Remote Path Mapping", "EditRemotePathMapping": "Edit Remote Path Mapping",
"EditRestriction": "Edit Restriction", "EditRestriction": "Edit Restriction",
"EditSelectedMovies": "Edit Selected Movies", "EditSelectedMovies": "Edit Selected Movies",
"Edition": "Edition",
"Enable": "Enable", "Enable": "Enable",
"EnableAutoHelpText": "If enabled, Movies will be automatically added to Radarr from this list", "EnableAutoHelpText": "If enabled, Movies will be automatically added to Radarr from this list",
"EnableAutomaticAdd": "Enable Automatic Add", "EnableAutomaticAdd": "Enable Automatic Add",
@ -302,6 +302,8 @@
"EnableColorImpairedMode": "Enable Color-Impaired Mode", "EnableColorImpairedMode": "Enable Color-Impaired Mode",
"EnableColorImpairedModeHelpText": "Altered style to allow color-impaired users to better distinguish color coded information", "EnableColorImpairedModeHelpText": "Altered style to allow color-impaired users to better distinguish color coded information",
"EnableCompletedDownloadHandlingHelpText": "Automatically import completed downloads from download client", "EnableCompletedDownloadHandlingHelpText": "Automatically import completed downloads from download client",
"Enabled": "Enabled",
"EnabledHelpText": "Enable this list for use in Radarr",
"EnableHelpText": "Enable metadata file creation for this metadata type", "EnableHelpText": "Enable metadata file creation for this metadata type",
"EnableInteractiveSearch": "Enable Interactive Search", "EnableInteractiveSearch": "Enable Interactive Search",
"EnableInteractiveSearchHelpText": "Will be used when interactive search is used", "EnableInteractiveSearchHelpText": "Will be used when interactive search is used",
@ -310,19 +312,17 @@
"EnableRSS": "Enable RSS", "EnableRSS": "Enable RSS",
"EnableSSL": "Enable SSL", "EnableSSL": "Enable SSL",
"EnableSslHelpText": " Requires restart running as administrator to take effect", "EnableSslHelpText": " Requires restart running as administrator to take effect",
"Enabled": "Enabled",
"EnabledHelpText": "Enable this list for use in Radarr",
"Ended": "Ended", "Ended": "Ended",
"Error": "Error", "Error": "Error",
"ErrorLoadingContents": "Error loading contents", "ErrorLoadingContents": "Error loading contents",
"ErrorLoadingPreviews": "Error loading previews", "ErrorLoadingPreviews": "Error loading previews",
"ErrorRestoringBackup": "Error restoring backup", "ErrorRestoringBackup": "Error restoring backup",
"EventType": "Event Type",
"Events": "Events", "Events": "Events",
"EventType": "Event Type",
"Exception": "Exception", "Exception": "Exception",
"Excluded": "Excluded",
"ExcludeMovie": "Exclude Movie", "ExcludeMovie": "Exclude Movie",
"ExcludeTitle": "Exclude {0}? This will prevent Radarr from automatically adding via list sync.", "ExcludeTitle": "Exclude {0}? This will prevent Radarr from automatically adding via list sync.",
"Excluded": "Excluded",
"Existing": "Existing", "Existing": "Existing",
"ExistingMovies": "Existing Movie(s)", "ExistingMovies": "Existing Movie(s)",
"ExistingTag": "Existing tag", "ExistingTag": "Existing tag",
@ -340,12 +340,12 @@
"File": "File", "File": "File",
"FileDateHelpText": "Change file date on import/rescan", "FileDateHelpText": "Change file date on import/rescan",
"FileManagement": "File Management", "FileManagement": "File Management",
"FileNameTokens": "File Name Tokens", "Filename": "Filename",
"FileNames": "File Names", "FileNames": "File Names",
"FileNameTokens": "File Name Tokens",
"Files": "Files",
"FileWasDeletedByUpgrade": "File was deleted to import an upgrade", "FileWasDeletedByUpgrade": "File was deleted to import an upgrade",
"FileWasDeletedByViaUI": "File was deleted via the UI", "FileWasDeletedByViaUI": "File was deleted via the UI",
"Filename": "Filename",
"Files": "Files",
"Filter": "Filter", "Filter": "Filter",
"FilterPlaceHolder": "Search movies", "FilterPlaceHolder": "Search movies",
"Filters": "Filters", "Filters": "Filters",
@ -356,11 +356,11 @@
"FolderMoveRenameWarning": "This will also rename the movie folder per the movie folder format in settings.", "FolderMoveRenameWarning": "This will also rename the movie folder per the movie folder format in settings.",
"Folders": "Folders", "Folders": "Folders",
"FollowPerson": "Follow Person", "FollowPerson": "Follow Person",
"Forecast": "Forecast",
"Formats": "Formats",
"ForMoreInformationOnTheIndividualDownloadClients": "For more information on the individual download clients, click the more info buttons.", "ForMoreInformationOnTheIndividualDownloadClients": "For more information on the individual download clients, click the more info buttons.",
"ForMoreInformationOnTheIndividualImportListsClinkOnTheInfoButtons": "For more information on the individual import lists, click on the info buttons.", "ForMoreInformationOnTheIndividualImportListsClinkOnTheInfoButtons": "For more information on the individual import lists, click on the info buttons.",
"ForMoreInformationOnTheIndividualIndexers": "For more information on the individual indexers, click on the info buttons.", "ForMoreInformationOnTheIndividualIndexers": "For more information on the individual indexers, click on the info buttons.",
"Forecast": "Forecast",
"Formats": "Formats",
"FreeSpace": "Free Space", "FreeSpace": "Free Space",
"From": "from", "From": "from",
"General": "General", "General": "General",
@ -370,11 +370,11 @@
"Global": "Global", "Global": "Global",
"GoToInterp": "Go to {0}", "GoToInterp": "Go to {0}",
"Grab": "Grab", "Grab": "Grab",
"Grabbed": "Grabbed",
"GrabID": "Grab ID", "GrabID": "Grab ID",
"GrabRelease": "Grab Release", "GrabRelease": "Grab Release",
"GrabReleaseMessageText": "Radarr was unable to determine which movie this release was for. Radarr may be unable to automatically import this release. Do you want to grab '{0}'?", "GrabReleaseMessageText": "Radarr was unable to determine which movie this release was for. Radarr may be unable to automatically import this release. Do you want to grab '{0}'?",
"GrabSelected": "Grab Selected", "GrabSelected": "Grab Selected",
"Grabbed": "Grabbed",
"Group": "Group", "Group": "Group",
"HardlinkCopyFiles": "Hardlink/Copy Files", "HardlinkCopyFiles": "Hardlink/Copy Files",
"HaveNotAddedMovies": "You haven't added any movies yet, do you want to import some or all of your movies first?", "HaveNotAddedMovies": "You haven't added any movies yet, do you want to import some or all of your movies first?",
@ -390,19 +390,22 @@
"HttpHttps": "HTTP(S)", "HttpHttps": "HTTP(S)",
"ICalFeed": "iCal Feed", "ICalFeed": "iCal Feed",
"ICalHttpUrlHelpText": "Copy this URL to your client(s) or click to subscribe if your browser supports webcal", "ICalHttpUrlHelpText": "Copy this URL to your client(s) or click to subscribe if your browser supports webcal",
"IMDb": "IMDb", "iCalLink": "iCal Link",
"IconForCutoffUnmet": "Icon for Cutoff Unmet", "IconForCutoffUnmet": "Icon for Cutoff Unmet",
"IgnoreDeletedMovies": "Unmonitor Deleted Movies",
"Ignored": "Ignored", "Ignored": "Ignored",
"IgnoredAddresses": "Ignored Addresses", "IgnoredAddresses": "Ignored Addresses",
"IgnoreDeletedMovies": "Unmonitor Deleted Movies",
"IgnoredHelpText": "The release will be rejected if it contains one or more of the terms (case insensitive)", "IgnoredHelpText": "The release will be rejected if it contains one or more of the terms (case insensitive)",
"IgnoredPlaceHolder": "Add new restriction", "IgnoredPlaceHolder": "Add new restriction",
"IllRestartLater": "I'll restart later", "IllRestartLater": "I'll restart later",
"Images": "Images", "Images": "Images",
"IMDb": "IMDb",
"ImdbRating": "IMDb Rating", "ImdbRating": "IMDb Rating",
"ImdbVotes": "IMDb Votes", "ImdbVotes": "IMDb Votes",
"Import": "Import", "Import": "Import",
"ImportCustomFormat": "Import Custom Format", "ImportCustomFormat": "Import Custom Format",
"Imported": "Imported",
"ImportedTo": "Imported To",
"ImportErrors": "Import Errors", "ImportErrors": "Import Errors",
"ImportExistingMovies": "Import Existing Movies", "ImportExistingMovies": "Import Existing Movies",
"ImportExtraFiles": "Import Extra Files", "ImportExtraFiles": "Import Extra Files",
@ -411,6 +414,7 @@
"ImportFailedInterp": "Import failed: {0}", "ImportFailedInterp": "Import failed: {0}",
"ImportHeader": "Import an existing organized library to add movies to Radarr", "ImportHeader": "Import an existing organized library to add movies to Radarr",
"ImportIncludeQuality": "Make sure that your files include the quality in their filenames. e.g. {0}", "ImportIncludeQuality": "Make sure that your files include the quality in their filenames. e.g. {0}",
"Importing": "Importing",
"ImportLibrary": "Library Import", "ImportLibrary": "Library Import",
"ImportListMissingRoot": "Missing root folder for import list(s): {0}", "ImportListMissingRoot": "Missing root folder for import list(s): {0}",
"ImportListMultipleMissingRoots": "Multiple root folders are missing for import lists: {0}", "ImportListMultipleMissingRoots": "Multiple root folders are missing for import lists: {0}",
@ -422,9 +426,6 @@
"ImportNotForDownloads": "Do not use for importing downloads from your download client, this is only for existing organized libraries, not unsorted files.", "ImportNotForDownloads": "Do not use for importing downloads from your download client, this is only for existing organized libraries, not unsorted files.",
"ImportRootPath": "Point Radarr to the folder containing all of your movies, not a specific movie. e.g. {0} and not {1}. Additionally, each movie must be in its own folder within the root/library folder.", "ImportRootPath": "Point Radarr to the folder containing all of your movies, not a specific movie. e.g. {0} and not {1}. Additionally, each movie must be in its own folder within the root/library folder.",
"ImportTipsMessage": "Some tips to ensure the import goes smoothly:", "ImportTipsMessage": "Some tips to ensure the import goes smoothly:",
"Imported": "Imported",
"ImportedTo": "Imported To",
"Importing": "Importing",
"InCinemas": "In Cinemas", "InCinemas": "In Cinemas",
"InCinemasDate": "In Cinemas Date", "InCinemasDate": "In Cinemas Date",
"InCinemasMsg": "Movie is in Cinemas", "InCinemasMsg": "Movie is in Cinemas",
@ -445,15 +446,15 @@
"IndexerPriorityHelpText": "Indexer Priority from 1 (Highest) to 50 (Lowest). Default: 25. Used when grabbing releases as a tiebreaker for otherwise equal releases, Radarr will still use all enabled indexers for RSS Sync and Searching", "IndexerPriorityHelpText": "Indexer Priority from 1 (Highest) to 50 (Lowest). Default: 25. Used when grabbing releases as a tiebreaker for otherwise equal releases, Radarr will still use all enabled indexers for RSS Sync and Searching",
"IndexerRssHealthCheckNoAvailableIndexers": "All rss-capable indexers are temporarily unavailable due to recent indexer errors", "IndexerRssHealthCheckNoAvailableIndexers": "All rss-capable indexers are temporarily unavailable due to recent indexer errors",
"IndexerRssHealthCheckNoIndexers": "No indexers available with RSS sync enabled, Radarr will not grab new releases automatically", "IndexerRssHealthCheckNoIndexers": "No indexers available with RSS sync enabled, Radarr will not grab new releases automatically",
"Indexers": "Indexers",
"IndexerSearchCheckNoAutomaticMessage": "No indexers available with Automatic Search enabled, Radarr will not provide any automatic search results", "IndexerSearchCheckNoAutomaticMessage": "No indexers available with Automatic Search enabled, Radarr will not provide any automatic search results",
"IndexerSearchCheckNoAvailableIndexersMessage": "All search-capable indexers are temporarily unavailable due to recent indexer errors", "IndexerSearchCheckNoAvailableIndexersMessage": "All search-capable indexers are temporarily unavailable due to recent indexer errors",
"IndexerSearchCheckNoInteractiveMessage": "No indexers available with Interactive Search enabled, Radarr will not provide any interactive search results", "IndexerSearchCheckNoInteractiveMessage": "No indexers available with Interactive Search enabled, Radarr will not provide any interactive search results",
"IndexerSettings": "Indexer Settings", "IndexerSettings": "Indexer Settings",
"IndexersSettingsSummary": "Indexers and release restrictions",
"IndexerStatusCheckAllClientMessage": "All indexers are unavailable due to failures", "IndexerStatusCheckAllClientMessage": "All indexers are unavailable due to failures",
"IndexerStatusCheckSingleClientMessage": "Indexers unavailable due to failures: {0}", "IndexerStatusCheckSingleClientMessage": "Indexers unavailable due to failures: {0}",
"IndexerTagHelpText": "Only use this indexer for movies with at least one matching tag. Leave blank to use with all movies.", "IndexerTagHelpText": "Only use this indexer for movies with at least one matching tag. Leave blank to use with all movies.",
"Indexers": "Indexers",
"IndexersSettingsSummary": "Indexers and release restrictions",
"Info": "Info", "Info": "Info",
"InstallLatest": "Install Latest", "InstallLatest": "Install Latest",
"InstanceName": "Instance Name", "InstanceName": "Instance Name",
@ -482,13 +483,13 @@
"Links": "Links", "Links": "Links",
"List": "List", "List": "List",
"ListExclusions": "List Exclusions", "ListExclusions": "List Exclusions",
"Lists": "Lists",
"ListSettings": "List Settings", "ListSettings": "List Settings",
"ListsSettingsSummary": "Import Lists, list exclusions",
"ListSyncLevelHelpText": "Movies in library will be handled based on your selection if they fall off or do not appear on your list(s)", "ListSyncLevelHelpText": "Movies in library will be handled based on your selection if they fall off or do not appear on your list(s)",
"ListSyncLevelHelpTextWarning": "Movie files will be permanently deleted, this can result in wiping your library if your lists are empty", "ListSyncLevelHelpTextWarning": "Movie files will be permanently deleted, this can result in wiping your library if your lists are empty",
"ListTagsHelpText": "Tags list items will be added with", "ListTagsHelpText": "Tags list items will be added with",
"ListUpdateInterval": "List Update Interval", "ListUpdateInterval": "List Update Interval",
"Lists": "Lists",
"ListsSettingsSummary": "Import Lists, list exclusions",
"Loading": "Loading", "Loading": "Loading",
"LoadingMovieCreditsFailed": "Loading movie credits failed", "LoadingMovieCreditsFailed": "Loading movie credits failed",
"LoadingMovieExtraFilesFailed": "Loading movie extra files failed", "LoadingMovieExtraFilesFailed": "Loading movie extra files failed",
@ -497,15 +498,14 @@
"LocalPath": "Local Path", "LocalPath": "Local Path",
"Location": "Location", "Location": "Location",
"LogFiles": "Log Files", "LogFiles": "Log Files",
"Logging": "Logging",
"LogLevel": "Log Level", "LogLevel": "Log Level",
"LogLevelTraceHelpTextWarning": "Trace logging should only be enabled temporarily", "LogLevelTraceHelpTextWarning": "Trace logging should only be enabled temporarily",
"LogOnly": "Log Only", "LogOnly": "Log Only",
"Logging": "Logging",
"Logs": "Logs", "Logs": "Logs",
"LookingForReleaseProfiles1": "Looking for Release Profiles? Try", "LookingForReleaseProfiles1": "Looking for Release Profiles? Try",
"LookingForReleaseProfiles2": "instead.", "LookingForReleaseProfiles2": "instead.",
"LowerCase": "Lowercase", "LowerCase": "Lowercase",
"MIA": "MIA",
"MaintenanceRelease": "Maintenance Release: bug fixes and other improvements. See Github Commit History for more details", "MaintenanceRelease": "Maintenance Release: bug fixes and other improvements. See Github Commit History for more details",
"Manual": "Manual", "Manual": "Manual",
"ManualImport": "Manual Import", "ManualImport": "Manual Import",
@ -532,6 +532,7 @@
"Metadata": "Metadata", "Metadata": "Metadata",
"MetadataSettings": "Metadata Settings", "MetadataSettings": "Metadata Settings",
"MetadataSettingsSummary": "Create metadata files when movies are imported or refreshed", "MetadataSettingsSummary": "Create metadata files when movies are imported or refreshed",
"MIA": "MIA",
"Min": "Min", "Min": "Min",
"MinAvailability": "Min Availability", "MinAvailability": "Min Availability",
"MinFormatScoreHelpText": "Minimum custom format score allowed to download", "MinFormatScoreHelpText": "Minimum custom format score allowed to download",
@ -554,13 +555,13 @@
"Monday": "Monday", "Monday": "Monday",
"Monitor": "Monitor", "Monitor": "Monitor",
"MonitorCollection": "Monitor Collection", "MonitorCollection": "Monitor Collection",
"MonitorMovie": "Monitor Movie",
"MonitorMovies": "Monitor Movies",
"Monitored": "Monitored", "Monitored": "Monitored",
"MonitoredCollectionHelpText": "Monitor to automatically have movies from this collection added to the library", "MonitoredCollectionHelpText": "Monitor to automatically have movies from this collection added to the library",
"MonitoredHelpText": "Download movie if available", "MonitoredHelpText": "Download movie if available",
"MonitoredOnly": "Monitored Only", "MonitoredOnly": "Monitored Only",
"MonitoredStatus": "Monitored/Status", "MonitoredStatus": "Monitored/Status",
"MonitorMovie": "Monitor Movie",
"MonitorMovies": "Monitor Movies",
"Month": "Month", "Month": "Month",
"Months": "Months", "Months": "Months",
"More": "More", "More": "More",
@ -601,20 +602,20 @@
"MovieMatchType": "Movie Match Type", "MovieMatchType": "Movie Match Type",
"MovieNaming": "Movie Naming", "MovieNaming": "Movie Naming",
"MovieOnly": "Movie Only", "MovieOnly": "Movie Only",
"Movies": "Movies",
"MoviesSelectedInterp": "{0} Movie(s) Selected",
"MovieTitle": "Movie Title", "MovieTitle": "Movie Title",
"MovieTitleHelpText": "The title of the movie to exclude (can be anything meaningful)", "MovieTitleHelpText": "The title of the movie to exclude (can be anything meaningful)",
"MovieYear": "Movie Year", "MovieYear": "Movie Year",
"MovieYearHelpText": "The year of the movie to exclude", "MovieYearHelpText": "The year of the movie to exclude",
"Movies": "Movies",
"MoviesSelectedInterp": "{0} Movie(s) Selected",
"MultiLanguage": "Multi-Language", "MultiLanguage": "Multi-Language",
"MustContain": "Must Contain", "MustContain": "Must Contain",
"MustNotContain": "Must Not Contain", "MustNotContain": "Must Not Contain",
"Name": "Name", "Name": "Name",
"NamingSettings": "Naming Settings", "NamingSettings": "Naming Settings",
"Negate": "Negate", "Negate": "Negate",
"NegateHelpText": "If checked, the custom format will not apply if this {0} condition matches.",
"Negated": "Negated", "Negated": "Negated",
"NegateHelpText": "If checked, the custom format will not apply if this {0} condition matches.",
"NetCore": ".NET", "NetCore": ".NET",
"Never": "Never", "Never": "Never",
"New": "New", "New": "New",
@ -635,15 +636,15 @@
"NoMinimumForAnyRuntime": "No minimum for any runtime", "NoMinimumForAnyRuntime": "No minimum for any runtime",
"NoMoveFilesSelf": " No, I'll Move the Files Myself", "NoMoveFilesSelf": " No, I'll Move the Files Myself",
"NoMoviesExist": "No movies found, to get started you'll want to add a new movie or import some existing ones.", "NoMoviesExist": "No movies found, to get started you'll want to add a new movie or import some existing ones.",
"None": "None",
"NoResultsFound": "No results found", "NoResultsFound": "No results found",
"NoTagsHaveBeenAddedYet": "No tags have been added yet", "NoTagsHaveBeenAddedYet": "No tags have been added yet",
"NoUpdatesAreAvailable": "No updates are available",
"NoVideoFilesFoundSelectedFolder": "No video files were found in the selected folder",
"None": "None",
"NotAvailable": "Not Available", "NotAvailable": "Not Available",
"NotMonitored": "Not Monitored",
"NotificationTriggers": "Notification Triggers", "NotificationTriggers": "Notification Triggers",
"NotificationTriggersHelpText": "Select which events should trigger this notification", "NotificationTriggersHelpText": "Select which events should trigger this notification",
"NotMonitored": "Not Monitored",
"NoUpdatesAreAvailable": "No updates are available",
"NoVideoFilesFoundSelectedFolder": "No video files were found in the selected folder",
"OAuthPopupMessage": "Pop-ups are being blocked by your browser", "OAuthPopupMessage": "Pop-ups are being blocked by your browser",
"Ok": "Ok", "Ok": "Ok",
"OnApplicationUpdate": "On Application Update", "OnApplicationUpdate": "On Application Update",
@ -657,6 +658,8 @@
"OnHealthRestoredHelpText": "On Health Restored", "OnHealthRestoredHelpText": "On Health Restored",
"OnImport": "On Import", "OnImport": "On Import",
"OnLatestVersion": "The latest version of Radarr is already installed", "OnLatestVersion": "The latest version of Radarr is already installed",
"OnlyTorrent": "Only Torrent",
"OnlyUsenet": "Only Usenet",
"OnManualInteractionRequired": "On Manual Interaction Required", "OnManualInteractionRequired": "On Manual Interaction Required",
"OnManualInteractionRequiredHelpText": "On Manual Interaction Required", "OnManualInteractionRequiredHelpText": "On Manual Interaction Required",
"OnMovieAdded": "On Movie Added", "OnMovieAdded": "On Movie Added",
@ -671,8 +674,6 @@
"OnRenameHelpText": "On Rename", "OnRenameHelpText": "On Rename",
"OnUpgrade": "On Upgrade", "OnUpgrade": "On Upgrade",
"OnUpgradeHelpText": "On Upgrade", "OnUpgradeHelpText": "On Upgrade",
"OnlyTorrent": "Only Torrent",
"OnlyUsenet": "Only Usenet",
"OpenBrowserOnStart": "Open browser on start", "OpenBrowserOnStart": "Open browser on start",
"OpenThisModal": "Open This Modal", "OpenThisModal": "Open This Modal",
"Options": "Options", "Options": "Options",
@ -707,16 +708,16 @@
"Port": "Port", "Port": "Port",
"PortNumber": "Port Number", "PortNumber": "Port Number",
"PosterOptions": "Poster Options", "PosterOptions": "Poster Options",
"PosterSize": "Poster Size",
"Posters": "Posters", "Posters": "Posters",
"PosterSize": "Poster Size",
"PreferAndUpgrade": "Prefer and Upgrade", "PreferAndUpgrade": "Prefer and Upgrade",
"PreferIndexerFlags": "Prefer Indexer Flags", "PreferIndexerFlags": "Prefer Indexer Flags",
"PreferIndexerFlagsHelpText": "Prioritize releases with special flags", "PreferIndexerFlagsHelpText": "Prioritize releases with special flags",
"PreferTorrent": "Prefer Torrent",
"PreferUsenet": "Prefer Usenet",
"Preferred": "Preferred", "Preferred": "Preferred",
"PreferredProtocol": "Preferred Protocol", "PreferredProtocol": "Preferred Protocol",
"PreferredSize": "Preferred Size", "PreferredSize": "Preferred Size",
"PreferTorrent": "Prefer Torrent",
"PreferUsenet": "Prefer Usenet",
"Presets": "Presets", "Presets": "Presets",
"PreviewRename": "Preview Rename", "PreviewRename": "Preview Rename",
"PreviewRenameHelpText": "Tip: To preview a rename... select 'Cancel' then click any movie title and use the", "PreviewRenameHelpText": "Tip: To preview a rename... select 'Cancel' then click any movie title and use the",
@ -754,15 +755,9 @@
"QualitySettings": "Quality Settings", "QualitySettings": "Quality Settings",
"QualitySettingsSummary": "Quality sizes and naming", "QualitySettingsSummary": "Quality sizes and naming",
"Queue": "Queue", "Queue": "Queue",
"QueueIsEmpty": "Queue is empty",
"Queued": "Queued", "Queued": "Queued",
"QueueIsEmpty": "Queue is empty",
"QuickImport": "Move Automatically", "QuickImport": "Move Automatically",
"RSS": "RSS",
"RSSHelpText": "Will be used when Radarr periodically looks for releases via RSS Sync",
"RSSIsNotSupportedWithThisIndexer": "RSS is not supported with this indexer",
"RSSSync": "RSS Sync",
"RSSSyncInterval": "RSS Sync Interval",
"RSSSyncIntervalHelpTextWarning": "This will apply to all indexers, please follow the rules set forth by them",
"RadarrCalendarFeed": "Radarr Calendar Feed", "RadarrCalendarFeed": "Radarr Calendar Feed",
"RadarrSupportsAnyDownloadClient": "Radarr supports many popular torrent and usenet download clients.", "RadarrSupportsAnyDownloadClient": "Radarr supports many popular torrent and usenet download clients.",
"RadarrSupportsAnyIndexer": "Radarr supports any indexer that uses the Newznab standard, as well as other indexers listed below.", "RadarrSupportsAnyIndexer": "Radarr supports any indexer that uses the Newznab standard, as well as other indexers listed below.",
@ -796,14 +791,14 @@
"RejectionCount": "Rejection Count", "RejectionCount": "Rejection Count",
"RelativePath": "Relative Path", "RelativePath": "Relative Path",
"ReleaseBranchCheckOfficialBranchMessage": "Branch {0} is not a valid Radarr release branch, you will not receive updates", "ReleaseBranchCheckOfficialBranchMessage": "Branch {0} is not a valid Radarr release branch, you will not receive updates",
"Released": "Released",
"ReleaseDates": "Release Dates", "ReleaseDates": "Release Dates",
"ReleasedMsg": "Movie is released",
"ReleaseGroup": "Release Group", "ReleaseGroup": "Release Group",
"ReleaseRejected": "Release Rejected", "ReleaseRejected": "Release Rejected",
"ReleaseStatus": "Release Status", "ReleaseStatus": "Release Status",
"ReleaseTitle": "Release Title", "ReleaseTitle": "Release Title",
"ReleaseWillBeProcessedInterp": "Release will be processed {0}", "ReleaseWillBeProcessedInterp": "Release will be processed {0}",
"Released": "Released",
"ReleasedMsg": "Movie is released",
"Reload": "Reload", "Reload": "Reload",
"RemotePath": "Remote Path", "RemotePath": "Remote Path",
"RemotePathMappingCheckBadDockerPath": "You are using docker; download client {0} places downloads in {1} but this is not a valid {2} path. Review your remote path mappings and download client settings.", "RemotePathMappingCheckBadDockerPath": "You are using docker; download client {0} places downloads in {1} but this is not a valid {2} path. Review your remote path mappings and download client settings.",
@ -824,9 +819,14 @@
"RemotePathMappings": "Remote Path Mappings", "RemotePathMappings": "Remote Path Mappings",
"Remove": "Remove", "Remove": "Remove",
"RemoveCompleted": "Remove Completed", "RemoveCompleted": "Remove Completed",
"RemoveCompletedDownloads": "Remove Completed Downloads",
"RemoveCompletedDownloadsHelpText": "Remove imported downloads from download client history", "RemoveCompletedDownloadsHelpText": "Remove imported downloads from download client history",
"RemovedFromTaskQueue": "Removed from task queue",
"RemovedMovieCheckMultipleMessage": "Movies {0} were removed from TMDb",
"RemovedMovieCheckSingleMessage": "Movie {0} was removed from TMDb",
"RemoveDownloadsAlert": "The Remove settings were moved to the individual Download Client settings in the table above.", "RemoveDownloadsAlert": "The Remove settings were moved to the individual Download Client settings in the table above.",
"RemoveFailed": "Remove Failed", "RemoveFailed": "Remove Failed",
"RemoveFailedDownloads": "Remove Failed Downloads",
"RemoveFailedDownloadsHelpText": "Remove failed downloads from download client history", "RemoveFailedDownloadsHelpText": "Remove failed downloads from download client history",
"RemoveFilter": "Remove filter", "RemoveFilter": "Remove filter",
"RemoveFromBlocklist": "Remove from blocklist", "RemoveFromBlocklist": "Remove from blocklist",
@ -840,14 +840,11 @@
"RemoveSelected": "Remove Selected", "RemoveSelected": "Remove Selected",
"RemoveSelectedItem": "Remove Selected Item", "RemoveSelectedItem": "Remove Selected Item",
"RemoveSelectedItems": "Remove Selected Items", "RemoveSelectedItems": "Remove Selected Items",
"RemovedFromTaskQueue": "Removed from task queue",
"RemovedMovieCheckMultipleMessage": "Movies {0} were removed from TMDb",
"RemovedMovieCheckSingleMessage": "Movie {0} was removed from TMDb",
"RemovingTag": "Removing tag", "RemovingTag": "Removing tag",
"Renamed": "Renamed",
"RenameFiles": "Rename Files", "RenameFiles": "Rename Files",
"RenameMovies": "Rename Movies", "RenameMovies": "Rename Movies",
"RenameMoviesHelpText": "Radarr will use the existing file name if renaming is disabled", "RenameMoviesHelpText": "Radarr will use the existing file name if renaming is disabled",
"Renamed": "Renamed",
"Reorder": "Reorder", "Reorder": "Reorder",
"Replace": "Replace", "Replace": "Replace",
"ReplaceIllegalCharacters": "Replace Illegal Characters", "ReplaceIllegalCharacters": "Replace Illegal Characters",
@ -885,13 +882,14 @@
"RootFolderCheckSingleMessage": "Missing root folder: {0}", "RootFolderCheckSingleMessage": "Missing root folder: {0}",
"RootFolders": "Root Folders", "RootFolders": "Root Folders",
"RottenTomatoesRating": "Tomato Rating", "RottenTomatoesRating": "Tomato Rating",
"RSS": "RSS",
"RSSHelpText": "Will be used when Radarr periodically looks for releases via RSS Sync",
"RSSIsNotSupportedWithThisIndexer": "RSS is not supported with this indexer",
"RSSSync": "RSS Sync",
"RssSyncHelpText": "Interval in minutes. Set to zero to disable (this will stop all automatic release grabbing)", "RssSyncHelpText": "Interval in minutes. Set to zero to disable (this will stop all automatic release grabbing)",
"RSSSyncInterval": "RSS Sync Interval",
"RSSSyncIntervalHelpTextWarning": "This will apply to all indexers, please follow the rules set forth by them",
"Runtime": "Runtime", "Runtime": "Runtime",
"SSLCertPassword": "SSL Cert Password",
"SSLCertPasswordHelpText": "Password for pfx file",
"SSLCertPath": "SSL Cert Path",
"SSLCertPathHelpText": "Path to pfx file",
"SSLPort": "SSL Port",
"Save": "Save", "Save": "Save",
"SaveChanges": "Save Changes", "SaveChanges": "Save Changes",
"SaveSettings": "Save Settings", "SaveSettings": "Save Settings",
@ -963,6 +961,7 @@
"ShowMonitoredHelpText": "Show monitored status under poster", "ShowMonitoredHelpText": "Show monitored status under poster",
"ShowMovieInformation": "Show Movie Information", "ShowMovieInformation": "Show Movie Information",
"ShowMovieInformationHelpText": "Show movie genres and certification", "ShowMovieInformationHelpText": "Show movie genres and certification",
"ShownClickToHide": "Shown, click to hide",
"ShowOverview": "Show Overview", "ShowOverview": "Show Overview",
"ShowPath": "Show Path", "ShowPath": "Show Path",
"ShowPosters": "Show Posters", "ShowPosters": "Show Posters",
@ -979,7 +978,6 @@
"ShowTitleHelpText": "Show movie title under poster", "ShowTitleHelpText": "Show movie title under poster",
"ShowUnknownMovieItems": "Show Unknown Movie Items", "ShowUnknownMovieItems": "Show Unknown Movie Items",
"ShowYear": "Show Year", "ShowYear": "Show Year",
"ShownClickToHide": "Shown, click to hide",
"Shutdown": "Shutdown", "Shutdown": "Shutdown",
"Size": "Size", "Size": "Size",
"SizeLimit": "Size Limit", "SizeLimit": "Size Limit",
@ -997,12 +995,17 @@
"SourceRelativePath": "Source Relative Path", "SourceRelativePath": "Source Relative Path",
"SourceTitle": "Source Title", "SourceTitle": "Source Title",
"SqliteVersionCheckUpgradeRequiredMessage": "Currently installed SQLite version {0} is no longer supported. Please upgrade SQLite to at least version {1}.", "SqliteVersionCheckUpgradeRequiredMessage": "Currently installed SQLite version {0} is no longer supported. Please upgrade SQLite to at least version {1}.",
"SSLCertPassword": "SSL Cert Password",
"SSLCertPasswordHelpText": "Password for pfx file",
"SSLCertPath": "SSL Cert Path",
"SSLCertPathHelpText": "Path to pfx file",
"SSLPort": "SSL Port",
"StandardMovieFormat": "Standard Movie Format", "StandardMovieFormat": "Standard Movie Format",
"Started": "Started",
"StartImport": "Start Import", "StartImport": "Start Import",
"StartProcessing": "Start Processing", "StartProcessing": "Start Processing",
"StartSearchForMissingMovie": "Start search for missing movie", "StartSearchForMissingMovie": "Start search for missing movie",
"StartTypingOrSelectAPathBelow": "Start typing or select a path below", "StartTypingOrSelectAPathBelow": "Start typing or select a path below",
"Started": "Started",
"StartupDirectory": "Startup directory", "StartupDirectory": "Startup directory",
"Status": "Status", "Status": "Status",
"StopSelecting": "Stop Selecting", "StopSelecting": "Stop Selecting",
@ -1013,8 +1016,6 @@
"Sunday": "Sunday", "Sunday": "Sunday",
"System": "System", "System": "System",
"SystemTimeCheckMessage": "System time is off by more than 1 day. Scheduled tasks may not run correctly until the time is corrected", "SystemTimeCheckMessage": "System time is off by more than 1 day. Scheduled tasks may not run correctly until the time is corrected",
"TMDBId": "TMDb Id",
"TMDb": "TMDb",
"Table": "Table", "Table": "Table",
"TableOptions": "Table Options", "TableOptions": "Table Options",
"TableOptionsColumnsMessage": "Choose which columns are visible and which order they appear in", "TableOptionsColumnsMessage": "Choose which columns are visible and which order they appear in",
@ -1024,8 +1025,8 @@
"Tags": "Tags", "Tags": "Tags",
"TagsHelpText": "Applies to movies with at least one matching tag", "TagsHelpText": "Applies to movies with at least one matching tag",
"TagsSettingsSummary": "See all tags and how they are used. Unused tags can be removed", "TagsSettingsSummary": "See all tags and how they are used. Unused tags can be removed",
"TaskUserAgentTooltip": "User-Agent provided by the app that called the API",
"Tasks": "Tasks", "Tasks": "Tasks",
"TaskUserAgentTooltip": "User-Agent provided by the app that called the API",
"Test": "Test", "Test": "Test",
"TestAll": "Test All", "TestAll": "Test All",
"TestAllClients": "Test All Clients", "TestAllClients": "Test All Clients",
@ -1041,6 +1042,8 @@
"Timeleft": "Time Left", "Timeleft": "Time Left",
"Title": "Title", "Title": "Title",
"Titles": "Titles", "Titles": "Titles",
"TMDb": "TMDb",
"TMDBId": "TMDb Id",
"TmdbIdHelpText": "The TMDb Id of the movie to exclude", "TmdbIdHelpText": "The TMDb Id of the movie to exclude",
"TmdbRating": "TMDb Rating", "TmdbRating": "TMDb Rating",
"TmdbVotes": "TMDb Votes", "TmdbVotes": "TMDb Votes",
@ -1066,7 +1069,6 @@
"UISettings": "UI Settings", "UISettings": "UI Settings",
"UISettingsSummary": "Calendar, date and color impaired options", "UISettingsSummary": "Calendar, date and color impaired options",
"UMask": "UMask", "UMask": "UMask",
"URLBase": "URL Base",
"UnableToAddANewConditionPleaseTryAgain": "Unable to add a new condition, please try again.", "UnableToAddANewConditionPleaseTryAgain": "Unable to add a new condition, please try again.",
"UnableToAddANewCustomFormatPleaseTryAgain": "Unable to add a new custom format, please try again.", "UnableToAddANewCustomFormatPleaseTryAgain": "Unable to add a new custom format, please try again.",
"UnableToAddANewDownloadClientPleaseTryAgain": "Unable to add a new download client, please try again.", "UnableToAddANewDownloadClientPleaseTryAgain": "Unable to add a new download client, please try again.",
@ -1129,24 +1131,25 @@
"UpdateCheckUINotWritableMessage": "Cannot install update because UI folder '{0}' is not writable by the user '{1}'.", "UpdateCheckUINotWritableMessage": "Cannot install update because UI folder '{0}' is not writable by the user '{1}'.",
"UpdateFiltered": "Update Filtered", "UpdateFiltered": "Update Filtered",
"UpdateMechanismHelpText": "Use Radarr's built-in updater or a script", "UpdateMechanismHelpText": "Use Radarr's built-in updater or a script",
"Updates": "Updates",
"UpdateScriptPathHelpText": "Path to a custom script that takes an extracted update package and handle the remainder of the update process", "UpdateScriptPathHelpText": "Path to a custom script that takes an extracted update package and handle the remainder of the update process",
"UpdateSelected": "Update Selected", "UpdateSelected": "Update Selected",
"Updates": "Updates",
"UpgradeAllowedHelpText": "If disabled qualities will not be upgraded", "UpgradeAllowedHelpText": "If disabled qualities will not be upgraded",
"UpgradesAllowed": "Upgrades Allowed",
"UpgradeUntilCustomFormatScore": "Upgrade Until Custom Format Score", "UpgradeUntilCustomFormatScore": "Upgrade Until Custom Format Score",
"UpgradeUntilQuality": "Upgrade Until Quality", "UpgradeUntilQuality": "Upgrade Until Quality",
"UpgradeUntilThisQualityIsMetOrExceeded": "Upgrade until this quality is met or exceeded", "UpgradeUntilThisQualityIsMetOrExceeded": "Upgrade until this quality is met or exceeded",
"UpgradesAllowed": "Upgrades Allowed",
"UpperCase": "Uppercase", "UpperCase": "Uppercase",
"Uptime": "Uptime", "Uptime": "Uptime",
"URLBase": "URL Base",
"UrlBaseHelpText": "For reverse proxy support, default is empty", "UrlBaseHelpText": "For reverse proxy support, default is empty",
"UseHardlinksInsteadOfCopy": "Use Hardlinks instead of Copy", "UseHardlinksInsteadOfCopy": "Use Hardlinks instead of Copy",
"UseProxy": "Use Proxy",
"Usenet": "Usenet", "Usenet": "Usenet",
"UsenetDelay": "Usenet Delay", "UsenetDelay": "Usenet Delay",
"UsenetDelayHelpText": "Delay in minutes to wait before grabbing a release from Usenet", "UsenetDelayHelpText": "Delay in minutes to wait before grabbing a release from Usenet",
"UsenetDelayTime": "Usenet Delay: {0}", "UsenetDelayTime": "Usenet Delay: {0}",
"UsenetDisabled": "Usenet Disabled", "UsenetDisabled": "Usenet Disabled",
"UseProxy": "Use Proxy",
"Username": "Username", "Username": "Username",
"Version": "Version", "Version": "Version",
"VersionUpdateText": "Version {0} of Radarr has been installed, in order to get the latest changes you'll need to reload Radarr.", "VersionUpdateText": "Version {0} of Radarr has been installed, in order to get the latest changes you'll need to reload Radarr.",
@ -1170,6 +1173,5 @@
"YesCancel": "Yes, Cancel", "YesCancel": "Yes, Cancel",
"YesMoveFiles": "Yes, Move the Files", "YesMoveFiles": "Yes, Move the Files",
"Yesterday": "Yesterday", "Yesterday": "Yesterday",
"YouCanAlsoSearch": "You can also search using TMDb ID or IMDb ID of a movie. e.g. `tmdb:71663`", "YouCanAlsoSearch": "You can also search using TMDb ID or IMDb ID of a movie. e.g. `tmdb:71663`"
"iCalLink": "iCal Link"
} }

View File

@ -12,9 +12,12 @@ public interface IProviderFactory<TProvider, TProviderDefinition>
bool Exists(int id); bool Exists(int id);
TProviderDefinition Find(int id); TProviderDefinition Find(int id);
TProviderDefinition Get(int id); TProviderDefinition Get(int id);
IEnumerable<TProviderDefinition> Get(IEnumerable<int> ids);
TProviderDefinition Create(TProviderDefinition definition); TProviderDefinition Create(TProviderDefinition definition);
void Update(TProviderDefinition definition); void Update(TProviderDefinition definition);
IEnumerable<TProviderDefinition> Update(IEnumerable<TProviderDefinition> definitions);
void Delete(int id); void Delete(int id);
void Delete(IEnumerable<int> ids);
IEnumerable<TProviderDefinition> GetDefaultDefinitions(); IEnumerable<TProviderDefinition> GetDefaultDefinitions();
IEnumerable<TProviderDefinition> GetPresetDefinitions(TProviderDefinition providerDefinition); IEnumerable<TProviderDefinition> GetPresetDefinitions(TProviderDefinition providerDefinition);
void SetProviderCharacteristics(TProviderDefinition definition); void SetProviderCharacteristics(TProviderDefinition definition);

View File

@ -101,6 +101,11 @@ public TProviderDefinition Get(int id)
return _providerRepository.Get(id); return _providerRepository.Get(id);
} }
public IEnumerable<TProviderDefinition> Get(IEnumerable<int> ids)
{
return _providerRepository.Get(ids);
}
public TProviderDefinition Find(int id) public TProviderDefinition Find(int id)
{ {
return _providerRepository.Find(id); return _providerRepository.Find(id);
@ -120,12 +125,34 @@ public virtual void Update(TProviderDefinition definition)
_eventAggregator.PublishEvent(new ProviderUpdatedEvent<TProvider>(definition)); _eventAggregator.PublishEvent(new ProviderUpdatedEvent<TProvider>(definition));
} }
public virtual IEnumerable<TProviderDefinition> Update(IEnumerable<TProviderDefinition> definitions)
{
_providerRepository.UpdateMany(definitions.ToList());
foreach (var definition in definitions)
{
_eventAggregator.PublishEvent(new ProviderUpdatedEvent<TProvider>(definition));
}
return definitions;
}
public void Delete(int id) public void Delete(int id)
{ {
_providerRepository.Delete(id); _providerRepository.Delete(id);
_eventAggregator.PublishEvent(new ProviderDeletedEvent<TProvider>(id)); _eventAggregator.PublishEvent(new ProviderDeletedEvent<TProvider>(id));
} }
public void Delete(IEnumerable<int> ids)
{
_providerRepository.DeleteMany(ids);
foreach (var id in ids)
{
_eventAggregator.PublishEvent(new ProviderDeletedEvent<TProvider>(id));
}
}
public TProvider GetInstance(TProviderDefinition definition) public TProvider GetInstance(TProviderDefinition definition)
{ {
var type = GetImplementation(definition); var type = GetImplementation(definition);

View File

@ -0,0 +1,34 @@
using System.Collections.Generic;
using NzbDrone.Core.Download;
namespace Radarr.Api.V3.DownloadClient
{
public class DownloadClientBulkResource : ProviderBulkResource<DownloadClientBulkResource>
{
public bool? Enable { get; set; }
public int? Priority { get; set; }
public bool? RemoveCompletedDownloads { get; set; }
public bool? RemoveFailedDownloads { get; set; }
}
public class DownloadClientBulkResourceMapper : ProviderBulkResourceMapper<DownloadClientBulkResource, DownloadClientDefinition>
{
public override List<DownloadClientDefinition> UpdateModel(DownloadClientBulkResource resource, List<DownloadClientDefinition> existingDefinitions)
{
if (resource == null)
{
return new List<DownloadClientDefinition>();
}
existingDefinitions.ForEach(existing =>
{
existing.Enable = resource.Enable ?? existing.Enable;
existing.Priority = resource.Priority ?? existing.Priority;
existing.RemoveCompletedDownloads = resource.RemoveCompletedDownloads ?? existing.RemoveCompletedDownloads;
existing.RemoveFailedDownloads = resource.RemoveFailedDownloads ?? existing.RemoveFailedDownloads;
});
return existingDefinitions;
}
}
}

View File

@ -4,12 +4,13 @@
namespace Radarr.Api.V3.DownloadClient namespace Radarr.Api.V3.DownloadClient
{ {
[V3ApiController] [V3ApiController]
public class DownloadClientController : ProviderControllerBase<DownloadClientResource, IDownloadClient, DownloadClientDefinition> public class DownloadClientController : ProviderControllerBase<DownloadClientResource, DownloadClientBulkResource, IDownloadClient, DownloadClientDefinition>
{ {
public static readonly DownloadClientResourceMapper ResourceMapper = new DownloadClientResourceMapper(); public static readonly DownloadClientResourceMapper ResourceMapper = new DownloadClientResourceMapper();
public static readonly DownloadClientBulkResourceMapper BulkResourceMapper = new DownloadClientBulkResourceMapper();
public DownloadClientController(IDownloadClientFactory downloadClientFactory) public DownloadClientController(IDownloadClientFactory downloadClientFactory)
: base(downloadClientFactory, "downloadclient", ResourceMapper) : base(downloadClientFactory, "downloadclient", ResourceMapper, BulkResourceMapper)
{ {
} }
} }

View File

@ -0,0 +1,32 @@
using System.Collections.Generic;
using NzbDrone.Core.ImportLists;
namespace Radarr.Api.V3.ImportLists
{
public class ImportListBulkResource : ProviderBulkResource<ImportListBulkResource>
{
public bool? EnableAuto { get; set; }
public string RootFolderPath { get; set; }
public int? ProfileId { get; set; }
}
public class ImportListBulkResourceMapper : ProviderBulkResourceMapper<ImportListBulkResource, ImportListDefinition>
{
public override List<ImportListDefinition> UpdateModel(ImportListBulkResource resource, List<ImportListDefinition> existingDefinitions)
{
if (resource == null)
{
return new List<ImportListDefinition>();
}
existingDefinitions.ForEach(existing =>
{
existing.EnableAuto = resource.EnableAuto ?? existing.EnableAuto;
existing.RootFolderPath = resource.RootFolderPath ?? existing.RootFolderPath;
existing.ProfileId = resource.ProfileId ?? existing.ProfileId;
});
return existingDefinitions;
}
}
}

View File

@ -7,13 +7,13 @@
namespace Radarr.Api.V3.ImportLists namespace Radarr.Api.V3.ImportLists
{ {
[V3ApiController] [V3ApiController]
public class ImportListController : ProviderControllerBase<ImportListResource, IImportList, ImportListDefinition> public class ImportListController : ProviderControllerBase<ImportListResource, ImportListBulkResource, IImportList, ImportListDefinition>
{ {
public static readonly ImportListResourceMapper ResourceMapper = new ImportListResourceMapper(); public static readonly ImportListResourceMapper ResourceMapper = new ImportListResourceMapper();
public static readonly ImportListBulkResourceMapper BulkResourceMapper = new ImportListBulkResourceMapper();
public ImportListController(IImportListFactory importListFactory, public ImportListController(IImportListFactory importListFactory, ProfileExistsValidator profileExistsValidator)
ProfileExistsValidator profileExistsValidator) : base(importListFactory, "importlist", ResourceMapper, BulkResourceMapper)
: base(importListFactory, "importlist", ResourceMapper)
{ {
SharedValidator.RuleFor(c => c.RootFolderPath).IsValidPath(); SharedValidator.RuleFor(c => c.RootFolderPath).IsValidPath();
SharedValidator.RuleFor(c => c.MinimumAvailability).NotNull(); SharedValidator.RuleFor(c => c.MinimumAvailability).NotNull();

View File

@ -0,0 +1,34 @@
using System.Collections.Generic;
using NzbDrone.Core.Indexers;
namespace Radarr.Api.V3.Indexers
{
public class IndexerBulkResource : ProviderBulkResource<IndexerBulkResource>
{
public bool? EnableRss { get; set; }
public bool? EnableAutomaticSearch { get; set; }
public bool? EnableInteractiveSearch { get; set; }
public int? Priority { get; set; }
}
public class IndexerBulkResourceMapper : ProviderBulkResourceMapper<IndexerBulkResource, IndexerDefinition>
{
public override List<IndexerDefinition> UpdateModel(IndexerBulkResource resource, List<IndexerDefinition> existingDefinitions)
{
if (resource == null)
{
return new List<IndexerDefinition>();
}
existingDefinitions.ForEach(existing =>
{
existing.EnableRss = resource.EnableRss ?? existing.EnableRss;
existing.EnableAutomaticSearch = resource.EnableAutomaticSearch ?? existing.EnableAutomaticSearch;
existing.EnableInteractiveSearch = resource.EnableInteractiveSearch ?? existing.EnableInteractiveSearch;
existing.Priority = resource.Priority ?? existing.Priority;
});
return existingDefinitions;
}
}
}

View File

@ -4,12 +4,13 @@
namespace Radarr.Api.V3.Indexers namespace Radarr.Api.V3.Indexers
{ {
[V3ApiController] [V3ApiController]
public class IndexerController : ProviderControllerBase<IndexerResource, IIndexer, IndexerDefinition> public class IndexerController : ProviderControllerBase<IndexerResource, IndexerBulkResource, IIndexer, IndexerDefinition>
{ {
public static readonly IndexerResourceMapper ResourceMapper = new IndexerResourceMapper(); public static readonly IndexerResourceMapper ResourceMapper = new IndexerResourceMapper();
public static readonly IndexerBulkResourceMapper BulkResourceMapper = new IndexerBulkResourceMapper();
public IndexerController(IndexerFactory indexerFactory) public IndexerController(IndexerFactory indexerFactory)
: base(indexerFactory, "indexer", ResourceMapper) : base(indexerFactory, "indexer", ResourceMapper, BulkResourceMapper)
{ {
} }
} }

View File

@ -0,0 +1,12 @@
using NzbDrone.Core.Extras.Metadata;
namespace Radarr.Api.V3.Metadata
{
public class MetadataBulkResource : ProviderBulkResource<MetadataBulkResource>
{
}
public class MetadataBulkResourceMapper : ProviderBulkResourceMapper<MetadataBulkResource, MetadataDefinition>
{
}
}

View File

@ -4,12 +4,13 @@
namespace Radarr.Api.V3.Metadata namespace Radarr.Api.V3.Metadata
{ {
[V3ApiController] [V3ApiController]
public class MetadataController : ProviderControllerBase<MetadataResource, IMetadata, MetadataDefinition> public class MetadataController : ProviderControllerBase<MetadataResource, MetadataBulkResource, IMetadata, MetadataDefinition>
{ {
public static readonly MetadataResourceMapper ResourceMapper = new MetadataResourceMapper(); public static readonly MetadataResourceMapper ResourceMapper = new MetadataResourceMapper();
public static readonly MetadataBulkResourceMapper BulkResourceMapper = new MetadataBulkResourceMapper();
public MetadataController(IMetadataFactory metadataFactory) public MetadataController(IMetadataFactory metadataFactory)
: base(metadataFactory, "metadata", ResourceMapper) : base(metadataFactory, "metadata", ResourceMapper, BulkResourceMapper)
{ {
} }
} }

View File

@ -0,0 +1,12 @@
using NzbDrone.Core.Notifications;
namespace Radarr.Api.V3.Notifications
{
public class NotificationBulkResource : ProviderBulkResource<NotificationBulkResource>
{
}
public class NotificationBulkResourceMapper : ProviderBulkResourceMapper<NotificationBulkResource, NotificationDefinition>
{
}
}

View File

@ -4,12 +4,13 @@
namespace Radarr.Api.V3.Notifications namespace Radarr.Api.V3.Notifications
{ {
[V3ApiController] [V3ApiController]
public class NotificationController : ProviderControllerBase<NotificationResource, INotification, NotificationDefinition> public class NotificationController : ProviderControllerBase<NotificationResource, NotificationBulkResource, INotification, NotificationDefinition>
{ {
public static readonly NotificationResourceMapper ResourceMapper = new NotificationResourceMapper(); public static readonly NotificationResourceMapper ResourceMapper = new NotificationResourceMapper();
public static readonly NotificationBulkResourceMapper BulkResourceMapper = new NotificationBulkResourceMapper();
public NotificationController(NotificationFactory notificationFactory) public NotificationController(NotificationFactory notificationFactory)
: base(notificationFactory, "notification", ResourceMapper) : base(notificationFactory, "notification", ResourceMapper, BulkResourceMapper)
{ {
} }
} }

View File

@ -0,0 +1,28 @@
using System.Collections.Generic;
using NzbDrone.Core.ThingiProvider;
using Radarr.Api.V3.Movies;
namespace Radarr.Api.V3
{
public class ProviderBulkResource<T>
{
public List<int> Ids { get; set; }
public List<int> Tags { get; set; }
public ApplyTags ApplyTags { get; set; }
}
public class ProviderBulkResourceMapper<TProviderBulkResource, TProviderDefinition>
where TProviderBulkResource : ProviderBulkResource<TProviderBulkResource>, new()
where TProviderDefinition : ProviderDefinition, new()
{
public virtual List<TProviderDefinition> UpdateModel(TProviderBulkResource resource, List<TProviderDefinition> existingDefinitions)
{
if (resource == null)
{
return new List<TProviderDefinition>();
}
return existingDefinitions;
}
}
}

View File

@ -6,23 +6,31 @@
using NzbDrone.Common.Serializer; using NzbDrone.Common.Serializer;
using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.ThingiProvider;
using NzbDrone.Core.Validation; using NzbDrone.Core.Validation;
using Radarr.Api.V3.Movies;
using Radarr.Http.REST; using Radarr.Http.REST;
using Radarr.Http.REST.Attributes; using Radarr.Http.REST.Attributes;
namespace Radarr.Api.V3 namespace Radarr.Api.V3
{ {
public abstract class ProviderControllerBase<TProviderResource, TProvider, TProviderDefinition> : RestController<TProviderResource> public abstract class ProviderControllerBase<TProviderResource, TBulkProviderResource, TProvider, TProviderDefinition> : RestController<TProviderResource>
where TProviderDefinition : ProviderDefinition, new() where TProviderDefinition : ProviderDefinition, new()
where TProvider : IProvider where TProvider : IProvider
where TProviderResource : ProviderResource<TProviderResource>, new() where TProviderResource : ProviderResource<TProviderResource>, new()
where TBulkProviderResource : ProviderBulkResource<TBulkProviderResource>, new()
{ {
private readonly IProviderFactory<TProvider, TProviderDefinition> _providerFactory; private readonly IProviderFactory<TProvider, TProviderDefinition> _providerFactory;
private readonly ProviderResourceMapper<TProviderResource, TProviderDefinition> _resourceMapper; private readonly ProviderResourceMapper<TProviderResource, TProviderDefinition> _resourceMapper;
private readonly ProviderBulkResourceMapper<TBulkProviderResource, TProviderDefinition> _bulkResourceMapper;
protected ProviderControllerBase(IProviderFactory<TProvider, TProviderDefinition> providerFactory, string resource, ProviderResourceMapper<TProviderResource, TProviderDefinition> resourceMapper) protected ProviderControllerBase(IProviderFactory<TProvider,
TProviderDefinition> providerFactory,
string resource,
ProviderResourceMapper<TProviderResource, TProviderDefinition> resourceMapper,
ProviderBulkResourceMapper<TBulkProviderResource, TProviderDefinition> bulkResourceMapper)
{ {
_providerFactory = providerFactory; _providerFactory = providerFactory;
_resourceMapper = resourceMapper; _resourceMapper = resourceMapper;
_bulkResourceMapper = bulkResourceMapper;
SharedValidator.RuleFor(c => c.Name).NotEmpty(); SharedValidator.RuleFor(c => c.Name).NotEmpty();
SharedValidator.RuleFor(c => c.Name).Must((v, c) => !_providerFactory.All().Any(p => p.Name == c && p.Id != v.Id)).WithMessage("Should be unique"); SharedValidator.RuleFor(c => c.Name).Must((v, c) => !_providerFactory.All().Any(p => p.Name == c && p.Id != v.Id)).WithMessage("Should be unique");
@ -88,6 +96,39 @@ public ActionResult<TProviderResource> UpdateProvider([FromBody] TProviderResour
return Accepted(providerResource.Id); return Accepted(providerResource.Id);
} }
[HttpPut("bulk")]
[Consumes("application/json")]
public ActionResult<TProviderResource> UpdateProvider([FromBody] TBulkProviderResource providerResource)
{
var definitionsToUpdate = _providerFactory.Get(providerResource.Ids).ToList();
foreach (var definition in definitionsToUpdate)
{
if (providerResource.Tags != null)
{
var newTags = providerResource.Tags;
var applyTags = providerResource.ApplyTags;
switch (applyTags)
{
case ApplyTags.Add:
newTags.ForEach(t => definition.Tags.Add(t));
break;
case ApplyTags.Remove:
newTags.ForEach(t => definition.Tags.Remove(t));
break;
case ApplyTags.Replace:
definition.Tags = new HashSet<int>(newTags);
break;
}
}
}
_bulkResourceMapper.UpdateModel(providerResource, definitionsToUpdate);
return Accepted(_providerFactory.Update(definitionsToUpdate).Select(x => _resourceMapper.ToResource(x)));
}
private TProviderDefinition GetDefinition(TProviderResource providerResource, bool validate, bool includeWarnings, bool forceValidate) private TProviderDefinition GetDefinition(TProviderResource providerResource, bool validate, bool includeWarnings, bool forceValidate)
{ {
var definition = _resourceMapper.ToModel(providerResource); var definition = _resourceMapper.ToModel(providerResource);
@ -107,6 +148,15 @@ public object DeleteProvider(int id)
return new { }; return new { };
} }
[HttpDelete("bulk")]
[Consumes("application/json")]
public object DeleteProviders([FromBody] TBulkProviderResource resource)
{
_providerFactory.Delete(resource.Ids);
return new { };
}
[HttpGet("schema")] [HttpGet("schema")]
public List<TProviderResource> GetTemplates() public List<TProviderResource> GetTemplates()
{ {