1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-16 21:15:28 -04:00

Compare commits

...

15 Commits

Author SHA1 Message Date
Bogdan
bd656ae7f6 Fixed: Avoid default category on existing Transmission configurations
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2024-11-27 17:30:03 -08:00
Mark McDowall
62bcf397dd Fixed: Adding/Editing not replacing Implementation Name 2024-11-27 17:29:24 -08:00
Mark McDowall
f9606518ee Fixed: Error loading queue
Closes #7422
2024-11-27 17:29:24 -08:00
Mark McDowall
40f4ef27b2 Support Postgres with non-standard version string 2024-11-26 17:37:37 -08:00
Mark McDowall
93c3f6d1d6 Fixed: Truncating long text in the middle when it shouldn't be truncated
Closes #7413
2024-11-26 17:37:30 -08:00
Mark McDowall
417af2b915 New: Ability to change root folder when editing series
Closes #5544
2024-11-26 17:37:21 -08:00
Mark McDowall
4491df3ae7 Update React and add React Query 2024-11-26 17:37:21 -08:00
Mark McDowall
a90866a73e Webpack web target 2024-11-26 17:37:21 -08:00
Mark McDowall
2f62494adc Convert EditSeriesModal to TypeScript 2024-11-26 17:37:21 -08:00
Mark McDowall
e361f18837 New: Support for new SABnzbd history retention values
Closes #7373
2024-11-26 17:37:06 -08:00
Mark McDowall
183b8b574a Deluge communication improvements
Closes #7318
2024-11-26 17:36:53 -08:00
Mark McDowall
12c1eb86f2 Fixed: New episodes in season follow season's monitored status
Closes #7401
2024-11-26 17:36:26 -08:00
Mark McDowall
5034d83062 Fixed: Kometa and Kodi metadata failing with duplicate episode files
Closes #7381
2024-11-26 17:36:10 -08:00
Mark McDowall
dba3a82439 Fixed: Prevent lack of internet from stopping all health checks from running 2024-11-26 17:36:00 -08:00
Mark McDowall
b51a490979 Rename SizeLeft and TimeLeft queue item properties
Closes #7392
2024-11-26 17:36:00 -08:00
67 changed files with 1380 additions and 617 deletions

View File

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

View File

@@ -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>
);
}

View File

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

View File

@@ -59,6 +59,8 @@ interface SeriesAppState
deleteOptions: {
addImportListExclusion: boolean;
};
pendingChanges: Partial<Series>;
}
export default SeriesAppState;

View File

@@ -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}
/>
);

View File

@@ -145,6 +145,7 @@ interface FormInputGroupProps<T> {
autoFocus?: boolean;
includeNoChange?: boolean;
includeNoChangeDisabled?: boolean;
valueOptions?: object;
selectedValueOptions?: object;
indexerFlags?: number;
pending?: boolean;

View File

@@ -16,3 +16,7 @@
height: 35px;
}
.fileBrowserMiddleButton {
composes: middleButton from '~./FormInputButton.css';
}

View File

@@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'fileBrowserButton': string;
'fileBrowserMiddleButton': string;
'hasFileBrowser': string;
'inputWrapper': string;
'pathMatch': string;

View File

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

View File

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

View File

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

View 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;

View 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;

View File

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

View File

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

View File

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

View 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;

View File

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

View 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;

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
};
}

View File

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

View File

@@ -4,7 +4,7 @@
}
.addReleaseProfile {
composes: releaseProfile from '~./ReleaseProfileRow.css';
composes: releaseProfile from '~./ReleaseProfileItem.css';
background-color: var(--cardAlternateBackgroundColor);
color: var(--gray);

View File

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

View File

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

View 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;

View File

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

View File

@@ -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} />);
}

View File

@@ -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();

View File

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

View File

@@ -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>[];
};

View File

@@ -3,6 +3,7 @@ declare module '*.module.css';
interface Window {
Sonarr: {
apiKey: string;
apiRoot: string;
instanceName: string;
theme: string;
urlBase: string;

View File

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

View File

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

View File

@@ -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);
}
}
}

View File

@@ -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")]

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View 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;
}
}

View File

@@ -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"))
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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());

View File

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

View File

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

View File

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

View File

@@ -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();
}
}
}

View File

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

View 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();
}
}
}

View 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)
{
}
}
}

View File

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

View File

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

View File

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

View 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
};
}
}

View File

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