mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-16 21:15:28 -04:00
Compare commits
15 Commits
v4.0.11.26
...
v4.0.11.26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 {
|
||||
|
||||
@@ -59,6 +59,8 @@ interface SeriesAppState
|
||||
deleteOptions: {
|
||||
addImportListExclusion: boolean;
|
||||
};
|
||||
|
||||
pendingChanges: Partial<Series>;
|
||||
}
|
||||
|
||||
export default SeriesAppState;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
167
frontend/src/Store/Selectors/selectSettings.ts
Normal file
167
frontend/src/Store/Selectors/selectSettings.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
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],
|
||||
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();
|
||||
|
||||
@@ -12,7 +12,7 @@ interface Field {
|
||||
order: number;
|
||||
name: string;
|
||||
label: string;
|
||||
value: boolean | number | string;
|
||||
value: boolean | number | string | number[];
|
||||
type: string;
|
||||
advanced: boolean;
|
||||
privacy: string;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
public class Deluge : TorrentClientBase<DelugeSettings>
|
||||
{
|
||||
private readonly IDelugeProxy _proxy;
|
||||
private bool _hasAttemptedReconnecting;
|
||||
|
||||
public Deluge(IDelugeProxy proxy,
|
||||
ITorrentFileInfoReader torrentFileInfoReader,
|
||||
@@ -128,14 +129,9 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
|
||||
foreach (var torrent in torrents)
|
||||
{
|
||||
// Silently ignore torrents with no hash
|
||||
if (torrent.Hash.IsNullOrWhiteSpace())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ignore torrents without a name, but track to log a single warning for all invalid torrents.
|
||||
if (torrent.Name.IsNullOrWhiteSpace())
|
||||
// Ignore torrents without a hash or name, but track to log a single warning
|
||||
// for all invalid torrents as well as reconnect to the Daemon.
|
||||
if (torrent.Hash.IsNullOrWhiteSpace() || torrent.Name.IsNullOrWhiteSpace())
|
||||
{
|
||||
ignoredCount++;
|
||||
continue;
|
||||
@@ -199,9 +195,20 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
if (ignoredCount > 0)
|
||||
if (ignoredCount > 0 && _hasAttemptedReconnecting)
|
||||
{
|
||||
_logger.Warn("{0} torrent(s) were ignored because they did not have a title. Check Deluge and remove any invalid torrents");
|
||||
if (_hasAttemptedReconnecting)
|
||||
{
|
||||
_logger.Warn("{0} torrent(s) were ignored because they did not have a hash or title. Deluge may have disconnected from it's daemon. If you continue to see this error, check Deluge for invalid torrents.", ignoredCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
_proxy.ReconnectToDaemon(Settings);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_hasAttemptedReconnecting = false;
|
||||
}
|
||||
|
||||
return items;
|
||||
@@ -322,9 +329,9 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
return null;
|
||||
}
|
||||
|
||||
var enabledPlugins = _proxy.GetEnabledPlugins(Settings);
|
||||
var methods = _proxy.GetMethods(Settings);
|
||||
|
||||
if (!enabledPlugins.Contains("Label"))
|
||||
if (!methods.Any(m => m.StartsWith("label.")))
|
||||
{
|
||||
return new NzbDroneValidationFailure("TvCategory", _localizationService.GetLocalizedString("DownloadClientDelugeValidationLabelPluginInactive"))
|
||||
{
|
||||
|
||||
@@ -18,8 +18,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
Dictionary<string, object> GetConfig(DelugeSettings settings);
|
||||
DelugeTorrent[] GetTorrents(DelugeSettings settings);
|
||||
DelugeTorrent[] GetTorrentsByLabel(string label, DelugeSettings settings);
|
||||
string[] GetAvailablePlugins(DelugeSettings settings);
|
||||
string[] GetEnabledPlugins(DelugeSettings settings);
|
||||
string[] GetMethods(DelugeSettings settings);
|
||||
string[] GetAvailableLabels(DelugeSettings settings);
|
||||
DelugeLabel GetLabelOptions(DelugeSettings settings);
|
||||
void SetTorrentLabel(string hash, string label, DelugeSettings settings);
|
||||
@@ -30,6 +29,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
string AddTorrentFromFile(string filename, byte[] fileContent, DelugeSettings settings);
|
||||
bool RemoveTorrent(string hash, bool removeData, DelugeSettings settings);
|
||||
void MoveTorrentToTopInQueue(string hash, DelugeSettings settings);
|
||||
void ReconnectToDaemon(DelugeSettings settings);
|
||||
}
|
||||
|
||||
public class DelugeProxy : IDelugeProxy
|
||||
@@ -51,25 +51,14 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
|
||||
public string GetVersion(DelugeSettings settings)
|
||||
{
|
||||
try
|
||||
var methods = GetMethods(settings);
|
||||
|
||||
if (methods.Contains("daemon.get_version"))
|
||||
{
|
||||
var response = ProcessRequest<string>(settings, "daemon.info");
|
||||
|
||||
return response;
|
||||
return ProcessRequest<string>(settings, "daemon.get_version");
|
||||
}
|
||||
catch (DownloadClientException ex)
|
||||
{
|
||||
if (ex.Message.Contains("Unknown method"))
|
||||
{
|
||||
// Deluge v2 beta replaced 'daemon.info' with 'daemon.get_version'.
|
||||
// It may return or become official, for now we just retry with the get_version api.
|
||||
var response = ProcessRequest<string>(settings, "daemon.get_version");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
return ProcessRequest<string>(settings, "daemon.info");
|
||||
}
|
||||
|
||||
public Dictionary<string, object> GetConfig(DelugeSettings settings)
|
||||
@@ -101,6 +90,13 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
return GetTorrents(response);
|
||||
}
|
||||
|
||||
public string[] GetMethods(DelugeSettings settings)
|
||||
{
|
||||
var response = ProcessRequest<string[]>(settings, "system.listMethods");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public string AddTorrentFromMagnet(string magnetLink, DelugeSettings settings)
|
||||
{
|
||||
dynamic options = new ExpandoObject();
|
||||
@@ -159,20 +155,6 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
ProcessRequest<object>(settings, "core.queue_top", (object)new string[] { hash });
|
||||
}
|
||||
|
||||
public string[] GetAvailablePlugins(DelugeSettings settings)
|
||||
{
|
||||
var response = ProcessRequest<string[]>(settings, "core.get_available_plugins");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public string[] GetEnabledPlugins(DelugeSettings settings)
|
||||
{
|
||||
var response = ProcessRequest<string[]>(settings, "core.get_enabled_plugins");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public string[] GetAvailableLabels(DelugeSettings settings)
|
||||
{
|
||||
var response = ProcessRequest<string[]>(settings, "label.get_labels");
|
||||
@@ -223,6 +205,12 @@ namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
ProcessRequest<object>(settings, "label.set_torrent", hash, label);
|
||||
}
|
||||
|
||||
public void ReconnectToDaemon(DelugeSettings settings)
|
||||
{
|
||||
ProcessRequest<string>(settings, "web.disconnect");
|
||||
ConnectDaemon(BuildRequest(settings));
|
||||
}
|
||||
|
||||
private JsonRpcRequestBuilder BuildRequest(DelugeSettings settings)
|
||||
{
|
||||
var url = HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase);
|
||||
|
||||
@@ -278,20 +278,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd
|
||||
status.OutputRootFolders = new List<OsPath> { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, category.FullPath) };
|
||||
}
|
||||
|
||||
if (config.Misc.history_retention.IsNullOrWhiteSpace())
|
||||
{
|
||||
status.RemovesCompletedDownloads = false;
|
||||
}
|
||||
else if (config.Misc.history_retention.EndsWith("d"))
|
||||
{
|
||||
int.TryParse(config.Misc.history_retention.AsSpan(0, config.Misc.history_retention.Length - 1),
|
||||
out var daysRetention);
|
||||
status.RemovesCompletedDownloads = daysRetention < 14;
|
||||
}
|
||||
else
|
||||
{
|
||||
status.RemovesCompletedDownloads = config.Misc.history_retention != "0";
|
||||
}
|
||||
status.RemovesCompletedDownloads = RemovesCompletedDownloads(config);
|
||||
|
||||
return status;
|
||||
}
|
||||
@@ -548,6 +535,44 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd
|
||||
return categories.Contains(category);
|
||||
}
|
||||
|
||||
private bool RemovesCompletedDownloads(SabnzbdConfig config)
|
||||
{
|
||||
var retention = config.Misc.history_retention;
|
||||
var option = config.Misc.history_retention_option;
|
||||
var number = config.Misc.history_retention_number;
|
||||
|
||||
switch (option)
|
||||
{
|
||||
case "all":
|
||||
return false;
|
||||
case "number-archive":
|
||||
case "number-delete":
|
||||
return true;
|
||||
case "days-archive":
|
||||
case "days-delete":
|
||||
return number < 14;
|
||||
case "all-archive":
|
||||
case "all-delete":
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO: Remove these checks once support for SABnzbd < 4.3 is removed
|
||||
|
||||
if (retention.IsNullOrWhiteSpace())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (retention.EndsWith("d"))
|
||||
{
|
||||
int.TryParse(config.Misc.history_retention.AsSpan(0, config.Misc.history_retention.Length - 1),
|
||||
out var daysRetention);
|
||||
return daysRetention < 14;
|
||||
}
|
||||
|
||||
return retention != "0";
|
||||
}
|
||||
|
||||
private bool ValidatePath(DownloadClientItem downloadClientItem)
|
||||
{
|
||||
var downloadItemOutputPath = downloadClientItem.OutputPath;
|
||||
|
||||
@@ -32,6 +32,8 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd
|
||||
public bool enable_date_sorting { get; set; }
|
||||
public bool pre_check { get; set; }
|
||||
public string history_retention { get; set; }
|
||||
public string history_retention_option { get; set; }
|
||||
public int history_retention_number { get; set; }
|
||||
}
|
||||
|
||||
public class SabnzbdCategory
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentValidation;
|
||||
using NzbDrone.Common.Extensions;
|
||||
@@ -27,6 +28,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
{
|
||||
private static readonly TransmissionSettingsValidator Validator = new ();
|
||||
|
||||
// This constructor is used when creating a new instance, such as the user adding a new Transmission client.
|
||||
public TransmissionSettings()
|
||||
{
|
||||
Host = "localhost";
|
||||
@@ -35,6 +37,18 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
TvCategory = "tv-sonarr";
|
||||
}
|
||||
|
||||
// TODO: Remove this in v5
|
||||
// This constructor is used when deserializing from JSON, it will set the
|
||||
// category to the deserialized value, defaulting to null.
|
||||
[JsonConstructor]
|
||||
public TransmissionSettings(string tvCategory = null)
|
||||
{
|
||||
Host = "localhost";
|
||||
Port = 9091;
|
||||
UrlBase = "/transmission/";
|
||||
TvCategory = tvCategory;
|
||||
}
|
||||
|
||||
[FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)]
|
||||
public string Host { get; set; }
|
||||
|
||||
|
||||
@@ -359,11 +359,11 @@ namespace NzbDrone.Core.Download.Pending
|
||||
ect = ect.AddMinutes(_configService.RssSyncInterval);
|
||||
}
|
||||
|
||||
var timeleft = ect.Subtract(DateTime.UtcNow);
|
||||
var timeLeft = ect.Subtract(DateTime.UtcNow);
|
||||
|
||||
if (timeleft.TotalSeconds < 0)
|
||||
if (timeLeft.TotalSeconds < 0)
|
||||
{
|
||||
timeleft = TimeSpan.Zero;
|
||||
timeLeft = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
string downloadClientName = null;
|
||||
@@ -385,9 +385,9 @@ namespace NzbDrone.Core.Download.Pending
|
||||
Quality = pendingRelease.RemoteEpisode.ParsedEpisodeInfo.Quality,
|
||||
Title = pendingRelease.Title,
|
||||
Size = pendingRelease.RemoteEpisode.Release.Size,
|
||||
Sizeleft = pendingRelease.RemoteEpisode.Release.Size,
|
||||
SizeLeft = pendingRelease.RemoteEpisode.Release.Size,
|
||||
RemoteEpisode = pendingRelease.RemoteEpisode,
|
||||
Timeleft = timeleft,
|
||||
TimeLeft = timeLeft,
|
||||
EstimatedCompletionTime = ect,
|
||||
Added = pendingRelease.Added,
|
||||
Status = Enum.TryParse(pendingRelease.Reason.ToString(), out QueueStatus outValue) ? outValue : QueueStatus.Unknown,
|
||||
|
||||
@@ -131,7 +131,15 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Kometa
|
||||
|
||||
try
|
||||
{
|
||||
var screenshot = episodeFile.Episodes.Value.First().Images.SingleOrDefault(i => i.CoverType == MediaCoverTypes.Screenshot);
|
||||
var firstEpisode = episodeFile.Episodes.Value.FirstOrDefault();
|
||||
|
||||
if (firstEpisode == null)
|
||||
{
|
||||
_logger.Debug("Episode file has no associated episodes, potentially a duplicate file");
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
|
||||
var screenshot = firstEpisode.Images.SingleOrDefault(i => i.CoverType == MediaCoverTypes.Screenshot);
|
||||
|
||||
if (screenshot == null)
|
||||
{
|
||||
|
||||
@@ -421,7 +421,15 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc
|
||||
|
||||
try
|
||||
{
|
||||
var screenshot = episodeFile.Episodes.Value.First().Images.SingleOrDefault(i => i.CoverType == MediaCoverTypes.Screenshot);
|
||||
var firstEpisode = episodeFile.Episodes.Value.FirstOrDefault();
|
||||
|
||||
if (firstEpisode == null)
|
||||
{
|
||||
_logger.Debug("Episode file has no associated episodes, potentially a duplicate file");
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
|
||||
var screenshot = firstEpisode.Images.SingleOrDefault(i => i.CoverType == MediaCoverTypes.Screenshot);
|
||||
|
||||
if (screenshot == null)
|
||||
{
|
||||
|
||||
@@ -23,19 +23,26 @@ namespace NzbDrone.Core.HealthCheck.Checks
|
||||
|
||||
public override HealthCheck Check()
|
||||
{
|
||||
var request = _cloudRequestBuilder.Create()
|
||||
.Resource("/time")
|
||||
.Build();
|
||||
|
||||
var response = _client.Execute(request);
|
||||
var result = Json.Deserialize<ServiceTimeResponse>(response.Content);
|
||||
var systemTime = DateTime.UtcNow;
|
||||
|
||||
// +/- more than 1 day
|
||||
if (Math.Abs(result.DateTimeUtc.Subtract(systemTime).TotalDays) >= 1)
|
||||
try
|
||||
{
|
||||
_logger.Error("System time mismatch. SystemTime: {0} Expected Time: {1}. Update system time", systemTime, result.DateTimeUtc);
|
||||
return new HealthCheck(GetType(), HealthCheckResult.Error, _localizationService.GetLocalizedString("SystemTimeHealthCheckMessage"), "#system-time-off");
|
||||
var request = _cloudRequestBuilder.Create()
|
||||
.Resource("/time")
|
||||
.Build();
|
||||
|
||||
var response = _client.Execute(request);
|
||||
var result = Json.Deserialize<ServiceTimeResponse>(response.Content);
|
||||
var systemTime = DateTime.UtcNow;
|
||||
|
||||
// +/- more than 1 day
|
||||
if (Math.Abs(result.DateTimeUtc.Subtract(systemTime).TotalDays) >= 1)
|
||||
{
|
||||
_logger.Error("System time mismatch. SystemTime: {0} Expected Time: {1}. Update system time", systemTime, result.DateTimeUtc);
|
||||
return new HealthCheck(GetType(), HealthCheckResult.Error, _localizationService.GetLocalizedString("SystemTimeHealthCheckMessage"), "#system-time-off");
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.Warn(e, "Unable to verify system time");
|
||||
}
|
||||
|
||||
return new HealthCheck(GetType());
|
||||
|
||||
@@ -2085,6 +2085,8 @@
|
||||
"UpdateStartupTranslocationHealthCheckMessage": "Cannot install update because startup folder '{startupFolder}' is in an App Translocation folder.",
|
||||
"UpdateUiNotWritableHealthCheckMessage": "Cannot install update because UI folder '{uiFolder}' is not writable by the user '{userName}'.",
|
||||
"UpdaterLogFiles": "Updater Log Files",
|
||||
"UpdatePath": "Update Path",
|
||||
"UpdateSeriesPath": "Update Series Path",
|
||||
"Updates": "Updates",
|
||||
"UpgradeUntil": "Upgrade Until",
|
||||
"UpgradeUntilCustomFormatScore": "Upgrade Until Custom Format Score",
|
||||
|
||||
@@ -18,8 +18,8 @@ namespace NzbDrone.Core.Queue
|
||||
public QualityModel Quality { get; set; }
|
||||
public decimal Size { get; set; }
|
||||
public string Title { get; set; }
|
||||
public decimal Sizeleft { get; set; }
|
||||
public TimeSpan? Timeleft { get; set; }
|
||||
public decimal SizeLeft { get; set; }
|
||||
public TimeSpan? TimeLeft { get; set; }
|
||||
public DateTime? EstimatedCompletionTime { get; set; }
|
||||
public DateTime? Added { get; set; }
|
||||
public QueueStatus Status { get; set; }
|
||||
|
||||
@@ -67,8 +67,8 @@ namespace NzbDrone.Core.Queue
|
||||
Quality = trackedDownload.RemoteEpisode?.ParsedEpisodeInfo.Quality ?? new QualityModel(Quality.Unknown),
|
||||
Title = Parser.Parser.RemoveFileExtension(trackedDownload.DownloadItem.Title),
|
||||
Size = trackedDownload.DownloadItem.TotalSize,
|
||||
Sizeleft = trackedDownload.DownloadItem.RemainingSize,
|
||||
Timeleft = trackedDownload.DownloadItem.RemainingTime,
|
||||
SizeLeft = trackedDownload.DownloadItem.RemainingSize,
|
||||
TimeLeft = trackedDownload.DownloadItem.RemainingTime,
|
||||
Status = Enum.TryParse(trackedDownload.DownloadItem.Status.ToString(), out QueueStatus outValue) ? outValue : QueueStatus.Unknown,
|
||||
TrackedDownloadStatus = trackedDownload.Status,
|
||||
TrackedDownloadState = trackedDownload.State,
|
||||
@@ -86,9 +86,9 @@ namespace NzbDrone.Core.Queue
|
||||
|
||||
queue.Id = HashConverter.GetHashInt31($"trackedDownload-{trackedDownload.DownloadClient}-{trackedDownload.DownloadItem.DownloadId}-ep{episode?.Id ?? 0}");
|
||||
|
||||
if (queue.Timeleft.HasValue)
|
||||
if (queue.TimeLeft.HasValue)
|
||||
{
|
||||
queue.EstimatedCompletionTime = DateTime.UtcNow.Add(queue.Timeleft.Value);
|
||||
queue.EstimatedCompletionTime = DateTime.UtcNow.Add(queue.TimeLeft.Value);
|
||||
}
|
||||
|
||||
return queue;
|
||||
|
||||
@@ -219,10 +219,10 @@ namespace NzbDrone.Core.RootFolders
|
||||
{
|
||||
var osPath = new OsPath(path);
|
||||
|
||||
return osPath.Directory.ToString().TrimEnd(osPath.IsUnixPath ? '/' : '\\');
|
||||
return osPath.Directory.ToString().GetCleanPath();
|
||||
}
|
||||
|
||||
return possibleRootFolder.Path;
|
||||
return possibleRootFolder.Path.GetCleanPath();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ namespace NzbDrone.Core.Tv
|
||||
|
||||
private bool GetMonitoredStatus(Episode episode, IEnumerable<Season> seasons, Series series)
|
||||
{
|
||||
if ((episode.EpisodeNumber == 0 && episode.SeasonNumber != 1) || series.MonitorNewItems == NewItemMonitorTypes.None)
|
||||
if (episode.EpisodeNumber == 0 && episode.SeasonNumber != 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
73
src/NzbDrone.Integration.Test/ApiTests/QueueFixture.cs
Normal file
73
src/NzbDrone.Integration.Test/ApiTests/QueueFixture.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.Messaging.Commands;
|
||||
using NzbDrone.Integration.Test.Client;
|
||||
using Sonarr.Api.V3.Queue;
|
||||
using Sonarr.Http;
|
||||
|
||||
namespace NzbDrone.Integration.Test.ApiTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class QueueFixture : IntegrationTest
|
||||
{
|
||||
private PagingResource<QueueResource> GetFirstPage()
|
||||
{
|
||||
var request = Queue.BuildRequest();
|
||||
request.AddParameter("includeUnknownSeriesItems", true);
|
||||
|
||||
return Queue.Get<PagingResource<QueueResource>>(request);
|
||||
}
|
||||
|
||||
private void RefreshQueue()
|
||||
{
|
||||
var command = Commands.Post(new SimpleCommandResource { Name = "RefreshMonitoredDownloads" });
|
||||
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
var updatedCommand = Commands.Get(command.Id);
|
||||
|
||||
if (updatedCommand.Status == CommandStatus.Completed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Thread.Sleep(1000);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Order(0)]
|
||||
public void ensure_queue_is_empty_when_download_client_is_configured()
|
||||
{
|
||||
EnsureNoDownloadClient();
|
||||
EnsureDownloadClient();
|
||||
|
||||
var queue = GetFirstPage();
|
||||
|
||||
queue.TotalRecords.Should().Be(0);
|
||||
queue.Records.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Order(1)]
|
||||
public void ensure_queue_is_not_empty()
|
||||
{
|
||||
EnsureNoDownloadClient();
|
||||
|
||||
var client = EnsureDownloadClient();
|
||||
var directory = client.Fields.First(v => v.Name == "watchFolder").Value as string;
|
||||
|
||||
File.WriteAllText(Path.Combine(directory, "Series.Title.S01E01.mkv"), "Test Download");
|
||||
RefreshQueue();
|
||||
|
||||
var queue = GetFirstPage();
|
||||
|
||||
queue.TotalRecords.Should().Be(1);
|
||||
queue.Records.Should().NotBeEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/NzbDrone.Integration.Test/Client/QueueClient.cs
Normal file
13
src/NzbDrone.Integration.Test/Client/QueueClient.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using RestSharp;
|
||||
using Sonarr.Api.V3.Queue;
|
||||
|
||||
namespace NzbDrone.Integration.Test.Client
|
||||
{
|
||||
public class QueueClient : ClientBase<QueueResource>
|
||||
{
|
||||
public QueueClient(IRestClient restClient, string apiKey)
|
||||
: base(restClient, apiKey)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ namespace NzbDrone.Integration.Test
|
||||
public ClientBase<TagResource> Tags;
|
||||
public ClientBase<EpisodeResource> WantedMissing;
|
||||
public ClientBase<EpisodeResource> WantedCutoffUnmet;
|
||||
public QueueClient Queue;
|
||||
|
||||
private List<SignalRMessage> _signalRReceived;
|
||||
|
||||
@@ -121,6 +122,7 @@ namespace NzbDrone.Integration.Test
|
||||
Tags = new ClientBase<TagResource>(RestClient, ApiKey);
|
||||
WantedMissing = new ClientBase<EpisodeResource>(RestClient, ApiKey, "wanted/missing");
|
||||
WantedCutoffUnmet = new ClientBase<EpisodeResource>(RestClient, ApiKey, "wanted/cutoff");
|
||||
Queue = new QueueClient(RestClient, ApiKey);
|
||||
}
|
||||
|
||||
[OneTimeTearDown]
|
||||
|
||||
@@ -219,8 +219,8 @@ namespace Sonarr.Api.V3.Queue
|
||||
if (pagingSpec.SortKey == "timeleft")
|
||||
{
|
||||
ordered = ascending
|
||||
? fullQueue.OrderBy(q => q.Timeleft, new TimeleftComparer())
|
||||
: fullQueue.OrderByDescending(q => q.Timeleft, new TimeleftComparer());
|
||||
? fullQueue.OrderBy(q => q.TimeLeft, new TimeleftComparer())
|
||||
: fullQueue.OrderByDescending(q => q.TimeLeft, new TimeleftComparer());
|
||||
}
|
||||
else if (pagingSpec.SortKey == "estimatedCompletionTime")
|
||||
{
|
||||
@@ -271,7 +271,7 @@ namespace Sonarr.Api.V3.Queue
|
||||
ordered = ascending ? fullQueue.OrderBy(orderByFunc) : fullQueue.OrderByDescending(orderByFunc);
|
||||
}
|
||||
|
||||
ordered = ordered.ThenByDescending(q => q.Size == 0 ? 0 : 100 - (q.Sizeleft / q.Size * 100));
|
||||
ordered = ordered.ThenByDescending(q => q.Size == 0 ? 0 : 100 - (q.SizeLeft / q.Size * 100));
|
||||
|
||||
pagingSpec.Records = ordered.Skip((pagingSpec.Page - 1) * pagingSpec.PageSize).Take(pagingSpec.PageSize).ToList();
|
||||
pagingSpec.TotalRecords = fullQueue.Count;
|
||||
@@ -312,9 +312,9 @@ namespace Sonarr.Api.V3.Queue
|
||||
return q => q.Size;
|
||||
case "progress":
|
||||
// Avoid exploding if a download's size is 0
|
||||
return q => 100 - (q.Sizeleft / Math.Max(q.Size * 100, 1));
|
||||
return q => 100 - (q.SizeLeft / Math.Max(q.Size * 100, 1));
|
||||
default:
|
||||
return q => q.Timeleft;
|
||||
return q => q.TimeLeft;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,11 @@ namespace Sonarr.Api.V3.Queue
|
||||
public int CustomFormatScore { get; set; }
|
||||
public decimal Size { get; set; }
|
||||
public string Title { get; set; }
|
||||
public decimal Sizeleft { get; set; }
|
||||
public TimeSpan? Timeleft { get; set; }
|
||||
|
||||
// Collides with existing properties due to case-insensitive deserialization
|
||||
// public decimal SizeLeft { get; set; }
|
||||
// public TimeSpan? TimeLeft { get; set; }
|
||||
|
||||
public DateTime? EstimatedCompletionTime { get; set; }
|
||||
public DateTime? Added { get; set; }
|
||||
public QueueStatus Status { get; set; }
|
||||
@@ -42,6 +45,12 @@ namespace Sonarr.Api.V3.Queue
|
||||
public string Indexer { get; set; }
|
||||
public string OutputPath { get; set; }
|
||||
public bool EpisodeHasFile { get; set; }
|
||||
|
||||
[Obsolete("Will be replaced by SizeLeft")]
|
||||
public decimal Sizeleft { get; set; }
|
||||
|
||||
[Obsolete("Will be replaced by TimeLeft")]
|
||||
public TimeSpan? Timeleft { get; set; }
|
||||
}
|
||||
|
||||
public static class QueueResourceMapper
|
||||
@@ -70,8 +79,11 @@ namespace Sonarr.Api.V3.Queue
|
||||
CustomFormatScore = customFormatScore,
|
||||
Size = model.Size,
|
||||
Title = model.Title,
|
||||
Sizeleft = model.Sizeleft,
|
||||
Timeleft = model.Timeleft,
|
||||
|
||||
// Collides with existing properties due to case-insensitive deserialization
|
||||
// SizeLeft = model.SizeLeft,
|
||||
// TimeLeft = model.TimeLeft,
|
||||
|
||||
EstimatedCompletionTime = model.EstimatedCompletionTime,
|
||||
Added = model.Added,
|
||||
Status = model.Status,
|
||||
@@ -85,7 +97,12 @@ namespace Sonarr.Api.V3.Queue
|
||||
DownloadClientHasPostImportCategory = model.DownloadClientHasPostImportCategory,
|
||||
Indexer = model.Indexer,
|
||||
OutputPath = model.OutputPath,
|
||||
EpisodeHasFile = model.Episode?.HasFile ?? false
|
||||
EpisodeHasFile = model.Episode?.HasFile ?? false,
|
||||
|
||||
#pragma warning disable CS0618
|
||||
Sizeleft = model.SizeLeft,
|
||||
Timeleft = model.TimeLeft,
|
||||
#pragma warning restore CS0618
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
31
src/Sonarr.Api.V3/Series/SeriesFolderController.cs
Normal file
31
src/Sonarr.Api.V3/Series/SeriesFolderController.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NzbDrone.Core.Organizer;
|
||||
using NzbDrone.Core.Tv;
|
||||
using Sonarr.Http;
|
||||
|
||||
namespace Sonarr.Api.V3.Series;
|
||||
|
||||
[V3ApiController("series")]
|
||||
public class SeriesFolderController : Controller
|
||||
{
|
||||
private readonly ISeriesService _seriesService;
|
||||
private readonly IBuildFileNames _fileNameBuilder;
|
||||
|
||||
public SeriesFolderController(ISeriesService seriesService, IBuildFileNames fileNameBuilder)
|
||||
{
|
||||
_seriesService = seriesService;
|
||||
_fileNameBuilder = fileNameBuilder;
|
||||
}
|
||||
|
||||
[HttpGet("{id}/folder")]
|
||||
public object GetFolder([FromRoute] int id)
|
||||
{
|
||||
var series = _seriesService.GetSeries(id);
|
||||
var folder = _fileNameBuilder.GetSeriesFolder(series);
|
||||
|
||||
return new
|
||||
{
|
||||
folder
|
||||
};
|
||||
}
|
||||
}
|
||||
92
yarn.lock
92
yarn.lock
@@ -1223,6 +1223,18 @@
|
||||
dependencies:
|
||||
"@sentry/types" "7.119.1"
|
||||
|
||||
"@tanstack/query-core@5.60.6":
|
||||
version "5.60.6"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.60.6.tgz#0dd33fe231b0d18bf66d0c615b29899738300658"
|
||||
integrity sha512-tI+k0KyCo1EBJ54vxK1kY24LWj673ujTydCZmzEZKAew4NqZzTaVQJEuaG1qKj2M03kUHN46rchLRd+TxVq/zQ==
|
||||
|
||||
"@tanstack/react-query@5.61.0":
|
||||
version "5.61.0"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.61.0.tgz#73473feb37aa28ceb410e297ee060e18f06f88e0"
|
||||
integrity sha512-SBzV27XAeCRBOQ8QcC94w2H1Md0+LI0gTWwc3qRJoaGuewKn5FNW4LSqwPFJZVEItfhMfGT7RpZuSFXjTi12pQ==
|
||||
dependencies:
|
||||
"@tanstack/query-core" "5.60.6"
|
||||
|
||||
"@types/archiver@^5.3.1":
|
||||
version "5.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-5.3.4.tgz#32172d5a56f165b5b4ac902e366248bf03d3ae84"
|
||||
@@ -1335,10 +1347,10 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-dom@18.2.25":
|
||||
version "18.2.25"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.25.tgz#2946a30081f53e7c8d585eb138277245caedc521"
|
||||
integrity sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==
|
||||
"@types/react-dom@18.3.1":
|
||||
version "18.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.1.tgz#1e4654c08a9cdcfb6594c780ac59b55aad42fe07"
|
||||
integrity sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
@@ -1405,10 +1417,10 @@
|
||||
"@types/prop-types" "*"
|
||||
csstype "^3.0.2"
|
||||
|
||||
"@types/react@18.2.79":
|
||||
version "18.2.79"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.79.tgz#c40efb4f255711f554d47b449f796d1c7756d865"
|
||||
integrity sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==
|
||||
"@types/react@18.3.12":
|
||||
version "18.3.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.12.tgz#99419f182ccd69151813b7ee24b792fe08774f60"
|
||||
integrity sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==
|
||||
dependencies:
|
||||
"@types/prop-types" "*"
|
||||
csstype "^3.0.2"
|
||||
@@ -2259,7 +2271,7 @@ chrome-trace-event@^1.0.2:
|
||||
resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b"
|
||||
integrity sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==
|
||||
|
||||
classnames@2.5.1, classnames@^2.2.6:
|
||||
classnames@2.5.1:
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b"
|
||||
integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
|
||||
@@ -4108,11 +4120,6 @@ isexe@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
||||
|
||||
isnumeric@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/isnumeric/-/isnumeric-0.2.0.tgz#a2347ba360de19e33d0ffd590fddf7755cbf2e64"
|
||||
integrity sha512-uSJoAwnN1eCKDFKi8hL3UCYJSkQv+NwhKzhevUPIn/QZ8ILO21f+wQnlZHU0eh1rsLO1gI4w/HQdeOSTKwlqMg==
|
||||
|
||||
isobject@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
|
||||
@@ -4452,7 +4459,7 @@ lodash.upperfirst@4.3.1:
|
||||
resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce"
|
||||
integrity sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==
|
||||
|
||||
lodash@4.17.21, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21:
|
||||
lodash@4.17.21, lodash@^4.17.14, lodash@^4.17.20, lodash@^4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||
@@ -4775,7 +4782,7 @@ normalize-range@^0.1.2:
|
||||
resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
|
||||
integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==
|
||||
|
||||
normalize.css@8.0.1, normalize.css@^8.0.0:
|
||||
normalize.css@8.0.1:
|
||||
version "8.0.1"
|
||||
resolved "https://registry.yarnpkg.com/normalize.css/-/normalize.css-8.0.1.tgz#9b98a208738b9cc2634caacbc42d131c97487bf3"
|
||||
integrity sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==
|
||||
@@ -5427,14 +5434,13 @@ react-document-title@2.0.3:
|
||||
prop-types "^15.5.6"
|
||||
react-side-effect "^1.0.2"
|
||||
|
||||
react-dom@17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
|
||||
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
|
||||
react-dom@18.3.1:
|
||||
version "18.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4"
|
||||
integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
scheduler "^0.20.2"
|
||||
scheduler "^0.23.2"
|
||||
|
||||
react-focus-lock@2.9.4:
|
||||
version "2.9.4"
|
||||
@@ -5480,16 +5486,6 @@ react-measure@1.4.7:
|
||||
prop-types "^15.5.4"
|
||||
resize-observer-polyfill "^1.4.1"
|
||||
|
||||
react-middle-truncate@1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/react-middle-truncate/-/react-middle-truncate-1.0.3.tgz#42d198ad9738bc2d8f7b8e77e11e02107b856fe1"
|
||||
integrity sha512-rBYJjSYgAvNayDk+yZz8QhQqbGLjsSZV2CuGJ4g18o6BUGlMgZ4fIOGKuIEIZj17zCXzSw7mCGAcZ4lw0y8Lgw==
|
||||
dependencies:
|
||||
classnames "^2.2.6"
|
||||
lodash "^4.17.15"
|
||||
normalize.css "^8.0.0"
|
||||
units-css "^0.4.0"
|
||||
|
||||
react-popper@1.3.7:
|
||||
version "1.3.7"
|
||||
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-1.3.7.tgz#f6a3471362ef1f0d10a4963673789de1baca2324"
|
||||
@@ -5606,13 +5602,12 @@ react-window@1.8.10:
|
||||
"@babel/runtime" "^7.0.0"
|
||||
memoize-one ">=3.1.1 <6"
|
||||
|
||||
react@17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
|
||||
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
|
||||
react@18.3.1:
|
||||
version "18.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"
|
||||
integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
|
||||
read-pkg-up@^7.0.1:
|
||||
version "7.0.1"
|
||||
@@ -5979,13 +5974,12 @@ sax@~1.2.4:
|
||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
||||
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
|
||||
|
||||
scheduler@^0.20.2:
|
||||
version "0.20.2"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
|
||||
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
|
||||
scheduler@^0.23.2:
|
||||
version "0.23.2"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
|
||||
integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
|
||||
schema-utils@>1.0.0, schema-utils@^4.0.0:
|
||||
version "4.2.0"
|
||||
@@ -6207,6 +6201,7 @@ string-template@~0.2.1:
|
||||
integrity sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.3:
|
||||
name string-width-cjs
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -6761,14 +6756,6 @@ unicode-property-aliases-ecmascript@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd"
|
||||
integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==
|
||||
|
||||
units-css@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/units-css/-/units-css-0.4.0.tgz#d6228653a51983d7c16ff28f8b9dc3b1ffed3a07"
|
||||
integrity sha512-WijzYC+chwzg2D6HmNGUSzPAgFRJfuxVyG9oiY28Ei5E+g6fHoPkhXUr5GV+5hE/RTHZNd9SuX2KLioYHdttoA==
|
||||
dependencies:
|
||||
isnumeric "^0.2.0"
|
||||
viewport-dimensions "^0.2.0"
|
||||
|
||||
universalify@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0"
|
||||
@@ -6854,11 +6841,6 @@ value-equal@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c"
|
||||
integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==
|
||||
|
||||
viewport-dimensions@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/viewport-dimensions/-/viewport-dimensions-0.2.0.tgz#de740747db5387fd1725f5175e91bac76afdf36c"
|
||||
integrity sha512-94JqlKxEP4m7WO+N3rm4tFRGXZmXXwSPQCoV+EPxDnn8YAGiLU3T+Ha1imLreAjXsHl0K+ELnIqv64i1XZHLFQ==
|
||||
|
||||
warning@^4.0.2, warning@^4.0.3:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
|
||||
|
||||
Reference in New Issue
Block a user