mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-17 21:26:13 -04:00
Compare commits
29 Commits
v4.0.11.26
...
v4.0.11.27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c38debab1b | ||
|
|
32f66922e7 | ||
|
|
ed536a85ad | ||
|
|
c62fc9d05b | ||
|
|
fb9a5efe05 | ||
|
|
8cb58a63d8 | ||
|
|
4c41a4f368 | ||
|
|
e039dc45e2 | ||
|
|
776143cc81 | ||
|
|
8c67a3bdee | ||
|
|
160151c6e0 | ||
|
|
efd48710e4 | ||
|
|
00c16cd06b | ||
|
|
65d07fa99e | ||
|
|
bd656ae7f6 | ||
|
|
62bcf397dd | ||
|
|
f9606518ee | ||
|
|
40f4ef27b2 | ||
|
|
93c3f6d1d6 | ||
|
|
417af2b915 | ||
|
|
4491df3ae7 | ||
|
|
a90866a73e | ||
|
|
2f62494adc | ||
|
|
e361f18837 | ||
|
|
183b8b574a | ||
|
|
12c1eb86f2 | ||
|
|
5034d83062 | ||
|
|
dba3a82439 | ||
|
|
b51a490979 |
@@ -26,6 +26,7 @@ module.exports = (env) => {
|
||||
const config = {
|
||||
mode: isProduction ? 'production' : 'development',
|
||||
devtool: isProduction ? 'source-map' : 'eval-source-map',
|
||||
target: 'web',
|
||||
|
||||
stats: {
|
||||
children: false
|
||||
@@ -51,8 +52,7 @@ module.exports = (env) => {
|
||||
'node_modules'
|
||||
],
|
||||
alias: {
|
||||
jquery: 'jquery/dist/jquery.min',
|
||||
'react-middle-truncate': 'react-middle-truncate/lib/react-middle-truncate'
|
||||
jquery: 'jquery/dist/jquery.min'
|
||||
},
|
||||
fallback: {
|
||||
buffer: false,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
|
||||
import React from 'react';
|
||||
import DocumentTitle from 'react-document-title';
|
||||
@@ -12,17 +13,21 @@ interface AppProps {
|
||||
history: ConnectedRouterProps['history'];
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function App({ store, history }: AppProps) {
|
||||
return (
|
||||
<DocumentTitle title={window.Sonarr.instanceName}>
|
||||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>
|
||||
<ApplyTheme />
|
||||
<PageConnector>
|
||||
<AppRoutes />
|
||||
</PageConnector>
|
||||
</ConnectedRouter>
|
||||
</Provider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>
|
||||
<ApplyTheme />
|
||||
<PageConnector>
|
||||
<AppRoutes />
|
||||
</PageConnector>
|
||||
</ConnectedRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</DocumentTitle>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import Column from 'Components/Table/Column';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import { ValidationFailure } from 'typings/pending';
|
||||
import { FilterBuilderProp, PropertyFilter } from './AppState';
|
||||
|
||||
export interface Error {
|
||||
responseJSON: {
|
||||
message: string;
|
||||
};
|
||||
status?: number;
|
||||
responseJSON:
|
||||
| {
|
||||
message: string | undefined;
|
||||
}
|
||||
| ValidationFailure[]
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export interface AppSectionDeleteState {
|
||||
@@ -58,6 +63,16 @@ export interface AppSectionItemState<T> {
|
||||
item: T;
|
||||
}
|
||||
|
||||
export interface AppSectionProviderState<T>
|
||||
extends AppSectionDeleteState,
|
||||
AppSectionSaveState {
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
error: Error;
|
||||
items: T[];
|
||||
pendingChanges: Partial<T>;
|
||||
}
|
||||
|
||||
interface AppSectionState<T> {
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
|
||||
6
frontend/src/App/State/MetadataAppState.ts
Normal file
6
frontend/src/App/State/MetadataAppState.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { AppSectionProviderState } from 'App/State/AppSectionState';
|
||||
import Metadata from 'typings/Metadata';
|
||||
|
||||
interface MetadataAppState extends AppSectionProviderState<Metadata> {}
|
||||
|
||||
export default MetadataAppState;
|
||||
@@ -59,6 +59,8 @@ interface SeriesAppState
|
||||
deleteOptions: {
|
||||
addImportListExclusion: boolean;
|
||||
};
|
||||
|
||||
pendingChanges: Partial<Series>;
|
||||
}
|
||||
|
||||
export default SeriesAppState;
|
||||
|
||||
@@ -20,6 +20,7 @@ import NamingConfig from 'typings/Settings/NamingConfig';
|
||||
import NamingExample from 'typings/Settings/NamingExample';
|
||||
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
||||
import UiSettings from 'typings/Settings/UiSettings';
|
||||
import MetadataAppState from './MetadataAppState';
|
||||
|
||||
export interface DownloadClientAppState
|
||||
extends AppSectionState<DownloadClient>,
|
||||
@@ -97,6 +98,7 @@ interface SettingsAppState {
|
||||
indexerFlags: IndexerFlagSettingsAppState;
|
||||
indexers: IndexerAppState;
|
||||
languages: LanguageSettingsAppState;
|
||||
metadata: MetadataAppState;
|
||||
naming: NamingAppState;
|
||||
namingExamples: NamingExamplesAppState;
|
||||
notifications: NotificationAppState;
|
||||
|
||||
@@ -14,13 +14,14 @@ function FormInputButton({
|
||||
className = styles.button,
|
||||
canSpin = false,
|
||||
isLastButton = true,
|
||||
kind = kinds.PRIMARY,
|
||||
...otherProps
|
||||
}: FormInputButtonProps) {
|
||||
if (canSpin) {
|
||||
return (
|
||||
<SpinnerButton
|
||||
className={classNames(className, !isLastButton && styles.middleButton)}
|
||||
kind={kinds.PRIMARY}
|
||||
kind={kind}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
@@ -29,7 +30,7 @@ function FormInputButton({
|
||||
return (
|
||||
<Button
|
||||
className={classNames(className, !isLastButton && styles.middleButton)}
|
||||
kind={kinds.PRIMARY}
|
||||
kind={kind}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -145,6 +145,7 @@ interface FormInputGroupProps<T> {
|
||||
autoFocus?: boolean;
|
||||
includeNoChange?: boolean;
|
||||
includeNoChangeDisabled?: boolean;
|
||||
valueOptions?: object;
|
||||
selectedValueOptions?: object;
|
||||
indexerFlags?: number;
|
||||
pending?: boolean;
|
||||
|
||||
@@ -16,3 +16,7 @@
|
||||
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.fileBrowserMiddleButton {
|
||||
composes: middleButton from '~./FormInputButton.css';
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'fileBrowserButton': string;
|
||||
'fileBrowserMiddleButton': string;
|
||||
'hasFileBrowser': string;
|
||||
'inputWrapper': string;
|
||||
'pathMatch': string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import classNames from 'classnames';
|
||||
import React, {
|
||||
KeyboardEvent,
|
||||
SyntheticEvent,
|
||||
@@ -29,6 +30,7 @@ interface PathInputProps {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
includeFiles: boolean;
|
||||
hasButton?: boolean;
|
||||
hasFileBrowser?: boolean;
|
||||
onChange: (change: InputChanged<string>) => void;
|
||||
}
|
||||
@@ -96,6 +98,7 @@ export function PathInputInternal(props: PathInputInternalProps) {
|
||||
value: inputValue = '',
|
||||
paths,
|
||||
includeFiles,
|
||||
hasButton,
|
||||
hasFileBrowser = true,
|
||||
onChange,
|
||||
onFetchPaths,
|
||||
@@ -229,9 +232,12 @@ export function PathInputInternal(props: PathInputInternalProps) {
|
||||
/>
|
||||
|
||||
{hasFileBrowser ? (
|
||||
<div>
|
||||
<>
|
||||
<FormInputButton
|
||||
className={styles.fileBrowserButton}
|
||||
className={classNames(
|
||||
styles.fileBrowserButton,
|
||||
hasButton && styles.fileBrowserMiddleButton
|
||||
)}
|
||||
onPress={handleFileBrowserOpenPress}
|
||||
>
|
||||
<Icon name={icons.FOLDER_OPEN} />
|
||||
@@ -245,7 +251,7 @@ export function PathInputInternal(props: PathInputInternalProps) {
|
||||
onChange={onChange}
|
||||
onModalClose={handleFileBrowserModalClose}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -138,6 +138,8 @@ ProviderFieldFormGroup.propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
advanced: PropTypes.bool.isRequired,
|
||||
hidden: PropTypes.string,
|
||||
isDisabled: PropTypes.bool,
|
||||
provider: PropTypes.string,
|
||||
pending: PropTypes.bool.isRequired,
|
||||
errors: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
warnings: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { addRootFolder } from 'Store/Actions/rootFolderActions';
|
||||
import {
|
||||
addRootFolder,
|
||||
fetchRootFolders,
|
||||
} from 'Store/Actions/rootFolderActions';
|
||||
import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
|
||||
import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
@@ -189,6 +192,10 @@ function RootFolderSelectInput({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchRootFolders());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EnhancedSelectInput
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import MiddleTruncate from 'react-middle-truncate';
|
||||
import Label, { LabelProps } from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Link from 'Components/Link/Link';
|
||||
import MiddleTruncate from 'Components/MiddleTruncate';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { TagBase } from './TagInput';
|
||||
import styles from './TagInputTag.css';
|
||||
@@ -58,7 +58,7 @@ function TagInputTag<T extends TagBase>({
|
||||
tabIndex={-1}
|
||||
onPress={handleDelete}
|
||||
>
|
||||
<MiddleTruncate text={String(tag.name)} start={10} end={10} />
|
||||
<MiddleTruncate text={String(tag.name)} />
|
||||
</Link>
|
||||
|
||||
{canEdit ? (
|
||||
|
||||
63
frontend/src/Components/MiddleTruncate.tsx
Normal file
63
frontend/src/Components/MiddleTruncate.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||
|
||||
interface MiddleTruncateProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
function getTruncatedText(text: string, length: number) {
|
||||
return `${text.slice(0, length)}...${text.slice(text.length - length)}`;
|
||||
}
|
||||
|
||||
function MiddleTruncate({ text }: MiddleTruncateProps) {
|
||||
const [containerRef, { width: containerWidth }] = useMeasure();
|
||||
const [textRef, { width: textWidth }] = useMeasure();
|
||||
const [truncatedText, setTruncatedText] = useState(text);
|
||||
const truncatedTextRef = useRef(text);
|
||||
|
||||
useEffect(() => {
|
||||
setTruncatedText(text);
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerWidth || !textWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (textWidth <= containerWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
const characterLength = textWidth / text.length;
|
||||
const charactersToRemove =
|
||||
Math.ceil(text.length - containerWidth / characterLength) + 3;
|
||||
let length = Math.ceil(text.length / 2 - charactersToRemove / 2);
|
||||
|
||||
let updatedText = getTruncatedText(text, length);
|
||||
|
||||
// Make sure if the text is still too long, we keep reducing the length
|
||||
// each time we re-run this.
|
||||
while (
|
||||
updatedText.length >= truncatedTextRef.current.length &&
|
||||
length > 10
|
||||
) {
|
||||
length--;
|
||||
updatedText = getTruncatedText(text, length);
|
||||
}
|
||||
|
||||
// Store the value in the ref so we can compare it in the next render,
|
||||
// without triggering this effect every time we change the text.
|
||||
truncatedTextRef.current = updatedText;
|
||||
setTruncatedText(updatedText);
|
||||
}, [text, truncatedTextRef, containerWidth, textWidth]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ whiteSpace: 'nowrap' }}>
|
||||
<div ref={textRef} style={{ display: 'inline-block' }}>
|
||||
{truncatedText}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MiddleTruncate;
|
||||
56
frontend/src/Helpers/Hooks/useApiQuery.ts
Normal file
56
frontend/src/Helpers/Hooks/useApiQuery.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface QueryOptions {
|
||||
url: string;
|
||||
headers?: HeadersInit;
|
||||
}
|
||||
|
||||
const absUrlRegex = /^(https?:)?\/\//i;
|
||||
const apiRoot = window.Sonarr.apiRoot;
|
||||
|
||||
function isAbsolute(url: string) {
|
||||
return absUrlRegex.test(url);
|
||||
}
|
||||
|
||||
function getUrl(url: string) {
|
||||
return apiRoot + url;
|
||||
}
|
||||
|
||||
function useApiQuery<T>(options: QueryOptions) {
|
||||
const { url, headers } = options;
|
||||
|
||||
const final = useMemo(() => {
|
||||
if (isAbsolute(url)) {
|
||||
return {
|
||||
url,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url: getUrl(url),
|
||||
headers: {
|
||||
...headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
},
|
||||
};
|
||||
}, [url, headers]);
|
||||
|
||||
return useQuery({
|
||||
queryKey: [final.url],
|
||||
queryFn: async () => {
|
||||
const result = await fetch(final.url, {
|
||||
headers: final.headers,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error('Failed to fetch');
|
||||
}
|
||||
|
||||
return result.json() as T;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default useApiQuery;
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
faFilter as fasFilter,
|
||||
faFlag as fasFlag,
|
||||
faFolderOpen as fasFolderOpen,
|
||||
faFolderTree as farFolderTree,
|
||||
faForward as fasForward,
|
||||
faHeart as fasHeart,
|
||||
faHistory as fasHistory,
|
||||
@@ -201,6 +202,7 @@ export const REMOVE = fasTimes;
|
||||
export const RESTART = fasRedoAlt;
|
||||
export const RESTORE = fasHistory;
|
||||
export const REORDER = fasBars;
|
||||
export const ROOT_FOLDER = farFolderTree;
|
||||
export const RSS = fasRss;
|
||||
export const SAVE = fasSave;
|
||||
export const SCENE_MAPPING = fasSitemap;
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
}
|
||||
|
||||
.title {
|
||||
text-wrap: balance;
|
||||
font-weight: 300;
|
||||
font-size: 50px;
|
||||
line-height: 50px;
|
||||
@@ -143,6 +144,7 @@
|
||||
flex: 1 0 0;
|
||||
margin-top: 8px;
|
||||
min-height: 0;
|
||||
text-wrap: balance;
|
||||
font-size: $intermediateFontSize;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { align, icons, kinds, sizes, sortDirections, tooltipPositions } from 'He
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
|
||||
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
|
||||
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
|
||||
import EditSeriesModal from 'Series/Edit/EditSeriesModal';
|
||||
import SeriesHistoryModal from 'Series/History/SeriesHistoryModal';
|
||||
import MonitoringOptionsModal from 'Series/MonitoringOptions/MonitoringOptionsModal';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
@@ -709,7 +709,7 @@ class SeriesDetails extends Component {
|
||||
onModalClose={this.onSeriesHistoryModalClose}
|
||||
/>
|
||||
|
||||
<EditSeriesModalConnector
|
||||
<EditSeriesModal
|
||||
isOpen={isEditSeriesModalOpen}
|
||||
seriesId={id}
|
||||
onModalClose={this.onEditSeriesModalClose}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import EditSeriesModalContentConnector from './EditSeriesModalContentConnector';
|
||||
|
||||
function EditSeriesModal({ isOpen, onModalClose, ...otherProps }) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<EditSeriesModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
EditSeriesModal.propTypes = {
|
||||
...EditSeriesModalContentConnector.propTypes,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EditSeriesModal;
|
||||
34
frontend/src/Series/Edit/EditSeriesModal.tsx
Normal file
34
frontend/src/Series/Edit/EditSeriesModal.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import { EditSeriesModalContentProps } from './EditSeriesModalContent';
|
||||
import EditSeriesModalContentConnector from './EditSeriesModalContentConnector';
|
||||
|
||||
interface EditSeriesModalProps extends EditSeriesModalContentProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
function EditSeriesModal({
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
}: EditSeriesModalProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
dispatch(clearPendingChanges({ section: 'series' }));
|
||||
onModalClose();
|
||||
}, [dispatch, onModalClose]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={handleModalClose}>
|
||||
<EditSeriesModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={handleModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditSeriesModal;
|
||||
@@ -1,240 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import SeriesMonitorNewItemsOptionsPopoverContent from 'AddSeries/SeriesMonitorNewItemsOptionsPopoverContent';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import MoveSeriesModal from 'Series/MoveSeries/MoveSeriesModal';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EditSeriesModalContent.css';
|
||||
|
||||
class EditSeriesModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isConfirmMoveModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onCancelPress = () => {
|
||||
this.setState({ isConfirmMoveModalOpen: false });
|
||||
};
|
||||
|
||||
onSavePress = () => {
|
||||
const {
|
||||
isPathChanging,
|
||||
onSavePress
|
||||
} = this.props;
|
||||
|
||||
if (isPathChanging && !this.state.isConfirmMoveModalOpen) {
|
||||
this.setState({ isConfirmMoveModalOpen: true });
|
||||
} else {
|
||||
this.setState({ isConfirmMoveModalOpen: false });
|
||||
|
||||
onSavePress(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMoveSeriesPress = () => {
|
||||
this.setState({ isConfirmMoveModalOpen: false });
|
||||
|
||||
this.props.onSavePress(true);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
title,
|
||||
item,
|
||||
isSaving,
|
||||
originalPath,
|
||||
onInputChange,
|
||||
onModalClose,
|
||||
onDeleteSeriesPress,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
monitored,
|
||||
monitorNewItems,
|
||||
seasonFolder,
|
||||
qualityProfileId,
|
||||
seriesType,
|
||||
path,
|
||||
tags
|
||||
} = item;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('EditSeriesModalHeader', { title })}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form {...otherProps}>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Monitored')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="monitored"
|
||||
helpText={translate('MonitoredEpisodesHelpText')}
|
||||
{...monitored}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
{translate('MonitorNewSeasons')}
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
className={styles.labelIcon}
|
||||
name={icons.INFO}
|
||||
/>
|
||||
}
|
||||
title={translate('MonitorNewSeasons')}
|
||||
body={<SeriesMonitorNewItemsOptionsPopoverContent />}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/>
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.MONITOR_NEW_ITEMS_SELECT}
|
||||
name="monitorNewItems"
|
||||
helpText={translate('MonitorNewSeasonsHelpText')}
|
||||
{...monitorNewItems}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('UseSeasonFolder')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="seasonFolder"
|
||||
helpText={translate('UseSeasonFolderHelpText')}
|
||||
{...seasonFolder}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('QualityProfile')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
{...qualityProfileId}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('SeriesType')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SERIES_TYPE_SELECT}
|
||||
name="seriesType"
|
||||
{...seriesType}
|
||||
helpText={translate('SeriesTypesHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Path')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PATH}
|
||||
name="path"
|
||||
{...path}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
{...tags}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
className={styles.deleteButton}
|
||||
kind={kinds.DANGER}
|
||||
onPress={onDeleteSeriesPress}
|
||||
>
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Cancel')}
|
||||
</Button>
|
||||
|
||||
<SpinnerButton
|
||||
isSpinning={isSaving}
|
||||
onPress={this.onSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerButton>
|
||||
</ModalFooter>
|
||||
|
||||
<MoveSeriesModal
|
||||
originalPath={originalPath}
|
||||
destinationPath={path.value}
|
||||
isOpen={this.state.isConfirmMoveModalOpen}
|
||||
onModalClose={this.onCancelPress}
|
||||
onSavePress={this.onSavePress}
|
||||
onMoveSeriesPress={this.onMoveSeriesPress}
|
||||
/>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditSeriesModalContent.propTypes = {
|
||||
seriesId: PropTypes.number.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
item: PropTypes.object.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
isPathChanging: PropTypes.bool.isRequired,
|
||||
originalPath: PropTypes.string.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onSavePress: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
onDeleteSeriesPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EditSeriesModalContent;
|
||||
295
frontend/src/Series/Edit/EditSeriesModalContent.tsx
Normal file
295
frontend/src/Series/Edit/EditSeriesModalContent.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import SeriesMonitorNewItemsOptionsPopoverContent from 'AddSeries/SeriesMonitorNewItemsOptionsPopoverContent';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import MoveSeriesModal from 'Series/MoveSeries/MoveSeriesModal';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { saveSeries, setSeriesValue } from 'Store/Actions/seriesActions';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import RootFolderModal from './RootFolder/RootFolderModal';
|
||||
import { RootFolderUpdated } from './RootFolder/RootFolderModalContent';
|
||||
import styles from './EditSeriesModalContent.css';
|
||||
|
||||
export interface EditSeriesModalContentProps {
|
||||
seriesId: number;
|
||||
onModalClose: () => void;
|
||||
onDeleteSeriesPress: () => void;
|
||||
}
|
||||
function EditSeriesModalContent({
|
||||
seriesId,
|
||||
onModalClose,
|
||||
onDeleteSeriesPress,
|
||||
}: EditSeriesModalContentProps) {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
title,
|
||||
monitored,
|
||||
monitorNewItems,
|
||||
seasonFolder,
|
||||
qualityProfileId,
|
||||
seriesType,
|
||||
path,
|
||||
tags,
|
||||
rootFolderPath: initialRootFolderPath,
|
||||
} = useSeries(seriesId)!;
|
||||
|
||||
const { isSaving, saveError, pendingChanges } = useSelector(
|
||||
(state: AppState) => state.series
|
||||
);
|
||||
|
||||
const [isRootFolderModalOpen, setIsRootFolderModalOpen] = useState(false);
|
||||
|
||||
const [rootFolderPath, setRootFolderPath] = useState(initialRootFolderPath);
|
||||
|
||||
const isPathChanging = pendingChanges.path && path !== pendingChanges.path;
|
||||
|
||||
const [isConfirmMoveModalOpen, setIsConfirmMoveModalOpen] = useState(false);
|
||||
|
||||
const { settings, ...otherSettings } = useMemo(() => {
|
||||
return selectSettings(
|
||||
{
|
||||
monitored,
|
||||
monitorNewItems,
|
||||
seasonFolder,
|
||||
qualityProfileId,
|
||||
seriesType,
|
||||
path,
|
||||
tags,
|
||||
},
|
||||
pendingChanges,
|
||||
saveError
|
||||
);
|
||||
}, [
|
||||
monitored,
|
||||
monitorNewItems,
|
||||
seasonFolder,
|
||||
qualityProfileId,
|
||||
seriesType,
|
||||
path,
|
||||
tags,
|
||||
pendingChanges,
|
||||
saveError,
|
||||
]);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
// @ts-expect-error actions aren't typed
|
||||
dispatch(setSeriesValue({ name, value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleRootFolderPress = useCallback(() => {
|
||||
setIsRootFolderModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleRootFolderModalClose = useCallback(() => {
|
||||
setIsRootFolderModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleRootFolderChange = useCallback(
|
||||
({
|
||||
path: newPath,
|
||||
rootFolderPath: newRootFolderPath,
|
||||
}: RootFolderUpdated) => {
|
||||
setIsRootFolderModalOpen(false);
|
||||
setRootFolderPath(newRootFolderPath);
|
||||
handleInputChange({ name: 'path', value: newPath });
|
||||
},
|
||||
[handleInputChange]
|
||||
);
|
||||
|
||||
const handleCancelPress = useCallback(() => {
|
||||
setIsConfirmMoveModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSavePress = useCallback(() => {
|
||||
if (isPathChanging && !isConfirmMoveModalOpen) {
|
||||
setIsConfirmMoveModalOpen(true);
|
||||
} else {
|
||||
setIsConfirmMoveModalOpen(false);
|
||||
|
||||
dispatch(
|
||||
saveSeries({
|
||||
id: seriesId,
|
||||
moveFiles: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [seriesId, isPathChanging, isConfirmMoveModalOpen, dispatch]);
|
||||
|
||||
const handleMoveSeriesPress = useCallback(() => {
|
||||
setIsConfirmMoveModalOpen(false);
|
||||
|
||||
dispatch(
|
||||
saveSeries({
|
||||
id: seriesId,
|
||||
moveFiles: true,
|
||||
})
|
||||
);
|
||||
}, [seriesId, dispatch]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('EditSeriesModalHeader', { title })}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form {...otherSettings}>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Monitored')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="monitored"
|
||||
helpText={translate('MonitoredEpisodesHelpText')}
|
||||
{...settings.monitored}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
{translate('MonitorNewSeasons')}
|
||||
<Popover
|
||||
anchor={<Icon className={styles.labelIcon} name={icons.INFO} />}
|
||||
title={translate('MonitorNewSeasons')}
|
||||
body={<SeriesMonitorNewItemsOptionsPopoverContent />}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/>
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.MONITOR_NEW_ITEMS_SELECT}
|
||||
name="monitorNewItems"
|
||||
helpText={translate('MonitorNewSeasonsHelpText')}
|
||||
{...settings.monitorNewItems}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('UseSeasonFolder')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="seasonFolder"
|
||||
helpText={translate('UseSeasonFolderHelpText')}
|
||||
{...settings.seasonFolder}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('QualityProfile')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
{...settings.qualityProfileId}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('SeriesType')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SERIES_TYPE_SELECT}
|
||||
name="seriesType"
|
||||
{...settings.seriesType}
|
||||
helpText={translate('SeriesTypesHelpText')}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Path')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PATH}
|
||||
name="path"
|
||||
{...settings.path}
|
||||
buttons={[
|
||||
<FormInputButton
|
||||
key="fileBrowser"
|
||||
kind={kinds.DEFAULT}
|
||||
title="Root Folder"
|
||||
onPress={handleRootFolderPress}
|
||||
>
|
||||
<Icon name={icons.ROOT_FOLDER} />
|
||||
</FormInputButton>,
|
||||
]}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
{...settings.tags}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
className={styles.deleteButton}
|
||||
kind={kinds.DANGER}
|
||||
onPress={onDeleteSeriesPress}
|
||||
>
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
error={saveError}
|
||||
isSpinning={isSaving}
|
||||
onPress={handleSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
|
||||
<RootFolderModal
|
||||
isOpen={isRootFolderModalOpen}
|
||||
seriesId={seriesId}
|
||||
rootFolderPath={rootFolderPath}
|
||||
onSavePress={handleRootFolderChange}
|
||||
onModalClose={handleRootFolderModalClose}
|
||||
/>
|
||||
|
||||
<MoveSeriesModal
|
||||
originalPath={path}
|
||||
destinationPath={pendingChanges.path}
|
||||
isOpen={isConfirmMoveModalOpen}
|
||||
onModalClose={handleCancelPress}
|
||||
onSavePress={handleSavePress}
|
||||
onMoveSeriesPress={handleMoveSeriesPress}
|
||||
/>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditSeriesModalContent;
|
||||
26
frontend/src/Series/Edit/RootFolder/RootFolderModal.tsx
Normal file
26
frontend/src/Series/Edit/RootFolder/RootFolderModal.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import RootFolderModalContent, {
|
||||
RootFolderModalContentProps,
|
||||
} from './RootFolderModalContent';
|
||||
|
||||
interface RootFolderModalProps extends RootFolderModalContentProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
function RootFolderModal(props: RootFolderModalProps) {
|
||||
const { isOpen, rootFolderPath, seriesId, onSavePress, onModalClose } = props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<RootFolderModalContent
|
||||
seriesId={seriesId}
|
||||
rootFolderPath={rootFolderPath}
|
||||
onSavePress={onSavePress}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default RootFolderModal;
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
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 useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
export interface RootFolderUpdated {
|
||||
path: string;
|
||||
rootFolderPath: string;
|
||||
}
|
||||
|
||||
export interface RootFolderModalContentProps {
|
||||
seriesId: number;
|
||||
rootFolderPath: string;
|
||||
onSavePress(change: RootFolderUpdated): void;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
interface SeriesFolder {
|
||||
folder: string;
|
||||
}
|
||||
|
||||
function RootFolderModalContent(props: RootFolderModalContentProps) {
|
||||
const { seriesId, onSavePress, onModalClose } = props;
|
||||
const { isWindows } = useSelector(createSystemStatusSelector());
|
||||
|
||||
const [rootFolderPath, setRootFolderPath] = useState(props.rootFolderPath);
|
||||
|
||||
const { isLoading, data } = useApiQuery<SeriesFolder>({
|
||||
url: `/series/${seriesId}/folder`,
|
||||
});
|
||||
|
||||
const onInputChange = useCallback(({ value }: InputChanged<string>) => {
|
||||
setRootFolderPath(value);
|
||||
}, []);
|
||||
|
||||
const handleSavePress = useCallback(() => {
|
||||
const separator = isWindows ? '\\' : '/';
|
||||
|
||||
onSavePress({
|
||||
path: `${rootFolderPath}${separator}${data?.folder}`,
|
||||
rootFolderPath,
|
||||
});
|
||||
}, [rootFolderPath, isWindows, data, onSavePress]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('UpdateSeriesPath')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('RootFolder')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.ROOT_FOLDER_SELECT}
|
||||
name="rootFolderPath"
|
||||
value={rootFolderPath}
|
||||
valueOptions={{
|
||||
seriesFolder: data?.folder,
|
||||
isWindows,
|
||||
}}
|
||||
selectedValueOptions={{
|
||||
seriesFolder: data?.folder,
|
||||
isWindows,
|
||||
}}
|
||||
helpText={translate('SeriesEditRootFolderHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<Button disabled={isLoading || !data?.folder} onPress={handleSavePress}>
|
||||
{translate('UpdatePath')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default RootFolderModalContent;
|
||||
@@ -9,7 +9,7 @@ import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import TagListConnector from 'Components/TagListConnector';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
|
||||
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
|
||||
import EditSeriesModal from 'Series/Edit/EditSeriesModal';
|
||||
import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar';
|
||||
import SeriesIndexPosterSelect from 'Series/Index/Select/SeriesIndexPosterSelect';
|
||||
import { Statistics } from 'Series/Series';
|
||||
@@ -252,7 +252,7 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditSeriesModalConnector
|
||||
<EditSeriesModal
|
||||
isOpen={isEditSeriesModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={onEditSeriesModalClose}
|
||||
|
||||
@@ -9,7 +9,7 @@ import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import TagListConnector from 'Components/TagListConnector';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
|
||||
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
|
||||
import EditSeriesModal from 'Series/Edit/EditSeriesModal';
|
||||
import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar';
|
||||
import SeriesIndexPosterSelect from 'Series/Index/Select/SeriesIndexPosterSelect';
|
||||
import { Statistics } from 'Series/Series';
|
||||
@@ -268,7 +268,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
|
||||
showTags={showTags}
|
||||
/>
|
||||
|
||||
<EditSeriesModalConnector
|
||||
<EditSeriesModal
|
||||
isOpen={isEditSeriesModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={onEditSeriesModalClose}
|
||||
|
||||
@@ -15,7 +15,7 @@ import Column from 'Components/Table/Column';
|
||||
import TagListConnector from 'Components/TagListConnector';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
|
||||
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
|
||||
import EditSeriesModal from 'Series/Edit/EditSeriesModal';
|
||||
import createSeriesIndexItemSelector from 'Series/Index/createSeriesIndexItemSelector';
|
||||
import { Statistics } from 'Series/Series';
|
||||
import SeriesBanner from 'Series/SeriesBanner';
|
||||
@@ -492,7 +492,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
|
||||
return null;
|
||||
})}
|
||||
|
||||
<EditSeriesModalConnector
|
||||
<EditSeriesModal
|
||||
isOpen={isEditSeriesModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={onEditSeriesModalClose}
|
||||
|
||||
@@ -39,7 +39,7 @@ function createImportListExclusionSelector(id?: number) {
|
||||
importListExclusions;
|
||||
|
||||
const mapping = id
|
||||
? items.find((i) => i.id === id)
|
||||
? items.find((i) => i.id === id)!
|
||||
: newImportListExclusion;
|
||||
const settings = selectSettings(mapping, pendingChanges, saveError);
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import EditMetadataModalContentConnector from './EditMetadataModalContentConnector';
|
||||
|
||||
function EditMetadataModal({ isOpen, onModalClose, ...otherProps }) {
|
||||
return (
|
||||
<Modal
|
||||
size={sizes.MEDIUM}
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<EditMetadataModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
EditMetadataModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EditMetadataModal;
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import EditMetadataModalContent, {
|
||||
EditMetadataModalContentProps,
|
||||
} from './EditMetadataModalContent';
|
||||
|
||||
interface EditMetadataModalProps extends EditMetadataModalContentProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
function EditMetadataModal({
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
}: EditMetadataModalProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
dispatch(clearPendingChanges({ section: 'metadata' }));
|
||||
onModalClose();
|
||||
}, [dispatch, onModalClose]);
|
||||
|
||||
return (
|
||||
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={handleModalClose}>
|
||||
<EditMetadataModalContent
|
||||
{...otherProps}
|
||||
onModalClose={handleModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditMetadataModal;
|
||||
@@ -1,44 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import EditMetadataModal from './EditMetadataModal';
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
const section = 'settings.metadata';
|
||||
|
||||
return {
|
||||
dispatchClearPendingChanges() {
|
||||
dispatch(clearPendingChanges({ section }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class EditMetadataModalConnector extends Component {
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onModalClose = () => {
|
||||
this.props.dispatchClearPendingChanges({ section: 'metadata' });
|
||||
this.props.onModalClose();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EditMetadataModal
|
||||
{...this.props}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditMetadataModalConnector.propTypes = {
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
dispatchClearPendingChanges: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(null, createMapDispatchToProps)(EditMetadataModalConnector);
|
||||
@@ -0,0 +1,5 @@
|
||||
.message {
|
||||
composes: alert from '~Components/Alert.css';
|
||||
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
7
frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.css.d.ts
vendored
Normal file
7
frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.css.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'message': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -1,105 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
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 ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||
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';
|
||||
|
||||
function EditMetadataModalContent(props) {
|
||||
const {
|
||||
advancedSettings,
|
||||
isSaving,
|
||||
saveError,
|
||||
item,
|
||||
onInputChange,
|
||||
onFieldChange,
|
||||
onModalClose,
|
||||
onSavePress,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const {
|
||||
name,
|
||||
enable,
|
||||
fields
|
||||
} = item;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('EditMetadata', { metadataType: name.value })}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form {...otherProps}>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Enable')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="enable"
|
||||
helpText={translate('EnableMetadataHelpText')}
|
||||
{...enable}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
fields.map((field) => {
|
||||
return (
|
||||
<ProviderFieldFormGroup
|
||||
key={field.name}
|
||||
advancedSettings={advancedSettings}
|
||||
provider="metadata"
|
||||
{...field}
|
||||
isDisabled={!enable.value}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
</Form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Cancel')}
|
||||
</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isSaving}
|
||||
error={saveError}
|
||||
onPress={onSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
EditMetadataModalContent.propTypes = {
|
||||
advancedSettings: PropTypes.bool.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
item: PropTypes.object.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onFieldChange: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
onSavePress: PropTypes.func.isRequired,
|
||||
onDeleteMetadataPress: PropTypes.func
|
||||
};
|
||||
|
||||
export default EditMetadataModalContent;
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Alert from 'Components/Alert';
|
||||
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 ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||
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 {
|
||||
saveMetadata,
|
||||
setMetadataFieldValue,
|
||||
setMetadataValue,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EditMetadataModalContent.css';
|
||||
|
||||
export interface EditMetadataModalContentProps {
|
||||
id: number;
|
||||
advancedSettings: boolean;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function EditMetadataModalContent({
|
||||
id,
|
||||
advancedSettings,
|
||||
onModalClose,
|
||||
}: EditMetadataModalContentProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { isSaving, saveError, pendingChanges, items } = useSelector(
|
||||
(state: AppState) => state.settings.metadata
|
||||
);
|
||||
|
||||
const { settings, ...otherSettings } = useMemo(() => {
|
||||
const item = items.find((item) => item.id === id)!;
|
||||
|
||||
return selectSettings(item, pendingChanges, saveError);
|
||||
}, [id, items, pendingChanges, saveError]);
|
||||
|
||||
const { name, enable, fields, message } = settings;
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
// @ts-expect-error not typed
|
||||
dispatch(setMetadataValue({ name, value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
// @ts-expect-error not typed
|
||||
dispatch(setMetadataFieldValue({ name, value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleSavePress = useCallback(() => {
|
||||
dispatch(saveMetadata({ id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('EditMetadata', { metadataType: name.value })}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form {...otherSettings}>
|
||||
{message ? (
|
||||
<Alert className={styles.message} kind={message.value.type}>
|
||||
{message.value.message}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Enable')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="enable"
|
||||
helpText={translate('EnableMetadataHelpText')}
|
||||
{...enable}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{fields.map((field) => {
|
||||
return (
|
||||
<ProviderFieldFormGroup
|
||||
key={field.name}
|
||||
advancedSettings={advancedSettings}
|
||||
provider="metadata"
|
||||
{...field}
|
||||
isDisabled={!enable.value}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isSaving}
|
||||
error={saveError}
|
||||
onPress={handleSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditMetadataModalContent;
|
||||
@@ -1,95 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { saveMetadata, setMetadataFieldValue, setMetadataValue } from 'Store/Actions/settingsActions';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import EditMetadataModalContent from './EditMetadataModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.advancedSettings,
|
||||
(state, { id }) => id,
|
||||
(state) => state.settings.metadata,
|
||||
(advancedSettings, id, metadata) => {
|
||||
const {
|
||||
isSaving,
|
||||
saveError,
|
||||
pendingChanges,
|
||||
items
|
||||
} = metadata;
|
||||
|
||||
const settings = selectSettings(_.find(items, { id }), pendingChanges, saveError);
|
||||
|
||||
return {
|
||||
advancedSettings,
|
||||
id,
|
||||
isSaving,
|
||||
saveError,
|
||||
item: settings.settings,
|
||||
...settings
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setMetadataValue,
|
||||
setMetadataFieldValue,
|
||||
saveMetadata
|
||||
};
|
||||
|
||||
class EditMetadataModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
|
||||
this.props.onModalClose();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.props.setMetadataValue({ name, value });
|
||||
};
|
||||
|
||||
onFieldChange = ({ name, value }) => {
|
||||
this.props.setMetadataFieldValue({ name, value });
|
||||
};
|
||||
|
||||
onSavePress = () => {
|
||||
this.props.saveMetadata({ id: this.props.id });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EditMetadataModalContent
|
||||
{...this.props}
|
||||
onSavePress={this.onSavePress}
|
||||
onInputChange={this.onInputChange}
|
||||
onFieldChange={this.onFieldChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditMetadataModalContentConnector.propTypes = {
|
||||
id: PropTypes.number,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
item: PropTypes.object.isRequired,
|
||||
setMetadataValue: PropTypes.func.isRequired,
|
||||
setMetadataFieldValue: PropTypes.func.isRequired,
|
||||
saveMetadata: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(EditMetadataModalContentConnector);
|
||||
@@ -1,150 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditMetadataModalConnector from './EditMetadataModalConnector';
|
||||
import styles from './Metadata.css';
|
||||
|
||||
class Metadata extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isEditMetadataModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onEditMetadataPress = () => {
|
||||
this.setState({ isEditMetadataModalOpen: true });
|
||||
};
|
||||
|
||||
onEditMetadataModalClose = () => {
|
||||
this.setState({ isEditMetadataModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
enable,
|
||||
fields
|
||||
} = this.props;
|
||||
|
||||
const metadataFields = [];
|
||||
const imageFields = [];
|
||||
|
||||
fields.forEach((field) => {
|
||||
if (field.section === 'metadata') {
|
||||
metadataFields.push(field);
|
||||
} else {
|
||||
imageFields.push(field);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={styles.metadata}
|
||||
overlayContent={true}
|
||||
onPress={this.onEditMetadataPress}
|
||||
>
|
||||
<div className={styles.name}>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{
|
||||
enable ?
|
||||
<Label kind={kinds.SUCCESS}>
|
||||
{translate('Enabled')}
|
||||
</Label> :
|
||||
<Label
|
||||
kind={kinds.DISABLED}
|
||||
outline={true}
|
||||
>
|
||||
{translate('Disabled')}
|
||||
</Label>
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
enable && !!metadataFields.length &&
|
||||
<div>
|
||||
<div className={styles.section}>
|
||||
{translate('Metadata')}
|
||||
</div>
|
||||
|
||||
{
|
||||
metadataFields.map((field) => {
|
||||
if (!field.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
key={field.label}
|
||||
kind={kinds.SUCCESS}
|
||||
>
|
||||
{field.label}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
enable && !!imageFields.length &&
|
||||
<div>
|
||||
<div className={styles.section}>
|
||||
{translate('Images')}
|
||||
</div>
|
||||
|
||||
{
|
||||
imageFields.map((field) => {
|
||||
if (!field.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
key={field.label}
|
||||
kind={kinds.SUCCESS}
|
||||
>
|
||||
{field.label}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<EditMetadataModalConnector
|
||||
id={id}
|
||||
isOpen={this.state.isEditMetadataModalOpen}
|
||||
onModalClose={this.onEditMetadataModalClose}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Metadata.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
enable: PropTypes.bool.isRequired,
|
||||
fields: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||
};
|
||||
|
||||
export default Metadata;
|
||||
107
frontend/src/Settings/Metadata/Metadata/Metadata.tsx
Normal file
107
frontend/src/Settings/Metadata/Metadata/Metadata.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import Field from 'typings/Field';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditMetadataModal from './EditMetadataModal';
|
||||
import styles from './Metadata.css';
|
||||
|
||||
interface MetadataProps {
|
||||
id: number;
|
||||
name: string;
|
||||
enable: boolean;
|
||||
fields: Field[];
|
||||
}
|
||||
|
||||
function Metadata({ id, name, enable, fields }: MetadataProps) {
|
||||
const [isEditMetadataModalOpen, setIsEditMetadataModalOpen] = useState(false);
|
||||
|
||||
const { metadataFields, imageFields } = useMemo(() => {
|
||||
return fields.reduce<{ metadataFields: Field[]; imageFields: Field[] }>(
|
||||
(acc, field) => {
|
||||
if (field.section === 'metadata') {
|
||||
acc.metadataFields.push(field);
|
||||
} else {
|
||||
acc.imageFields.push(field);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ metadataFields: [], imageFields: [] }
|
||||
);
|
||||
}, [fields]);
|
||||
|
||||
const handleOpenPress = useCallback(() => {
|
||||
setIsEditMetadataModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
setIsEditMetadataModalOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={styles.metadata}
|
||||
overlayContent={true}
|
||||
onPress={handleOpenPress}
|
||||
>
|
||||
<div className={styles.name}>{name}</div>
|
||||
|
||||
<div>
|
||||
{enable ? (
|
||||
<Label kind={kinds.SUCCESS}>{translate('Enabled')}</Label>
|
||||
) : (
|
||||
<Label kind={kinds.DISABLED} outline={true}>
|
||||
{translate('Disabled')}
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{enable && metadataFields.length ? (
|
||||
<div>
|
||||
<div className={styles.section}>{translate('Metadata')}</div>
|
||||
|
||||
{metadataFields.map((field) => {
|
||||
if (!field.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label key={field.label} kind={kinds.SUCCESS}>
|
||||
{field.label}
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{enable && imageFields.length ? (
|
||||
<div>
|
||||
<div className={styles.section}>{translate('Images')}</div>
|
||||
|
||||
{imageFields.map((field) => {
|
||||
if (!field.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label key={field.label} kind={kinds.SUCCESS}>
|
||||
{field.label}
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<EditMetadataModal
|
||||
advancedSettings={false}
|
||||
id={id}
|
||||
isOpen={isEditMetadataModalOpen}
|
||||
onModalClose={handleModalClose}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default Metadata;
|
||||
@@ -1,44 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import Metadata from './Metadata';
|
||||
import styles from './Metadatas.css';
|
||||
|
||||
function Metadatas(props) {
|
||||
const {
|
||||
items,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<FieldSet legend={translate('Metadata')}>
|
||||
<PageSectionContent
|
||||
errorMessage={translate('MetadataLoadError')}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.metadatas}>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<Metadata
|
||||
key={item.id}
|
||||
{...item}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</PageSectionContent>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
Metadatas.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||
};
|
||||
|
||||
export default Metadatas;
|
||||
52
frontend/src/Settings/Metadata/Metadata/Metadatas.tsx
Normal file
52
frontend/src/Settings/Metadata/Metadata/Metadatas.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import MetadataAppState from 'App/State/MetadataAppState';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||
import { fetchMetadata } from 'Store/Actions/settingsActions';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import MetadataType from 'typings/Metadata';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import Metadata from './Metadata';
|
||||
import styles from './Metadatas.css';
|
||||
|
||||
function createMetadatasSelector() {
|
||||
return createSelector(
|
||||
createSortedSectionSelector<MetadataType>(
|
||||
'settings.metadata',
|
||||
sortByProp('name')
|
||||
),
|
||||
(metadata: MetadataAppState) => metadata
|
||||
);
|
||||
}
|
||||
|
||||
function Metadatas() {
|
||||
const dispatch = useDispatch();
|
||||
const { isFetching, error, items, ...otherProps } = useSelector(
|
||||
createMetadatasSelector()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchMetadata());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<FieldSet legend={translate('Metadata')}>
|
||||
<PageSectionContent
|
||||
isFetching={isFetching}
|
||||
errorMessage={translate('MetadataLoadError')}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.metadatas}>
|
||||
{items.map((item) => {
|
||||
return <Metadata key={item.id} {...item} />;
|
||||
})}
|
||||
</div>
|
||||
</PageSectionContent>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export default Metadatas;
|
||||
@@ -3,7 +3,7 @@ import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import MetadatasConnector from './Metadata/MetadatasConnector';
|
||||
import Metadatas from './Metadata/Metadatas';
|
||||
|
||||
function MetadataSettings() {
|
||||
return (
|
||||
@@ -13,7 +13,7 @@ function MetadataSettings() {
|
||||
/>
|
||||
|
||||
<PageContentBody>
|
||||
<MetadatasConnector />
|
||||
<Metadatas />
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
|
||||
@@ -19,14 +19,15 @@ import {
|
||||
setReleaseProfileValue,
|
||||
} from 'Store/Actions/Settings/releaseProfiles';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import { PendingSection } from 'typings/pending';
|
||||
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EditReleaseProfileModalContent.css';
|
||||
|
||||
const tagInputDelimiters = ['Tab', 'Enter'];
|
||||
|
||||
const newReleaseProfile = {
|
||||
const newReleaseProfile: ReleaseProfile = {
|
||||
id: 0,
|
||||
name: '',
|
||||
enabled: true,
|
||||
required: [],
|
||||
ignored: [],
|
||||
@@ -41,8 +42,12 @@ function createReleaseProfileSelector(id?: number) {
|
||||
const { items, isFetching, error, isSaving, saveError, pendingChanges } =
|
||||
releaseProfiles;
|
||||
|
||||
const mapping = id ? items.find((i) => i.id === id) : newReleaseProfile;
|
||||
const settings = selectSettings(mapping, pendingChanges, saveError);
|
||||
const mapping = id ? items.find((i) => i.id === id)! : newReleaseProfile;
|
||||
const settings = selectSettings<ReleaseProfile>(
|
||||
mapping,
|
||||
pendingChanges,
|
||||
saveError
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -50,7 +55,7 @@ function createReleaseProfileSelector(id?: number) {
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
item: settings.settings as PendingSection<ReleaseProfile>,
|
||||
item: settings.settings,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import MiddleTruncate from 'react-middle-truncate';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import MiddleTruncate from 'Components/MiddleTruncate';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import TagList from 'Components/TagList';
|
||||
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
|
||||
@@ -13,14 +13,14 @@ import Indexer from 'typings/Indexer';
|
||||
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditReleaseProfileModal from './EditReleaseProfileModal';
|
||||
import styles from './ReleaseProfileRow.css';
|
||||
import styles from './ReleaseProfileItem.css';
|
||||
|
||||
interface ReleaseProfileProps extends ReleaseProfile {
|
||||
tagList: Tag[];
|
||||
indexerList: Indexer[];
|
||||
}
|
||||
|
||||
function ReleaseProfileRow(props: ReleaseProfileProps) {
|
||||
function ReleaseProfileItem(props: ReleaseProfileProps) {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
@@ -70,7 +70,7 @@ function ReleaseProfileRow(props: ReleaseProfileProps) {
|
||||
|
||||
return (
|
||||
<Label key={item} className={styles.label} kind={kinds.SUCCESS}>
|
||||
<MiddleTruncate text={item} start={10} end={10} />
|
||||
<MiddleTruncate text={item} />
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
@@ -84,7 +84,7 @@ function ReleaseProfileRow(props: ReleaseProfileProps) {
|
||||
|
||||
return (
|
||||
<Label key={item} className={styles.label} kind={kinds.DANGER}>
|
||||
<MiddleTruncate text={item} start={10} end={10} />
|
||||
<MiddleTruncate text={item} />
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
@@ -128,4 +128,4 @@ function ReleaseProfileRow(props: ReleaseProfileProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default ReleaseProfileRow;
|
||||
export default ReleaseProfileItem;
|
||||
@@ -4,7 +4,7 @@
|
||||
}
|
||||
|
||||
.addReleaseProfile {
|
||||
composes: releaseProfile from '~./ReleaseProfileRow.css';
|
||||
composes: releaseProfile from '~./ReleaseProfileItem.css';
|
||||
|
||||
background-color: var(--cardAlternateBackgroundColor);
|
||||
color: var(--gray);
|
||||
|
||||
@@ -14,7 +14,7 @@ import createClientSideCollectionSelector from 'Store/Selectors/createClientSide
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditReleaseProfileModal from './EditReleaseProfileModal';
|
||||
import ReleaseProfileRow from './ReleaseProfileRow';
|
||||
import ReleaseProfileItem from './ReleaseProfileItem';
|
||||
import styles from './ReleaseProfiles.css';
|
||||
|
||||
function ReleaseProfiles() {
|
||||
@@ -59,7 +59,7 @@ function ReleaseProfiles() {
|
||||
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<ReleaseProfileRow
|
||||
<ReleaseProfileItem
|
||||
key={item.id}
|
||||
tagList={tagList}
|
||||
indexerList={indexerList}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
function getValidationFailures(saveError) {
|
||||
if (!saveError || saveError.status !== 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _.cloneDeep(saveError.responseJSON);
|
||||
}
|
||||
|
||||
function mapFailure(failure) {
|
||||
return {
|
||||
errorMessage: failure.errorMessage,
|
||||
infoLink: failure.infoLink,
|
||||
detailedDescription: failure.detailedDescription,
|
||||
|
||||
// TODO: Remove these renamed properties
|
||||
message: failure.errorMessage,
|
||||
link: failure.infoLink,
|
||||
detailedMessage: failure.detailedDescription
|
||||
};
|
||||
}
|
||||
|
||||
function selectSettings(item, pendingChanges, saveError) {
|
||||
const validationFailures = getValidationFailures(saveError);
|
||||
|
||||
// Merge all settings from the item along with pending
|
||||
// changes to ensure any settings that were not included
|
||||
// with the item are included.
|
||||
const allSettings = Object.assign({}, item, pendingChanges);
|
||||
|
||||
const settings = _.reduce(allSettings, (result, value, key) => {
|
||||
if (key === 'fields') {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Return a flattened value
|
||||
if (key === 'implementationName') {
|
||||
result.implementationName = item[key];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const setting = {
|
||||
value: item[key],
|
||||
errors: _.map(_.remove(validationFailures, (failure) => {
|
||||
return failure.propertyName.toLowerCase() === key.toLowerCase() && !failure.isWarning;
|
||||
}), mapFailure),
|
||||
|
||||
warnings: _.map(_.remove(validationFailures, (failure) => {
|
||||
return failure.propertyName.toLowerCase() === key.toLowerCase() && failure.isWarning;
|
||||
}), mapFailure)
|
||||
};
|
||||
|
||||
if (pendingChanges.hasOwnProperty(key)) {
|
||||
setting.previousValue = setting.value;
|
||||
setting.value = pendingChanges[key];
|
||||
setting.pending = true;
|
||||
}
|
||||
|
||||
result[key] = setting;
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
const fields = _.reduce(item.fields, (result, f) => {
|
||||
const field = Object.assign({ pending: false }, f);
|
||||
const hasPendingFieldChange = pendingChanges.fields && pendingChanges.fields.hasOwnProperty(field.name);
|
||||
|
||||
if (hasPendingFieldChange) {
|
||||
field.previousValue = field.value;
|
||||
field.value = pendingChanges.fields[field.name];
|
||||
field.pending = true;
|
||||
}
|
||||
|
||||
field.errors = _.map(_.remove(validationFailures, (failure) => {
|
||||
return failure.propertyName.toLowerCase() === field.name.toLowerCase() && !failure.isWarning;
|
||||
}), mapFailure);
|
||||
|
||||
field.warnings = _.map(_.remove(validationFailures, (failure) => {
|
||||
return failure.propertyName.toLowerCase() === field.name.toLowerCase() && failure.isWarning;
|
||||
}), mapFailure);
|
||||
|
||||
result.push(field);
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
if (fields.length) {
|
||||
settings.fields = fields;
|
||||
}
|
||||
|
||||
const validationErrors = _.filter(validationFailures, (failure) => {
|
||||
return !failure.isWarning;
|
||||
});
|
||||
|
||||
const validationWarnings = _.filter(validationFailures, (failure) => {
|
||||
return failure.isWarning;
|
||||
});
|
||||
|
||||
return {
|
||||
settings,
|
||||
validationErrors,
|
||||
validationWarnings,
|
||||
hasPendingChanges: !_.isEmpty(pendingChanges),
|
||||
hasSettings: !_.isEmpty(settings),
|
||||
pendingChanges
|
||||
};
|
||||
}
|
||||
|
||||
export default selectSettings;
|
||||
168
frontend/src/Store/Selectors/selectSettings.ts
Normal file
168
frontend/src/Store/Selectors/selectSettings.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { cloneDeep, isEmpty } from 'lodash';
|
||||
import { Error } from 'App/State/AppSectionState';
|
||||
import Field from 'typings/Field';
|
||||
import {
|
||||
Failure,
|
||||
Pending,
|
||||
PendingField,
|
||||
PendingSection,
|
||||
ValidationError,
|
||||
ValidationFailure,
|
||||
ValidationWarning,
|
||||
} from 'typings/pending';
|
||||
|
||||
interface ValidationFailures {
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
}
|
||||
|
||||
function getValidationFailures(saveError?: Error): ValidationFailures {
|
||||
if (!saveError || saveError.status !== 400) {
|
||||
return {
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
return cloneDeep(saveError.responseJSON as ValidationFailure[]).reduce(
|
||||
(acc: ValidationFailures, failure: ValidationFailure) => {
|
||||
if (failure.isWarning) {
|
||||
acc.warnings.push(failure as ValidationWarning);
|
||||
} else {
|
||||
acc.errors.push(failure as ValidationError);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
errors: [],
|
||||
warnings: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getFailures(failures: ValidationFailure[], key: string) {
|
||||
const result = [];
|
||||
|
||||
for (let i = failures.length - 1; i >= 0; i--) {
|
||||
if (failures[i].propertyName.toLowerCase() === key.toLowerCase()) {
|
||||
result.unshift(mapFailure(failures[i]));
|
||||
|
||||
failures.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function mapFailure(failure: ValidationFailure): Failure {
|
||||
return {
|
||||
errorMessage: failure.errorMessage,
|
||||
infoLink: failure.infoLink,
|
||||
detailedDescription: failure.detailedDescription,
|
||||
|
||||
// TODO: Remove these renamed properties
|
||||
message: failure.errorMessage,
|
||||
link: failure.infoLink,
|
||||
detailedMessage: failure.detailedDescription,
|
||||
};
|
||||
}
|
||||
|
||||
interface ModelBaseSetting {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[id: string]: any;
|
||||
}
|
||||
|
||||
function selectSettings<T extends ModelBaseSetting>(
|
||||
item: T,
|
||||
pendingChanges: Partial<ModelBaseSetting>,
|
||||
saveError?: Error
|
||||
) {
|
||||
const { errors, warnings } = getValidationFailures(saveError);
|
||||
|
||||
// Merge all settings from the item along with pending
|
||||
// changes to ensure any settings that were not included
|
||||
// with the item are included.
|
||||
const allSettings = Object.assign({}, item, pendingChanges);
|
||||
|
||||
const settings = Object.keys(allSettings).reduce(
|
||||
(acc: PendingSection<T>, key) => {
|
||||
if (key === 'fields') {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Return a flattened value
|
||||
if (key === 'implementationName') {
|
||||
acc.implementationName = item[key];
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
const setting: Pending<T> = {
|
||||
value: item[key],
|
||||
pending: false,
|
||||
errors: getFailures(errors, key),
|
||||
warnings: getFailures(warnings, key),
|
||||
};
|
||||
|
||||
if (pendingChanges.hasOwnProperty(key)) {
|
||||
setting.previousValue = setting.value;
|
||||
setting.value = pendingChanges[key];
|
||||
setting.pending = true;
|
||||
}
|
||||
|
||||
// @ts-expect-error - This is a valid key
|
||||
acc[key] = setting;
|
||||
return acc;
|
||||
},
|
||||
{} as PendingSection<T>
|
||||
);
|
||||
|
||||
if ('fields' in item) {
|
||||
const fields =
|
||||
(item.fields as Field[]).reduce((acc: PendingField<T>[], f) => {
|
||||
const field: PendingField<T> = Object.assign(
|
||||
{ pending: false, errors: [], warnings: [] },
|
||||
f
|
||||
);
|
||||
|
||||
if ('fields' in pendingChanges) {
|
||||
const pendingChangesFields = pendingChanges.fields as Record<
|
||||
string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
any
|
||||
>;
|
||||
|
||||
if (pendingChangesFields.hasOwnProperty(field.name)) {
|
||||
field.previousValue = field.value;
|
||||
field.value = pendingChangesFields[field.name];
|
||||
field.pending = true;
|
||||
}
|
||||
}
|
||||
|
||||
field.errors = getFailures(errors, field.name);
|
||||
field.warnings = getFailures(warnings, field.name);
|
||||
|
||||
acc.push(field);
|
||||
return acc;
|
||||
}, []) ?? [];
|
||||
|
||||
if (fields.length) {
|
||||
settings.fields = fields;
|
||||
}
|
||||
}
|
||||
|
||||
const validationErrors = errors;
|
||||
const validationWarnings = warnings;
|
||||
|
||||
return {
|
||||
settings,
|
||||
validationErrors,
|
||||
validationWarnings,
|
||||
hasPendingChanges: !isEmpty(pendingChanges),
|
||||
hasSettings: !isEmpty(settings),
|
||||
pendingChanges,
|
||||
};
|
||||
}
|
||||
|
||||
export default selectSettings;
|
||||
@@ -1,19 +1,15 @@
|
||||
interface AjaxResponse {
|
||||
responseJSON:
|
||||
| {
|
||||
message: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
import { Error } from 'App/State/AppSectionState';
|
||||
|
||||
function getErrorMessage(xhr: AjaxResponse, fallbackErrorMessage?: string) {
|
||||
if (!xhr || !xhr.responseJSON || !xhr.responseJSON.message) {
|
||||
function getErrorMessage(xhr: Error, fallbackErrorMessage?: string) {
|
||||
if (!xhr || !xhr.responseJSON) {
|
||||
return fallbackErrorMessage;
|
||||
}
|
||||
|
||||
const message = xhr.responseJSON.message;
|
||||
if ('message' in xhr.responseJSON && xhr.responseJSON.message) {
|
||||
return xhr.responseJSON.message;
|
||||
}
|
||||
|
||||
return message || fallbackErrorMessage;
|
||||
return fallbackErrorMessage;
|
||||
}
|
||||
|
||||
export default getErrorMessage;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createBrowserHistory } from 'history';
|
||||
import React from 'react';
|
||||
import { render } from 'react-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import createAppStore from 'Store/createAppStore';
|
||||
import App from './App/App';
|
||||
|
||||
@@ -9,9 +9,8 @@ import 'Diag/ConsoleApi';
|
||||
export async function bootstrap() {
|
||||
const history = createBrowserHistory();
|
||||
const store = createAppStore(history);
|
||||
const container = document.getElementById('root');
|
||||
|
||||
render(
|
||||
<App store={store} history={history} />,
|
||||
document.getElementById('root')
|
||||
);
|
||||
const root = createRoot(container!); // createRoot(container!) if you use TypeScript
|
||||
root.render(<App store={store} history={history} />);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,31 @@ window.Sonarr = await response.json();
|
||||
__webpack_public_path__ = `${window.Sonarr.urlBase}/`;
|
||||
/* eslint-enable no-undef, @typescript-eslint/ban-ts-comment */
|
||||
|
||||
const error = console.error;
|
||||
|
||||
// Monkey patch console.error to filter out some warnings from React
|
||||
// TODO: Remove this after the great TypeScript migration
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function logError(...parameters: any[]) {
|
||||
const filter = parameters.find((parameter) => {
|
||||
return (
|
||||
parameter.includes(
|
||||
'Support for defaultProps will be removed from function components in a future major release'
|
||||
) ||
|
||||
parameter.includes(
|
||||
'findDOMNode is deprecated and will be removed in the next major release'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
if (!filter) {
|
||||
error(...parameters);
|
||||
}
|
||||
}
|
||||
|
||||
console.error = logError;
|
||||
|
||||
const { bootstrap } = await import('./bootstrap');
|
||||
|
||||
await bootstrap();
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import Field from './Field';
|
||||
import Provider from './Provider';
|
||||
|
||||
export type Protocol = 'torrent' | 'usenet' | 'unknown';
|
||||
|
||||
interface DownloadClient extends ModelBase {
|
||||
interface DownloadClient extends Provider {
|
||||
enable: boolean;
|
||||
protocol: Protocol;
|
||||
priority: number;
|
||||
removeCompletedDownloads: boolean;
|
||||
removeFailedDownloads: boolean;
|
||||
name: string;
|
||||
fields: Field[];
|
||||
implementationName: string;
|
||||
implementation: string;
|
||||
configContract: string;
|
||||
infoLink: string;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ interface Field {
|
||||
order: number;
|
||||
name: string;
|
||||
label: string;
|
||||
value: boolean | number | string;
|
||||
value: boolean | number | string | number[];
|
||||
section: string;
|
||||
hidden: 'hidden' | 'hiddenIfNotSet' | 'visible';
|
||||
type: string;
|
||||
advanced: boolean;
|
||||
privacy: string;
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import Field from './Field';
|
||||
import Provider from './Provider';
|
||||
|
||||
interface ImportList extends ModelBase {
|
||||
interface ImportList extends Provider {
|
||||
enable: boolean;
|
||||
enableAutomaticAdd: boolean;
|
||||
qualityProfileId: number;
|
||||
rootFolderPath: string;
|
||||
name: string;
|
||||
fields: Field[];
|
||||
implementationName: string;
|
||||
implementation: string;
|
||||
configContract: string;
|
||||
infoLink: string;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import Field from './Field';
|
||||
import Provider from './Provider';
|
||||
|
||||
interface Indexer extends ModelBase {
|
||||
interface Indexer extends Provider {
|
||||
enableRss: boolean;
|
||||
enableAutomaticSearch: boolean;
|
||||
enableInteractiveSearch: boolean;
|
||||
protocol: string;
|
||||
priority: number;
|
||||
name: string;
|
||||
fields: Field[];
|
||||
implementationName: string;
|
||||
implementation: string;
|
||||
configContract: string;
|
||||
infoLink: string;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
|
||||
7
frontend/src/typings/Metadata.ts
Normal file
7
frontend/src/typings/Metadata.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Provider from './Provider';
|
||||
|
||||
interface Metadata extends Provider {
|
||||
enable: boolean;
|
||||
}
|
||||
|
||||
export default Metadata;
|
||||
@@ -1,14 +1,7 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import Field from './Field';
|
||||
import Provider from './Provider';
|
||||
|
||||
interface Notification extends ModelBase {
|
||||
interface Notification extends Provider {
|
||||
enable: boolean;
|
||||
name: string;
|
||||
fields: Field[];
|
||||
implementationName: string;
|
||||
implementation: string;
|
||||
configContract: string;
|
||||
infoLink: string;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
|
||||
20
frontend/src/typings/Provider.ts
Normal file
20
frontend/src/typings/Provider.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import Field from './Field';
|
||||
|
||||
export interface ProviderMessage {
|
||||
message: string;
|
||||
type: Extract<Kind, 'info' | 'error' | 'warning'>;
|
||||
}
|
||||
|
||||
interface Provider extends ModelBase {
|
||||
name: string;
|
||||
fields: Field[];
|
||||
implementationName: string;
|
||||
implementation: string;
|
||||
configContract: string;
|
||||
infoLink: string;
|
||||
message: ProviderMessage;
|
||||
}
|
||||
|
||||
export default Provider;
|
||||
@@ -1,4 +1,7 @@
|
||||
import Field from './Field';
|
||||
|
||||
export interface ValidationFailure {
|
||||
isWarning: boolean;
|
||||
propertyName: string;
|
||||
errorMessage: string;
|
||||
infoLink?: string;
|
||||
@@ -14,12 +17,47 @@ export interface ValidationWarning extends ValidationFailure {
|
||||
isWarning: true;
|
||||
}
|
||||
|
||||
export interface Pending<T> {
|
||||
value: T;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
export interface Failure {
|
||||
errorMessage: ValidationFailure['errorMessage'];
|
||||
infoLink: ValidationFailure['infoLink'];
|
||||
detailedDescription: ValidationFailure['detailedDescription'];
|
||||
|
||||
// TODO: Remove these renamed properties
|
||||
|
||||
message: ValidationFailure['errorMessage'];
|
||||
link: ValidationFailure['infoLink'];
|
||||
detailedMessage: ValidationFailure['detailedDescription'];
|
||||
}
|
||||
|
||||
export type PendingSection<T> = {
|
||||
[K in keyof T]: Pending<T[K]>;
|
||||
export interface Pending<T> {
|
||||
value: T;
|
||||
errors: Failure[];
|
||||
warnings: Failure[];
|
||||
pending: boolean;
|
||||
previousValue?: T;
|
||||
}
|
||||
|
||||
export interface PendingField<T>
|
||||
extends Field,
|
||||
Omit<Pending<T>, 'previousValue' | 'value'> {
|
||||
previousValue?: Field['value'];
|
||||
}
|
||||
|
||||
// export type PendingSection<T> = {
|
||||
// [K in keyof T]: Pending<T[K]>;
|
||||
// };
|
||||
|
||||
type Mapped<T> = {
|
||||
[Prop in keyof T]: {
|
||||
value: T[Prop];
|
||||
errors: Failure[];
|
||||
warnings: Failure[];
|
||||
pending?: boolean;
|
||||
previousValue?: T[Prop];
|
||||
};
|
||||
};
|
||||
|
||||
export type PendingSection<T> = Mapped<T> & {
|
||||
implementationName?: string;
|
||||
fields?: PendingField<T>[];
|
||||
};
|
||||
|
||||
1
frontend/typings/Globals.d.ts
vendored
1
frontend/typings/Globals.d.ts
vendored
@@ -3,6 +3,7 @@ declare module '*.module.css';
|
||||
interface Window {
|
||||
Sonarr: {
|
||||
apiKey: string;
|
||||
apiRoot: string;
|
||||
instanceName: string;
|
||||
theme: string;
|
||||
urlBase: string;
|
||||
|
||||
16
frontend/typings/MiddleTruncate.d.ts
vendored
16
frontend/typings/MiddleTruncate.d.ts
vendored
@@ -1,16 +0,0 @@
|
||||
declare module 'react-middle-truncate' {
|
||||
import { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
interface MiddleTruncateProps extends ComponentPropsWithoutRef<'div'> {
|
||||
text: string;
|
||||
ellipsis?: string;
|
||||
start?: number | RegExp | string;
|
||||
end?: number | RegExp | string;
|
||||
smartCopy?: 'all' | 'partial';
|
||||
onResizeDebounceMs?: number;
|
||||
}
|
||||
|
||||
export default function MiddleTruncate(
|
||||
props: MiddleTruncateProps
|
||||
): JSX.Element;
|
||||
}
|
||||
10
package.json
10
package.json
@@ -29,9 +29,10 @@
|
||||
"@juggle/resize-observer": "3.4.0",
|
||||
"@microsoft/signalr": "6.0.21",
|
||||
"@sentry/browser": "7.119.1",
|
||||
"@tanstack/react-query": "5.61.0",
|
||||
"@types/node": "20.16.11",
|
||||
"@types/react": "18.2.79",
|
||||
"@types/react-dom": "18.2.25",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"classnames": "2.5.1",
|
||||
"connected-react-router": "6.9.3",
|
||||
"copy-to-clipboard": "3.3.3",
|
||||
@@ -48,7 +49,7 @@
|
||||
"normalize.css": "8.0.1",
|
||||
"prop-types": "15.8.1",
|
||||
"qs": "6.13.0",
|
||||
"react": "17.0.2",
|
||||
"react": "18.3.1",
|
||||
"react-addons-shallow-compare": "15.6.3",
|
||||
"react-async-script": "1.2.0",
|
||||
"react-autosuggest": "10.1.0",
|
||||
@@ -58,12 +59,11 @@
|
||||
"react-dnd-multi-backend": "6.0.2",
|
||||
"react-dnd-touch-backend": "14.1.1",
|
||||
"react-document-title": "2.0.3",
|
||||
"react-dom": "17.0.2",
|
||||
"react-dom": "18.3.1",
|
||||
"react-focus-lock": "2.9.4",
|
||||
"react-google-recaptcha": "2.1.0",
|
||||
"react-lazyload": "3.2.0",
|
||||
"react-measure": "1.4.7",
|
||||
"react-middle-truncate": "1.0.3",
|
||||
"react-popper": "1.3.7",
|
||||
"react-redux": "7.2.4",
|
||||
"react-router": "5.2.0",
|
||||
|
||||
@@ -21,9 +21,28 @@ namespace NzbDrone.Common.Test.ExtensionTests
|
||||
[TestCase("1.2.3.4")]
|
||||
[TestCase("172.55.0.1")]
|
||||
[TestCase("192.55.0.1")]
|
||||
[TestCase("100.64.0.1")]
|
||||
[TestCase("100.127.255.254")]
|
||||
public void should_return_false_for_public_ip_address(string ipAddress)
|
||||
{
|
||||
IPAddress.Parse(ipAddress).IsLocalAddress().Should().BeFalse();
|
||||
}
|
||||
|
||||
[TestCase("100.64.0.1")]
|
||||
[TestCase("100.127.255.254")]
|
||||
[TestCase("100.100.100.100")]
|
||||
public void should_return_true_for_cgnat_ip_address(string ipAddress)
|
||||
{
|
||||
IPAddress.Parse(ipAddress).IsCgnatIpAddress().Should().BeTrue();
|
||||
}
|
||||
|
||||
[TestCase("1.2.3.4")]
|
||||
[TestCase("192.168.5.1")]
|
||||
[TestCase("100.63.255.255")]
|
||||
[TestCase("100.128.0.0")]
|
||||
public void should_return_false_for_non_cgnat_ip_address(string ipAddress)
|
||||
{
|
||||
IPAddress.Parse(ipAddress).IsCgnatIpAddress().Should().BeFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,16 +190,23 @@ namespace NzbDrone.Common.Disk
|
||||
|
||||
var fi = new FileInfo(path);
|
||||
|
||||
// If the file is a symlink, resolve the target path and get the size of the target file.
|
||||
if (fi.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
try
|
||||
{
|
||||
var targetPath = fi.ResolveLinkTarget(true)?.FullName;
|
||||
|
||||
if (targetPath != null)
|
||||
// If the file is a symlink, resolve the target path and get the size of the target file.
|
||||
if (fi.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
{
|
||||
fi = new FileInfo(targetPath);
|
||||
var targetPath = fi.ResolveLinkTarget(true)?.FullName;
|
||||
|
||||
if (targetPath != null)
|
||||
{
|
||||
fi = new FileInfo(targetPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
Logger.Trace(ex, "Unable to resolve symlink target for {0}", path);
|
||||
}
|
||||
|
||||
return fi.Length;
|
||||
}
|
||||
|
||||
@@ -39,18 +39,24 @@ namespace NzbDrone.Common.Extensions
|
||||
private static bool IsLocalIPv4(byte[] ipv4Bytes)
|
||||
{
|
||||
// Link local (no IP assigned by DHCP): 169.254.0.0 to 169.254.255.255 (169.254.0.0/16)
|
||||
bool IsLinkLocal() => ipv4Bytes[0] == 169 && ipv4Bytes[1] == 254;
|
||||
var isLinkLocal = ipv4Bytes[0] == 169 && ipv4Bytes[1] == 254;
|
||||
|
||||
// Class A private range: 10.0.0.0 – 10.255.255.255 (10.0.0.0/8)
|
||||
bool IsClassA() => ipv4Bytes[0] == 10;
|
||||
var isClassA = ipv4Bytes[0] == 10;
|
||||
|
||||
// Class B private range: 172.16.0.0 – 172.31.255.255 (172.16.0.0/12)
|
||||
bool IsClassB() => ipv4Bytes[0] == 172 && ipv4Bytes[1] >= 16 && ipv4Bytes[1] <= 31;
|
||||
var isClassB = ipv4Bytes[0] == 172 && ipv4Bytes[1] >= 16 && ipv4Bytes[1] <= 31;
|
||||
|
||||
// Class C private range: 192.168.0.0 – 192.168.255.255 (192.168.0.0/16)
|
||||
bool IsClassC() => ipv4Bytes[0] == 192 && ipv4Bytes[1] == 168;
|
||||
var isClassC = ipv4Bytes[0] == 192 && ipv4Bytes[1] == 168;
|
||||
|
||||
return IsLinkLocal() || IsClassA() || IsClassC() || IsClassB();
|
||||
return isLinkLocal || isClassA || isClassC || isClassB;
|
||||
}
|
||||
|
||||
public static bool IsCgnatIpAddress(this IPAddress ipAddress)
|
||||
{
|
||||
var bytes = ipAddress.GetAddressBytes();
|
||||
return bytes.Length == 4 && bytes[0] == 100 && bytes[1] >= 64 && bytes[1] <= 127;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ public class AuthOptions
|
||||
public bool? Enabled { get; set; }
|
||||
public string Method { get; set; }
|
||||
public string Required { get; set; }
|
||||
public bool? TrustCgnatIpAddresses { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.Datastore;
|
||||
|
||||
namespace NzbDrone.Core.Test.Datastore;
|
||||
|
||||
[TestFixture]
|
||||
public class DatabaseVersionParserFixture
|
||||
{
|
||||
[TestCase("3.44.2", 3, 44, 2)]
|
||||
public void should_parse_sqlite_database_version(string serverVersion, int majorVersion, int minorVersion, int buildVersion)
|
||||
{
|
||||
var version = DatabaseVersionParser.ParseServerVersion(serverVersion);
|
||||
|
||||
version.Should().NotBeNull();
|
||||
version.Major.Should().Be(majorVersion);
|
||||
version.Minor.Should().Be(minorVersion);
|
||||
version.Build.Should().Be(buildVersion);
|
||||
}
|
||||
|
||||
[TestCase("14.8 (Debian 14.8-1.pgdg110+1)", 14, 8, null)]
|
||||
[TestCase("16.3 (Debian 16.3-1.pgdg110+1)", 16, 3, null)]
|
||||
[TestCase("16.3 - Percona Distribution", 16, 3, null)]
|
||||
[TestCase("17.0 - Percona Server", 17, 0, null)]
|
||||
public void should_parse_postgres_database_version(string serverVersion, int majorVersion, int minorVersion, int? buildVersion)
|
||||
{
|
||||
var version = DatabaseVersionParser.ParseServerVersion(serverVersion);
|
||||
|
||||
version.Should().NotBeNull();
|
||||
version.Major.Should().Be(majorVersion);
|
||||
version.Minor.Should().Be(minorVersion);
|
||||
|
||||
if (buildVersion.HasValue)
|
||||
{
|
||||
version.Build.Should().Be(buildVersion.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,38 +22,38 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
|
||||
private List<ReleaseInfo> _reports;
|
||||
private RemoteEpisode _remoteEpisode;
|
||||
|
||||
private Mock<IDecisionEngineSpecification> _pass1;
|
||||
private Mock<IDecisionEngineSpecification> _pass2;
|
||||
private Mock<IDecisionEngineSpecification> _pass3;
|
||||
private Mock<IDownloadDecisionEngineSpecification> _pass1;
|
||||
private Mock<IDownloadDecisionEngineSpecification> _pass2;
|
||||
private Mock<IDownloadDecisionEngineSpecification> _pass3;
|
||||
|
||||
private Mock<IDecisionEngineSpecification> _fail1;
|
||||
private Mock<IDecisionEngineSpecification> _fail2;
|
||||
private Mock<IDecisionEngineSpecification> _fail3;
|
||||
private Mock<IDownloadDecisionEngineSpecification> _fail1;
|
||||
private Mock<IDownloadDecisionEngineSpecification> _fail2;
|
||||
private Mock<IDownloadDecisionEngineSpecification> _fail3;
|
||||
|
||||
private Mock<IDecisionEngineSpecification> _failDelayed1;
|
||||
private Mock<IDownloadDecisionEngineSpecification> _failDelayed1;
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
_pass1 = new Mock<IDecisionEngineSpecification>();
|
||||
_pass2 = new Mock<IDecisionEngineSpecification>();
|
||||
_pass3 = new Mock<IDecisionEngineSpecification>();
|
||||
_pass1 = new Mock<IDownloadDecisionEngineSpecification>();
|
||||
_pass2 = new Mock<IDownloadDecisionEngineSpecification>();
|
||||
_pass3 = new Mock<IDownloadDecisionEngineSpecification>();
|
||||
|
||||
_fail1 = new Mock<IDecisionEngineSpecification>();
|
||||
_fail2 = new Mock<IDecisionEngineSpecification>();
|
||||
_fail3 = new Mock<IDecisionEngineSpecification>();
|
||||
_fail1 = new Mock<IDownloadDecisionEngineSpecification>();
|
||||
_fail2 = new Mock<IDownloadDecisionEngineSpecification>();
|
||||
_fail3 = new Mock<IDownloadDecisionEngineSpecification>();
|
||||
|
||||
_failDelayed1 = new Mock<IDecisionEngineSpecification>();
|
||||
_failDelayed1 = new Mock<IDownloadDecisionEngineSpecification>();
|
||||
|
||||
_pass1.Setup(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null)).Returns(Decision.Accept);
|
||||
_pass2.Setup(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null)).Returns(Decision.Accept);
|
||||
_pass3.Setup(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null)).Returns(Decision.Accept);
|
||||
_pass1.Setup(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null)).Returns(DownloadSpecDecision.Accept);
|
||||
_pass2.Setup(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null)).Returns(DownloadSpecDecision.Accept);
|
||||
_pass3.Setup(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null)).Returns(DownloadSpecDecision.Accept);
|
||||
|
||||
_fail1.Setup(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null)).Returns(Decision.Reject("fail1"));
|
||||
_fail2.Setup(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null)).Returns(Decision.Reject("fail2"));
|
||||
_fail3.Setup(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null)).Returns(Decision.Reject("fail3"));
|
||||
_fail1.Setup(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null)).Returns(DownloadSpecDecision.Reject(DownloadRejectionReason.Unknown, "fail1"));
|
||||
_fail2.Setup(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null)).Returns(DownloadSpecDecision.Reject(DownloadRejectionReason.Unknown, "fail2"));
|
||||
_fail3.Setup(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null)).Returns(DownloadSpecDecision.Reject(DownloadRejectionReason.Unknown, "fail3"));
|
||||
|
||||
_failDelayed1.Setup(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null)).Returns(Decision.Reject("failDelayed1"));
|
||||
_failDelayed1.Setup(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null)).Returns(DownloadSpecDecision.Reject(DownloadRejectionReason.MinimumAgeDelay, "failDelayed1"));
|
||||
_failDelayed1.SetupGet(c => c.Priority).Returns(SpecificationPriority.Disk);
|
||||
|
||||
_reports = new List<ReleaseInfo> { new ReleaseInfo { Title = "The.Office.S03E115.DVDRip.XviD-OSiTV" } };
|
||||
@@ -68,9 +68,9 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
|
||||
.Returns(_remoteEpisode);
|
||||
}
|
||||
|
||||
private void GivenSpecifications(params Mock<IDecisionEngineSpecification>[] mocks)
|
||||
private void GivenSpecifications(params Mock<IDownloadDecisionEngineSpecification>[] mocks)
|
||||
{
|
||||
Mocker.SetConstant<IEnumerable<IDecisionEngineSpecification>>(mocks.Select(c => c.Object));
|
||||
Mocker.SetConstant<IEnumerable<IDownloadDecisionEngineSpecification>>(mocks.Select(c => c.Object));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -273,7 +273,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
|
||||
Episodes = episodes.Where(v => v.SceneEpisodeNumber == p.EpisodeNumbers.First()).ToList()
|
||||
});
|
||||
|
||||
Mocker.SetConstant<IEnumerable<IDecisionEngineSpecification>>(new List<IDecisionEngineSpecification>
|
||||
Mocker.SetConstant<IEnumerable<IDownloadDecisionEngineSpecification>>(new List<IDownloadDecisionEngineSpecification>
|
||||
{
|
||||
Mocker.Resolve<NzbDrone.Core.DecisionEngine.Specifications.Search.EpisodeRequestedSpecification>()
|
||||
});
|
||||
@@ -345,7 +345,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
|
||||
var result = Subject.GetRssDecision(_reports);
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result.First().Rejections.First().Reason.Should().Contain("12345");
|
||||
result.First().Rejections.First().Message.Should().Contain("12345");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ using FluentAssertions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Core.DecisionEngine;
|
||||
using NzbDrone.Core.Download;
|
||||
using NzbDrone.Core.Download.TrackedDownloads;
|
||||
using NzbDrone.Core.History;
|
||||
@@ -122,11 +121,11 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
|
||||
{
|
||||
new ImportResult(
|
||||
new ImportDecision(
|
||||
new LocalEpisode { Path = @"C:\TestPath\Droned.S01E01.mkv", Episodes = { _episode1 } }, new Rejection("Rejected!")), "Test Failure"),
|
||||
new LocalEpisode { Path = @"C:\TestPath\Droned.S01E01.mkv", Episodes = { _episode1 } }, new ImportRejection(ImportRejectionReason.Unknown, "Rejected!")), "Test Failure"),
|
||||
|
||||
new ImportResult(
|
||||
new ImportDecision(
|
||||
new LocalEpisode { Path = @"C:\TestPath\Droned.S01E02.mkv", Episodes = { _episode2 } }, new Rejection("Rejected!")), "Test Failure")
|
||||
new LocalEpisode { Path = @"C:\TestPath\Droned.S01E02.mkv", Episodes = { _episode2 } }, new ImportRejection(ImportRejectionReason.Unknown, "Rejected!")), "Test Failure")
|
||||
});
|
||||
|
||||
Subject.Import(_trackedDownload);
|
||||
@@ -146,11 +145,11 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
|
||||
{
|
||||
new ImportResult(
|
||||
new ImportDecision(
|
||||
new LocalEpisode { Path = @"C:\TestPath\Droned.S01E01.mkv", Episodes = { _episode1 } }, new Rejection("Rejected!")), "Test Failure"),
|
||||
new LocalEpisode { Path = @"C:\TestPath\Droned.S01E01.mkv", Episodes = { _episode1 } }, new ImportRejection(ImportRejectionReason.Unknown, "Rejected!")), "Test Failure"),
|
||||
|
||||
new ImportResult(
|
||||
new ImportDecision(
|
||||
new LocalEpisode { Path = @"C:\TestPath\Droned.S01E02.mkv", Episodes = { _episode2 } }, new Rejection("Rejected!")), "Test Failure")
|
||||
new LocalEpisode { Path = @"C:\TestPath\Droned.S01E02.mkv", Episodes = { _episode2 } }, new ImportRejection(ImportRejectionReason.Unknown, "Rejected!")), "Test Failure")
|
||||
});
|
||||
|
||||
_trackedDownload.RemoteEpisode.Episodes.Clear();
|
||||
|
||||
@@ -186,8 +186,8 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
|
||||
public void should_return_an_empty_list_when_none_are_approved()
|
||||
{
|
||||
var decisions = new List<DownloadDecision>();
|
||||
decisions.Add(new DownloadDecision(null, new Rejection("Failure!")));
|
||||
decisions.Add(new DownloadDecision(null, new Rejection("Failure!")));
|
||||
decisions.Add(new DownloadDecision(null, new DownloadRejection(DownloadRejectionReason.Unknown, "Failure!")));
|
||||
decisions.Add(new DownloadDecision(null, new DownloadRejection(DownloadRejectionReason.Unknown, "Failure!")));
|
||||
|
||||
Subject.GetQualifiedReports(decisions).Should().BeEmpty();
|
||||
}
|
||||
@@ -199,7 +199,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
|
||||
var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p));
|
||||
|
||||
var decisions = new List<DownloadDecision>();
|
||||
decisions.Add(new DownloadDecision(remoteEpisode, new Rejection("Failure!", RejectionType.Temporary)));
|
||||
decisions.Add(new DownloadDecision(remoteEpisode, new DownloadRejection(DownloadRejectionReason.Unknown, "Failure!", RejectionType.Temporary)));
|
||||
|
||||
await Subject.ProcessDecisions(decisions);
|
||||
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteEpisode>(), null), Times.Never());
|
||||
@@ -213,7 +213,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
|
||||
|
||||
var decisions = new List<DownloadDecision>();
|
||||
decisions.Add(new DownloadDecision(remoteEpisode));
|
||||
decisions.Add(new DownloadDecision(remoteEpisode, new Rejection("Failure!", RejectionType.Temporary)));
|
||||
decisions.Add(new DownloadDecision(remoteEpisode, new DownloadRejection(DownloadRejectionReason.Unknown, "Failure!", RejectionType.Temporary)));
|
||||
|
||||
await Subject.ProcessDecisions(decisions);
|
||||
Mocker.GetMock<IPendingReleaseService>().Verify(v => v.AddMany(It.IsAny<List<Tuple<DownloadDecision, PendingReleaseReason>>>()), Times.Never());
|
||||
@@ -226,8 +226,8 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
|
||||
var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p));
|
||||
|
||||
var decisions = new List<DownloadDecision>();
|
||||
decisions.Add(new DownloadDecision(remoteEpisode, new Rejection("Failure!", RejectionType.Temporary)));
|
||||
decisions.Add(new DownloadDecision(remoteEpisode, new Rejection("Failure!", RejectionType.Temporary)));
|
||||
decisions.Add(new DownloadDecision(remoteEpisode, new DownloadRejection(DownloadRejectionReason.Unknown, "Failure!", RejectionType.Temporary)));
|
||||
decisions.Add(new DownloadDecision(remoteEpisode, new DownloadRejection(DownloadRejectionReason.Unknown, "Failure!", RejectionType.Temporary)));
|
||||
|
||||
await Subject.ProcessDecisions(decisions);
|
||||
Mocker.GetMock<IPendingReleaseService>().Verify(v => v.AddMany(It.IsAny<List<Tuple<DownloadDecision, PendingReleaseReason>>>()), Times.Once());
|
||||
|
||||
@@ -478,6 +478,37 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests
|
||||
downloadClientInfo.RemovesCompletedDownloads.Should().BeTrue();
|
||||
}
|
||||
|
||||
[TestCase("all", 0)]
|
||||
[TestCase("days-archive", 15)]
|
||||
[TestCase("days-delete", 15)]
|
||||
public void should_set_history_removes_completed_downloads_false_for_separate_properties(string option, int number)
|
||||
{
|
||||
_config.Misc.history_retention_option = option;
|
||||
_config.Misc.history_retention_number = number;
|
||||
|
||||
var downloadClientInfo = Subject.GetStatus();
|
||||
|
||||
downloadClientInfo.RemovesCompletedDownloads.Should().BeFalse();
|
||||
}
|
||||
|
||||
[TestCase("number-archive", 10)]
|
||||
[TestCase("number-delete", 10)]
|
||||
[TestCase("number-archive", 0)]
|
||||
[TestCase("number-delete", 0)]
|
||||
[TestCase("days-archive", 3)]
|
||||
[TestCase("days-delete", 3)]
|
||||
[TestCase("all-archive", 0)]
|
||||
[TestCase("all-delete", 0)]
|
||||
public void should_set_history_removes_completed_downloads_true_for_separate_properties(string option, int number)
|
||||
{
|
||||
_config.Misc.history_retention_option = option;
|
||||
_config.Misc.history_retention_number = number;
|
||||
|
||||
var downloadClientInfo = Subject.GetStatus();
|
||||
|
||||
downloadClientInfo.RemovesCompletedDownloads.Should().BeTrue();
|
||||
}
|
||||
|
||||
[TestCase(@"Y:\nzbget\root", @"completed\downloads", @"vv", @"Y:\nzbget\root\completed\downloads", @"Y:\nzbget\root\completed\downloads\vv")]
|
||||
[TestCase(@"Y:\nzbget\root", @"completed", @"vv", @"Y:\nzbget\root\completed", @"Y:\nzbget\root\completed\vv")]
|
||||
[TestCase(@"/nzbget/root", @"completed/downloads", @"vv", @"/nzbget/root/completed/downloads", @"/nzbget/root/completed/downloads/vv")]
|
||||
|
||||
@@ -63,7 +63,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
_remoteEpisode.ParsedEpisodeInfo = _parsedEpisodeInfo;
|
||||
_remoteEpisode.Release = _release;
|
||||
|
||||
_temporarilyRejected = new DownloadDecision(_remoteEpisode, new Rejection("Temp Rejected", RejectionType.Temporary));
|
||||
_temporarilyRejected = new DownloadDecision(_remoteEpisode, new DownloadRejection(DownloadRejectionReason.MinimumAgeDelay, "Temp Rejected", RejectionType.Temporary));
|
||||
|
||||
_heldReleases = new List<PendingRelease>();
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
_remoteEpisode.ParsedEpisodeInfo = _parsedEpisodeInfo;
|
||||
_remoteEpisode.Release = _release;
|
||||
|
||||
_temporarilyRejected = new DownloadDecision(_remoteEpisode, new Rejection("Temp Rejected", RejectionType.Temporary));
|
||||
_temporarilyRejected = new DownloadDecision(_remoteEpisode, new DownloadRejection(DownloadRejectionReason.MinimumAgeDelay, "Temp Rejected", RejectionType.Temporary));
|
||||
|
||||
_heldReleases = new List<PendingRelease>();
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
_remoteEpisode.ParsedEpisodeInfo = _parsedEpisodeInfo;
|
||||
_remoteEpisode.Release = _release;
|
||||
|
||||
_temporarilyRejected = new DownloadDecision(_remoteEpisode, new Rejection("Temp Rejected", RejectionType.Temporary));
|
||||
_temporarilyRejected = new DownloadDecision(_remoteEpisode, new DownloadRejection(DownloadRejectionReason.MinimumAgeDelay, "Temp Rejected", RejectionType.Temporary));
|
||||
|
||||
Mocker.GetMock<IPendingReleaseRepository>()
|
||||
.Setup(s => s.All())
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.Indexers;
|
||||
using NzbDrone.Core.Indexers.Torznab;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
@@ -16,9 +16,9 @@ namespace NzbDrone.Core.Test.IndexerTests
|
||||
[Test]
|
||||
public void should_not_return_config_for_non_existent_indexer()
|
||||
{
|
||||
Mocker.GetMock<IIndexerFactory>()
|
||||
.Setup(v => v.Get(It.IsAny<int>()))
|
||||
.Throws(new ModelNotFoundException(typeof(IndexerDefinition), 0));
|
||||
Mocker.GetMock<ICachedIndexerSettingsProvider>()
|
||||
.Setup(v => v.GetSettings(It.IsAny<int>()))
|
||||
.Returns<CachedIndexerSettings>(null);
|
||||
|
||||
var result = Subject.GetSeedConfiguration(new RemoteEpisode
|
||||
{
|
||||
@@ -38,11 +38,12 @@ namespace NzbDrone.Core.Test.IndexerTests
|
||||
var settings = new TorznabSettings();
|
||||
settings.SeedCriteria.SeasonPackSeedTime = 10;
|
||||
|
||||
Mocker.GetMock<IIndexerFactory>()
|
||||
.Setup(v => v.Get(It.IsAny<int>()))
|
||||
.Returns(new IndexerDefinition
|
||||
Mocker.GetMock<ICachedIndexerSettingsProvider>()
|
||||
.Setup(v => v.GetSettings(It.IsAny<int>()))
|
||||
.Returns(new CachedIndexerSettings
|
||||
{
|
||||
Settings = settings
|
||||
FailDownloads = new HashSet<FailDownloads> { FailDownloads.Executables },
|
||||
SeedCriteriaSettings = settings.SeedCriteria
|
||||
});
|
||||
|
||||
var result = Subject.GetSeedConfiguration(new RemoteEpisode
|
||||
|
||||
@@ -15,5 +15,6 @@ namespace NzbDrone.Core.Test.IndexerTests
|
||||
public string BaseUrl { get; set; }
|
||||
|
||||
public IEnumerable<int> MultiLanguages { get; set; }
|
||||
public IEnumerable<int> FailDownloads { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.DecisionEngine;
|
||||
using NzbDrone.Core.Download;
|
||||
using NzbDrone.Core.History;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
@@ -47,9 +46,9 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
|
||||
var episodes = Builder<Episode>.CreateListOfSize(5)
|
||||
.Build();
|
||||
|
||||
_rejectedDecisions.Add(new ImportDecision(new LocalEpisode(), new Rejection("Rejected!")));
|
||||
_rejectedDecisions.Add(new ImportDecision(new LocalEpisode(), new Rejection("Rejected!")));
|
||||
_rejectedDecisions.Add(new ImportDecision(new LocalEpisode(), new Rejection("Rejected!")));
|
||||
_rejectedDecisions.Add(new ImportDecision(new LocalEpisode(), new ImportRejection(ImportRejectionReason.Unknown, "Rejected!")));
|
||||
_rejectedDecisions.Add(new ImportDecision(new LocalEpisode(), new ImportRejection(ImportRejectionReason.Unknown, "Rejected!")));
|
||||
_rejectedDecisions.Add(new ImportDecision(new LocalEpisode(), new ImportRejection(ImportRejectionReason.Unknown, "Rejected!")));
|
||||
_rejectedDecisions.ForEach(r => r.LocalEpisode.FileEpisodeInfo = new ParsedEpisodeInfo());
|
||||
|
||||
foreach (var episode in episodes)
|
||||
|
||||
@@ -4,7 +4,6 @@ using FizzWare.NBuilder;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.DecisionEngine;
|
||||
using NzbDrone.Core.Download;
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
@@ -46,13 +45,13 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
|
||||
_fail2 = new Mock<IImportDecisionEngineSpecification>();
|
||||
_fail3 = new Mock<IImportDecisionEngineSpecification>();
|
||||
|
||||
_pass1.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEpisode>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Accept());
|
||||
_pass2.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEpisode>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Accept());
|
||||
_pass3.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEpisode>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Accept());
|
||||
_pass1.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEpisode>(), It.IsAny<DownloadClientItem>())).Returns(ImportSpecDecision.Accept());
|
||||
_pass2.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEpisode>(), It.IsAny<DownloadClientItem>())).Returns(ImportSpecDecision.Accept());
|
||||
_pass3.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEpisode>(), It.IsAny<DownloadClientItem>())).Returns(ImportSpecDecision.Accept());
|
||||
|
||||
_fail1.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEpisode>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Reject("_fail1"));
|
||||
_fail2.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEpisode>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Reject("_fail2"));
|
||||
_fail3.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEpisode>(), It.IsAny<DownloadClientItem>())).Returns(Decision.Reject("_fail3"));
|
||||
_fail1.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEpisode>(), It.IsAny<DownloadClientItem>())).Returns(ImportSpecDecision.Reject(ImportRejectionReason.Unknown, "_fail1"));
|
||||
_fail2.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEpisode>(), It.IsAny<DownloadClientItem>())).Returns(ImportSpecDecision.Reject(ImportRejectionReason.Unknown, "_fail2"));
|
||||
_fail3.Setup(c => c.IsSatisfiedBy(It.IsAny<LocalEpisode>(), It.IsAny<DownloadClientItem>())).Returns(ImportSpecDecision.Reject(ImportRejectionReason.Unknown, "_fail3"));
|
||||
|
||||
_series = Builder<Series>.CreateNew()
|
||||
.With(e => e.Path = @"C:\Test\Series".AsOsAgnostic())
|
||||
|
||||
@@ -396,5 +396,65 @@ namespace NzbDrone.Core.Test.TvTests
|
||||
|
||||
_insertedEpisodes.Any(e => e.AbsoluteEpisodeNumberAdded).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_monitor_new_episode_if_season_is_monitored()
|
||||
{
|
||||
var series = GetSeries();
|
||||
series.Seasons = new List<Season>();
|
||||
series.Seasons.Add(new Season { SeasonNumber = 1, Monitored = true });
|
||||
|
||||
var episodes = Builder<Episode>.CreateListOfSize(2)
|
||||
.All()
|
||||
.With(e => e.SeasonNumber = 1)
|
||||
.Build()
|
||||
.ToList();
|
||||
|
||||
var existingEpisode = new Episode
|
||||
{
|
||||
SeasonNumber = episodes[0].SeasonNumber,
|
||||
EpisodeNumber = episodes[0].EpisodeNumber,
|
||||
Monitored = true
|
||||
};
|
||||
|
||||
Mocker.GetMock<IEpisodeService>().Setup(c => c.GetEpisodeBySeries(It.IsAny<int>()))
|
||||
.Returns(new List<Episode> { existingEpisode });
|
||||
|
||||
Subject.RefreshEpisodeInfo(series, episodes);
|
||||
|
||||
_updatedEpisodes.Should().HaveCount(1);
|
||||
_insertedEpisodes.Should().HaveCount(1);
|
||||
_insertedEpisodes.Should().OnlyContain(e => e.Monitored == true);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_monitor_new_episode_if_season_is_not_monitored()
|
||||
{
|
||||
var series = GetSeries();
|
||||
series.Seasons = new List<Season>();
|
||||
series.Seasons.Add(new Season { SeasonNumber = 1, Monitored = false });
|
||||
|
||||
var episodes = Builder<Episode>.CreateListOfSize(2)
|
||||
.All()
|
||||
.With(e => e.SeasonNumber = 1)
|
||||
.Build()
|
||||
.ToList();
|
||||
|
||||
var existingEpisode = new Episode
|
||||
{
|
||||
SeasonNumber = episodes[0].SeasonNumber,
|
||||
EpisodeNumber = episodes[0].EpisodeNumber,
|
||||
Monitored = true
|
||||
};
|
||||
|
||||
Mocker.GetMock<IEpisodeService>().Setup(c => c.GetEpisodeBySeries(It.IsAny<int>()))
|
||||
.Returns(new List<Episode> { existingEpisode });
|
||||
|
||||
Subject.RefreshEpisodeInfo(series, episodes);
|
||||
|
||||
_updatedEpisodes.Should().HaveCount(1);
|
||||
_insertedEpisodes.Should().HaveCount(1);
|
||||
_insertedEpisodes.Should().OnlyContain(e => e.Monitored == false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ namespace NzbDrone.Core.Configuration
|
||||
string PostgresPassword { get; }
|
||||
string PostgresMainDb { get; }
|
||||
string PostgresLogDb { get; }
|
||||
bool TrustCgnatIpAddresses { get; }
|
||||
}
|
||||
|
||||
public class ConfigFileProvider : IConfigFileProvider
|
||||
@@ -475,5 +476,7 @@ namespace NzbDrone.Core.Configuration
|
||||
{
|
||||
SetValue("ApiKey", GenerateApiKey());
|
||||
}
|
||||
|
||||
public bool TrustCgnatIpAddresses => _authOptions.TrustCgnatIpAddresses ?? GetValueBoolean("TrustCgnatIpAddresses", false, persist: false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +390,12 @@ namespace NzbDrone.Core.Configuration
|
||||
|
||||
public string ApplicationUrl => GetValue("ApplicationUrl", string.Empty);
|
||||
|
||||
public bool TrustCgnatIpAddresses
|
||||
{
|
||||
get { return GetValueBoolean("TrustCgnatIpAddresses", false); }
|
||||
set { SetValue("TrustCgnatIpAddresses", value); }
|
||||
}
|
||||
|
||||
private string GetValue(string key)
|
||||
{
|
||||
return GetValue(key, string.Empty);
|
||||
|
||||
@@ -2,7 +2,6 @@ using System;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Data.SQLite;
|
||||
using System.Text.RegularExpressions;
|
||||
using Dapper;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Instrumentation;
|
||||
@@ -52,9 +51,8 @@ namespace NzbDrone.Core.Datastore
|
||||
{
|
||||
using var db = _datamapperFactory();
|
||||
var dbConnection = db as DbConnection;
|
||||
var version = Regex.Replace(dbConnection.ServerVersion, @"\(.*?\)", "");
|
||||
|
||||
return new Version(version);
|
||||
return DatabaseVersionParser.ParseServerVersion(dbConnection.ServerVersion);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
src/NzbDrone.Core/Datastore/DatabaseVersionParser.cs
Normal file
16
src/NzbDrone.Core/Datastore/DatabaseVersionParser.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace NzbDrone.Core.Datastore;
|
||||
|
||||
public static class DatabaseVersionParser
|
||||
{
|
||||
private static readonly Regex VersionRegex = new (@"^[^ ]+", RegexOptions.Compiled);
|
||||
|
||||
public static Version ParseServerVersion(string serverVersion)
|
||||
{
|
||||
var match = VersionRegex.Match(serverVersion);
|
||||
|
||||
return match.Success ? new Version(match.Value) : null;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
namespace NzbDrone.Core.DecisionEngine
|
||||
{
|
||||
public class Decision
|
||||
{
|
||||
public bool Accepted { get; private set; }
|
||||
public string Reason { get; private set; }
|
||||
|
||||
private static readonly Decision AcceptDecision = new Decision { Accepted = true };
|
||||
private Decision()
|
||||
{
|
||||
}
|
||||
|
||||
public static Decision Accept()
|
||||
{
|
||||
return AcceptDecision;
|
||||
}
|
||||
|
||||
public static Decision Reject(string reason, params object[] args)
|
||||
{
|
||||
return Reject(string.Format(reason, args));
|
||||
}
|
||||
|
||||
public static Decision Reject(string reason)
|
||||
{
|
||||
return new Decision
|
||||
{
|
||||
Accepted = false,
|
||||
Reason = reason
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ namespace NzbDrone.Core.DecisionEngine
|
||||
public class DownloadDecision
|
||||
{
|
||||
public RemoteEpisode RemoteEpisode { get; private set; }
|
||||
public IEnumerable<Rejection> Rejections { get; private set; }
|
||||
public IEnumerable<DownloadRejection> Rejections { get; private set; }
|
||||
|
||||
public bool Approved => !Rejections.Any();
|
||||
|
||||
@@ -27,7 +27,7 @@ namespace NzbDrone.Core.DecisionEngine
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadDecision(RemoteEpisode episode, params Rejection[] rejections)
|
||||
public DownloadDecision(RemoteEpisode episode, params DownloadRejection[] rejections)
|
||||
{
|
||||
RemoteEpisode = episode;
|
||||
Rejections = rejections.ToList();
|
||||
|
||||
@@ -23,14 +23,14 @@ namespace NzbDrone.Core.DecisionEngine
|
||||
|
||||
public class DownloadDecisionMaker : IMakeDownloadDecision
|
||||
{
|
||||
private readonly IEnumerable<IDecisionEngineSpecification> _specifications;
|
||||
private readonly IEnumerable<IDownloadDecisionEngineSpecification> _specifications;
|
||||
private readonly IParsingService _parsingService;
|
||||
private readonly ICustomFormatCalculationService _formatCalculator;
|
||||
private readonly IRemoteEpisodeAggregationService _aggregationService;
|
||||
private readonly ISceneMappingService _sceneMappingService;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public DownloadDecisionMaker(IEnumerable<IDecisionEngineSpecification> specifications,
|
||||
public DownloadDecisionMaker(IEnumerable<IDownloadDecisionEngineSpecification> specifications,
|
||||
IParsingService parsingService,
|
||||
ICustomFormatCalculationService formatService,
|
||||
IRemoteEpisodeAggregationService aggregationService,
|
||||
@@ -95,19 +95,20 @@ namespace NzbDrone.Core.DecisionEngine
|
||||
|
||||
if (remoteEpisode.Series == null)
|
||||
{
|
||||
var reason = "Unknown Series";
|
||||
var matchingTvdbId = _sceneMappingService.FindTvdbId(parsedEpisodeInfo.SeriesTitle, parsedEpisodeInfo.ReleaseTitle, parsedEpisodeInfo.SeasonNumber);
|
||||
|
||||
if (matchingTvdbId.HasValue)
|
||||
{
|
||||
reason = $"{parsedEpisodeInfo.SeriesTitle} matches an alias for series with TVDB ID: {matchingTvdbId}";
|
||||
decision = new DownloadDecision(remoteEpisode, new DownloadRejection(DownloadRejectionReason.MatchesAnotherSeries, $"{parsedEpisodeInfo.SeriesTitle} matches an alias for series with TVDB ID: {matchingTvdbId}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
decision = new DownloadDecision(remoteEpisode, new DownloadRejection(DownloadRejectionReason.UnknownSeries, "Unknown Series"));
|
||||
}
|
||||
|
||||
decision = new DownloadDecision(remoteEpisode, new Rejection(reason));
|
||||
}
|
||||
else if (remoteEpisode.Episodes.Empty())
|
||||
{
|
||||
decision = new DownloadDecision(remoteEpisode, new Rejection("Unable to identify correct episode(s) using release name and scene mappings"));
|
||||
decision = new DownloadDecision(remoteEpisode, new DownloadRejection(DownloadRejectionReason.UnknownEpisode, "Unable to identify correct episode(s) using release name and scene mappings"));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -141,7 +142,7 @@ namespace NzbDrone.Core.DecisionEngine
|
||||
Languages = parsedEpisodeInfo.Languages
|
||||
};
|
||||
|
||||
decision = new DownloadDecision(remoteEpisode, new Rejection("Unable to parse release"));
|
||||
decision = new DownloadDecision(remoteEpisode, new DownloadRejection(DownloadRejectionReason.UnableToParse, "Unable to parse release"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +151,7 @@ namespace NzbDrone.Core.DecisionEngine
|
||||
_logger.Error(e, "Couldn't process release.");
|
||||
|
||||
var remoteEpisode = new RemoteEpisode { Release = report };
|
||||
decision = new DownloadDecision(remoteEpisode, new Rejection("Unexpected error processing release"));
|
||||
decision = new DownloadDecision(remoteEpisode, new DownloadRejection(DownloadRejectionReason.Error, "Unexpected error processing release"));
|
||||
}
|
||||
|
||||
reportNumber++;
|
||||
@@ -193,7 +194,7 @@ namespace NzbDrone.Core.DecisionEngine
|
||||
|
||||
private DownloadDecision GetDecisionForReport(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria = null)
|
||||
{
|
||||
var reasons = Array.Empty<Rejection>();
|
||||
var reasons = Array.Empty<DownloadRejection>();
|
||||
|
||||
foreach (var specifications in _specifications.GroupBy(v => v.Priority).OrderBy(v => v.Key))
|
||||
{
|
||||
@@ -210,7 +211,7 @@ namespace NzbDrone.Core.DecisionEngine
|
||||
return new DownloadDecision(remoteEpisode, reasons.ToArray());
|
||||
}
|
||||
|
||||
private Rejection EvaluateSpec(IDecisionEngineSpecification spec, RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteriaBase = null)
|
||||
private DownloadRejection EvaluateSpec(IDownloadDecisionEngineSpecification spec, RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteriaBase = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -218,7 +219,7 @@ namespace NzbDrone.Core.DecisionEngine
|
||||
|
||||
if (!result.Accepted)
|
||||
{
|
||||
return new Rejection(result.Reason, spec.Type);
|
||||
return new DownloadRejection(result.Reason, result.Message, spec.Type);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
@@ -226,7 +227,7 @@ namespace NzbDrone.Core.DecisionEngine
|
||||
e.Data.Add("report", remoteEpisode.Release.ToJson());
|
||||
e.Data.Add("parsed", remoteEpisode.ParsedEpisodeInfo.ToJson());
|
||||
_logger.Error(e, "Couldn't evaluate decision on {0}", remoteEpisode.Release.Title);
|
||||
return new Rejection($"{spec.GetType().Name}: {e.Message}");
|
||||
return new DownloadRejection(DownloadRejectionReason.DecisionError, $"{spec.GetType().Name}: {e.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
9
src/NzbDrone.Core/DecisionEngine/DownloadRejection.cs
Normal file
9
src/NzbDrone.Core/DecisionEngine/DownloadRejection.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace NzbDrone.Core.DecisionEngine;
|
||||
|
||||
public class DownloadRejection : Rejection<DownloadRejectionReason>
|
||||
{
|
||||
public DownloadRejection(DownloadRejectionReason reason, string message, RejectionType type = RejectionType.Permanent)
|
||||
: base(reason, message, type)
|
||||
{
|
||||
}
|
||||
}
|
||||
75
src/NzbDrone.Core/DecisionEngine/DownloadRejectionReason.cs
Normal file
75
src/NzbDrone.Core/DecisionEngine/DownloadRejectionReason.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
namespace NzbDrone.Core.DecisionEngine;
|
||||
|
||||
public enum DownloadRejectionReason
|
||||
{
|
||||
Unknown,
|
||||
UnknownSeries,
|
||||
UnknownEpisode,
|
||||
MatchesAnotherSeries,
|
||||
UnableToParse,
|
||||
Error,
|
||||
DecisionError,
|
||||
MinimumAgeDelay,
|
||||
SeriesNotMonitored,
|
||||
EpisodeNotMonitored,
|
||||
HistoryRecentCutoffMet,
|
||||
HistoryCdhDisabledCutoffMet,
|
||||
HistoryHigherPreference,
|
||||
HistoryHigherRevision,
|
||||
HistoryCutoffMet,
|
||||
HistoryCustomFormatCutoffMet,
|
||||
HistoryCustomFormatScore,
|
||||
HistoryCustomFormatScoreIncrement,
|
||||
NoMatchingTag,
|
||||
PropersDisabled,
|
||||
ProperForOldFile,
|
||||
WrongEpisode,
|
||||
WrongSeason,
|
||||
WrongSeries,
|
||||
FullSeason,
|
||||
UnknownRuntime,
|
||||
BelowMinimumSize,
|
||||
AboveMaximumSize,
|
||||
AlreadyImportedSameHash,
|
||||
AlreadyImportedSameName,
|
||||
UnknownReleaseGroup,
|
||||
ReleaseGroupDoesNotMatch,
|
||||
IndexerDisabled,
|
||||
Blocklisted,
|
||||
CustomFormatMinimumScore,
|
||||
MinimumFreeSpace,
|
||||
FullSeasonNotAired,
|
||||
MaximumSizeExceeded,
|
||||
MinimumAge,
|
||||
MaximumAge,
|
||||
MultiSeason,
|
||||
Sample,
|
||||
ProtocolDisabled,
|
||||
QualityNotWanted,
|
||||
QualityUpgradesDisabled,
|
||||
QueueHigherPreference,
|
||||
QueueHigherRevision,
|
||||
QueueCutoffMet,
|
||||
QueueCustomFormatCutoffMet,
|
||||
QueueCustomFormatScore,
|
||||
QueueCustomFormatScoreIncrement,
|
||||
QueueNoUpgrades,
|
||||
QueuePropersDisabled,
|
||||
Raw,
|
||||
MustContainMissing,
|
||||
MustNotContainPresent,
|
||||
RepackDisabled,
|
||||
RepackUnknownReleaseGroup,
|
||||
RepackReleaseGroupDoesNotMatch,
|
||||
ExistingFileHasMoreEpisodes,
|
||||
AmbiguousNumbering,
|
||||
NotSeasonPack,
|
||||
SplitEpisode,
|
||||
MinimumSeeders,
|
||||
DiskHigherPreference,
|
||||
DiskHigherRevision,
|
||||
DiskCutoffMet,
|
||||
DiskCustomFormatCutoffMet,
|
||||
DiskCustomFormatScore,
|
||||
DiskCustomFormatScoreIncrement,
|
||||
}
|
||||
34
src/NzbDrone.Core/DecisionEngine/DownloadSpecDecision.cs
Normal file
34
src/NzbDrone.Core/DecisionEngine/DownloadSpecDecision.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace NzbDrone.Core.DecisionEngine
|
||||
{
|
||||
public class DownloadSpecDecision
|
||||
{
|
||||
public bool Accepted { get; private set; }
|
||||
public DownloadRejectionReason Reason { get; set; }
|
||||
public string Message { get; private set; }
|
||||
|
||||
private static readonly DownloadSpecDecision AcceptDownloadSpecDecision = new () { Accepted = true };
|
||||
private DownloadSpecDecision()
|
||||
{
|
||||
}
|
||||
|
||||
public static DownloadSpecDecision Accept()
|
||||
{
|
||||
return AcceptDownloadSpecDecision;
|
||||
}
|
||||
|
||||
public static DownloadSpecDecision Reject(DownloadRejectionReason reason, string message, params object[] args)
|
||||
{
|
||||
return Reject(reason, string.Format(message, args));
|
||||
}
|
||||
|
||||
public static DownloadSpecDecision Reject(DownloadRejectionReason reason, string message)
|
||||
{
|
||||
return new DownloadSpecDecision
|
||||
{
|
||||
Accepted = false,
|
||||
Reason = reason,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
namespace NzbDrone.Core.DecisionEngine
|
||||
{
|
||||
public class Rejection
|
||||
public class Rejection<TRejectionReason>
|
||||
{
|
||||
public string Reason { get; set; }
|
||||
public TRejectionReason Reason { get; set; }
|
||||
public string Message { get; set; }
|
||||
public RejectionType Type { get; set; }
|
||||
|
||||
public Rejection(string reason, RejectionType type = RejectionType.Permanent)
|
||||
public Rejection(TRejectionReason reason, string message, RejectionType type = RejectionType.Permanent)
|
||||
{
|
||||
Reason = reason;
|
||||
Message = message;
|
||||
Type = type;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return string.Format("[{0}] {1}", Type, Reason);
|
||||
return string.Format("[{0}] {1}", Type, Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ using NzbDrone.Core.Tv;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
{
|
||||
public class AcceptableSizeSpecification : IDecisionEngineSpecification
|
||||
public class AcceptableSizeSpecification : IDownloadDecisionEngineSpecification
|
||||
{
|
||||
private readonly IQualityDefinitionService _qualityDefinitionService;
|
||||
private readonly IEpisodeService _episodeService;
|
||||
@@ -24,7 +24,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
public SpecificationPriority Priority => SpecificationPriority.Default;
|
||||
public RejectionType Type => RejectionType.Permanent;
|
||||
|
||||
public Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria)
|
||||
public DownloadSpecDecision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria)
|
||||
{
|
||||
_logger.Debug("Beginning size check for: {0}", subject);
|
||||
|
||||
@@ -33,13 +33,13 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
if (subject.ParsedEpisodeInfo.Special)
|
||||
{
|
||||
_logger.Debug("Special release found, skipping size check.");
|
||||
return Decision.Accept();
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
|
||||
if (subject.Release.Size == 0)
|
||||
{
|
||||
_logger.Debug("Release has unknown size, skipping size check");
|
||||
return Decision.Accept();
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
|
||||
var seriesRuntime = subject.Series.Runtime;
|
||||
@@ -75,7 +75,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
if (runtime == 0)
|
||||
{
|
||||
_logger.Debug("Runtime of all episodes is 0, unable to validate size until it is available, rejecting");
|
||||
return Decision.Reject("Runtime of all episodes is 0, unable to validate size until it is available");
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.UnknownRuntime, "Runtime of all episodes is 0, unable to validate size until it is available");
|
||||
}
|
||||
|
||||
var qualityDefinition = _qualityDefinitionService.Get(quality);
|
||||
@@ -93,7 +93,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
var runtimeMessage = subject.Episodes.Count == 1 ? $"{runtime}min" : $"{subject.Episodes.Count}x {runtime}min";
|
||||
|
||||
_logger.Debug("Item: {0}, Size: {1} is smaller than minimum allowed size ({2} bytes for {3}), rejecting.", subject, subject.Release.Size, minSize, runtimeMessage);
|
||||
return Decision.Reject("{0} is smaller than minimum allowed {1} (for {2})", subject.Release.Size.SizeSuffix(), minSize.SizeSuffix(), runtimeMessage);
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.BelowMinimumSize, "{0} is smaller than minimum allowed {1} (for {2})", subject.Release.Size.SizeSuffix(), minSize.SizeSuffix(), runtimeMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,12 +114,12 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
var runtimeMessage = subject.Episodes.Count == 1 ? $"{runtime}min" : $"{subject.Episodes.Count}x {runtime}min";
|
||||
|
||||
_logger.Debug("Item: {0}, Size: {1} is greater than maximum allowed size ({2} for {3}), rejecting", subject, subject.Release.Size, maxSize, runtimeMessage);
|
||||
return Decision.Reject("{0} is larger than maximum allowed {1} (for {2})", subject.Release.Size.SizeSuffix(), maxSize.SizeSuffix(), runtimeMessage);
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.AboveMaximumSize, "{0} is larger than maximum allowed {1} (for {2})", subject.Release.Size.SizeSuffix(), maxSize.SizeSuffix(), runtimeMessage);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Debug("Item: {0}, meets size constraints", subject);
|
||||
return Decision.Accept();
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ using NzbDrone.Core.Parser.Model;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
{
|
||||
public class AlreadyImportedSpecification : IDecisionEngineSpecification
|
||||
public class AlreadyImportedSpecification : IDownloadDecisionEngineSpecification
|
||||
{
|
||||
private readonly IHistoryService _historyService;
|
||||
private readonly IConfigService _configService;
|
||||
@@ -27,14 +27,14 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
public SpecificationPriority Priority => SpecificationPriority.Database;
|
||||
public RejectionType Type => RejectionType.Permanent;
|
||||
|
||||
public Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria)
|
||||
public DownloadSpecDecision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria)
|
||||
{
|
||||
var cdhEnabled = _configService.EnableCompletedDownloadHandling;
|
||||
|
||||
if (!cdhEnabled)
|
||||
{
|
||||
_logger.Debug("Skipping already imported check because CDH is disabled");
|
||||
return Decision.Accept();
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
|
||||
_logger.Debug("Performing already imported check on report");
|
||||
@@ -80,7 +80,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
if (torrentInfo?.InfoHash != null && torrentInfo.InfoHash.ToUpper() == lastGrabbed.DownloadId)
|
||||
{
|
||||
_logger.Debug("Has same torrent hash as a grabbed and imported release");
|
||||
return Decision.Reject("Has same torrent hash as a grabbed and imported release");
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.AlreadyImportedSameHash, "Has same torrent hash as a grabbed and imported release");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,11 +90,11 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
if (release.Title.Equals(lastGrabbed.SourceTitle, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
_logger.Debug("Has same release name as a grabbed and imported release");
|
||||
return Decision.Reject("Has same release name as a grabbed and imported release");
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.AlreadyImportedSameName, "Has same release name as a grabbed and imported release");
|
||||
}
|
||||
}
|
||||
|
||||
return Decision.Accept();
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ using NzbDrone.Core.Tv;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
{
|
||||
public class AnimeVersionUpgradeSpecification : IDecisionEngineSpecification
|
||||
public class AnimeVersionUpgradeSpecification : IDownloadDecisionEngineSpecification
|
||||
{
|
||||
private readonly UpgradableSpecification _upgradableSpecification;
|
||||
private readonly IConfigService _configService;
|
||||
@@ -25,13 +25,13 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
public SpecificationPriority Priority => SpecificationPriority.Default;
|
||||
public RejectionType Type => RejectionType.Permanent;
|
||||
|
||||
public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria)
|
||||
public virtual DownloadSpecDecision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria)
|
||||
{
|
||||
var releaseGroup = subject.ParsedEpisodeInfo.ReleaseGroup;
|
||||
|
||||
if (subject.Series.SeriesType != SeriesTypes.Anime)
|
||||
{
|
||||
return Decision.Accept();
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
|
||||
var downloadPropersAndRepacks = _configService.DownloadPropersAndRepacks;
|
||||
@@ -39,7 +39,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
if (downloadPropersAndRepacks == ProperDownloadTypes.DoNotPrefer)
|
||||
{
|
||||
_logger.Debug("Version upgrades are not preferred, skipping check");
|
||||
return Decision.Accept();
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
|
||||
foreach (var file in subject.Episodes.Where(c => c.EpisodeFileId != 0).Select(c => c.EpisodeFile.Value))
|
||||
@@ -54,24 +54,24 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
if (file.ReleaseGroup.IsNullOrWhiteSpace())
|
||||
{
|
||||
_logger.Debug("Unable to compare release group, existing file's release group is unknown");
|
||||
return Decision.Reject("Existing release group is unknown");
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.UnknownReleaseGroup, "Existing release group is unknown");
|
||||
}
|
||||
|
||||
if (releaseGroup.IsNullOrWhiteSpace())
|
||||
{
|
||||
_logger.Debug("Unable to compare release group, release's release group is unknown");
|
||||
return Decision.Reject("Release group is unknown");
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.UnknownReleaseGroup, "Release group is unknown");
|
||||
}
|
||||
|
||||
if (file.ReleaseGroup != releaseGroup)
|
||||
{
|
||||
_logger.Debug("Existing Release group is: {0} - release's release group is: {1}", file.ReleaseGroup, releaseGroup);
|
||||
return Decision.Reject("{0} does not match existing release group {1}", releaseGroup, file.ReleaseGroup);
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.ReleaseGroupDoesNotMatch, "{0} does not match existing release group {1}", releaseGroup, file.ReleaseGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Decision.Accept();
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ using NzbDrone.Core.Parser.Model;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
{
|
||||
public class BlockedIndexerSpecification : IDecisionEngineSpecification
|
||||
public class BlockedIndexerSpecification : IDownloadDecisionEngineSpecification
|
||||
{
|
||||
private readonly IIndexerStatusService _indexerStatusService;
|
||||
private readonly Logger _logger;
|
||||
@@ -27,15 +27,15 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
public SpecificationPriority Priority => SpecificationPriority.Database;
|
||||
public RejectionType Type => RejectionType.Temporary;
|
||||
|
||||
public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria)
|
||||
public virtual DownloadSpecDecision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria)
|
||||
{
|
||||
var status = _blockedIndexerCache.Find(subject.Release.IndexerId.ToString());
|
||||
if (status != null)
|
||||
{
|
||||
return Decision.Reject($"Indexer {subject.Release.Indexer} is blocked till {status.DisabledTill} due to failures, cannot grab release.");
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.IndexerDisabled, $"Indexer {subject.Release.Indexer} is blocked till {status.DisabledTill} due to failures, cannot grab release.");
|
||||
}
|
||||
|
||||
return Decision.Accept();
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
|
||||
private IDictionary<string, IndexerStatus> FetchBlockedIndexer()
|
||||
|
||||
@@ -5,7 +5,7 @@ using NzbDrone.Core.Parser.Model;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
{
|
||||
public class BlocklistSpecification : IDecisionEngineSpecification
|
||||
public class BlocklistSpecification : IDownloadDecisionEngineSpecification
|
||||
{
|
||||
private readonly IBlocklistService _blocklistService;
|
||||
private readonly Logger _logger;
|
||||
@@ -19,15 +19,15 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
public SpecificationPriority Priority => SpecificationPriority.Database;
|
||||
public RejectionType Type => RejectionType.Permanent;
|
||||
|
||||
public Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria)
|
||||
public DownloadSpecDecision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria)
|
||||
{
|
||||
if (_blocklistService.Blocklisted(subject.Series.Id, subject.Release))
|
||||
{
|
||||
_logger.Debug("{0} is blocklisted, rejecting.", subject.Release.Title);
|
||||
return Decision.Reject("Release is blocklisted");
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.Blocklisted, "Release is blocklisted");
|
||||
}
|
||||
|
||||
return Decision.Accept();
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,22 +4,22 @@ using NzbDrone.Core.Parser.Model;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
{
|
||||
public class CustomFormatAllowedbyProfileSpecification : IDecisionEngineSpecification
|
||||
public class CustomFormatAllowedbyProfileSpecification : IDownloadDecisionEngineSpecification
|
||||
{
|
||||
public SpecificationPriority Priority => SpecificationPriority.Default;
|
||||
public RejectionType Type => RejectionType.Permanent;
|
||||
|
||||
public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria)
|
||||
public virtual DownloadSpecDecision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria)
|
||||
{
|
||||
var minScore = subject.Series.QualityProfile.Value.MinFormatScore;
|
||||
var score = subject.CustomFormatScore;
|
||||
|
||||
if (score < minScore)
|
||||
{
|
||||
return Decision.Reject("Custom Formats {0} have score {1} below Series profile minimum {2}", subject.CustomFormats.ConcatToString(), score, minScore);
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.CustomFormatMinimumScore, "Custom Formats {0} have score {1} below Series profile minimum {2}", subject.CustomFormats.ConcatToString(), score, minScore);
|
||||
}
|
||||
|
||||
return Decision.Accept();
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ using NzbDrone.Core.Parser.Model;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
{
|
||||
public class FreeSpaceSpecification : IDecisionEngineSpecification
|
||||
public class FreeSpaceSpecification : IDownloadDecisionEngineSpecification
|
||||
{
|
||||
private readonly IConfigService _configService;
|
||||
private readonly IDiskProvider _diskProvider;
|
||||
@@ -24,12 +24,12 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
public SpecificationPriority Priority => SpecificationPriority.Default;
|
||||
public RejectionType Type => RejectionType.Permanent;
|
||||
|
||||
public Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria)
|
||||
public DownloadSpecDecision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria)
|
||||
{
|
||||
if (_configService.SkipFreeSpaceCheckWhenImporting)
|
||||
{
|
||||
_logger.Debug("Skipping free space check");
|
||||
return Decision.Accept();
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
|
||||
var size = subject.Release.Size;
|
||||
@@ -49,7 +49,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
{
|
||||
_logger.Debug("Unable to get available space for {0}. Skipping", path);
|
||||
|
||||
return Decision.Accept();
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
|
||||
var minimumSpace = _configService.MinimumFreeSpaceWhenImporting.Megabytes();
|
||||
@@ -60,7 +60,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
var message = "Importing after download will exceed available disk space";
|
||||
|
||||
_logger.Debug(message);
|
||||
return Decision.Reject(message);
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.MinimumFreeSpace, message);
|
||||
}
|
||||
|
||||
if (remainingSpace < minimumSpace)
|
||||
@@ -68,10 +68,10 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
var message = $"Not enough free space ({minimumSpace.SizeSuffix()}) to import after download: {remainingSpace.SizeSuffix()}. (Settings: Media Management: Minimum Free Space)";
|
||||
|
||||
_logger.Debug(message);
|
||||
return Decision.Reject(message);
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.MinimumFreeSpace, message);
|
||||
}
|
||||
|
||||
return Decision.Accept();
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ using NzbDrone.Core.Parser.Model;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
{
|
||||
public class FullSeasonSpecification : IDecisionEngineSpecification
|
||||
public class FullSeasonSpecification : IDownloadDecisionEngineSpecification
|
||||
{
|
||||
private readonly Logger _logger;
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
public SpecificationPriority Priority => SpecificationPriority.Default;
|
||||
public RejectionType Type => RejectionType.Permanent;
|
||||
|
||||
public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria)
|
||||
public virtual DownloadSpecDecision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria)
|
||||
{
|
||||
if (subject.ParsedEpisodeInfo.FullSeason)
|
||||
{
|
||||
@@ -28,11 +28,11 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
if (subject.Episodes.Any(e => !e.AirDateUtc.HasValue || e.AirDateUtc.Value.After(DateTime.UtcNow.AddHours(24))))
|
||||
{
|
||||
_logger.Debug("Full season release {0} rejected. All episodes haven't aired yet.", subject.Release.Title);
|
||||
return Decision.Reject("Full season release rejected. All episodes haven't aired yet.");
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.FullSeasonNotAired, "Full season release rejected. All episodes haven't aired yet.");
|
||||
}
|
||||
}
|
||||
|
||||
return Decision.Accept();
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user