Compare commits

...

32 Commits

Author SHA1 Message Date
Bogdan 2f04b037a1 Fixed nlog deprecated calls 2024-08-11 09:08:38 -07:00
Bogdan 7b87de2e93
Clear pending changes for edit import list exclusions on modal close 2024-08-11 11:53:17 -04:00
Bogdan eb2fd13509
Fixed: Overwriting query params for remove item handler (#7075) 2024-08-11 11:51:11 -04:00
Bogdan ffdb08cfe6 Fixed: Dedupe titles to avoid similar search requests 2024-08-11 08:49:22 -07:00
Mark McDowall 37c4647f24 Fix typos and improve log messages 2024-08-11 08:48:33 -07:00
Mark McDowall f7a58aab33 Align queue action buttons on right 2024-08-11 08:48:33 -07:00
Mark McDowall 4b186e894e
Fixed: Marking queued item as failed not blocking the correct Torrent Info Hash 2024-08-11 11:48:22 -04:00
kephasdev 35a2bc9403
Fix: Use indexer's Multi Languages setting for pushed releases
Closes #7059
2024-08-11 11:47:59 -04:00
Bogdan cc03ce04f1 Fixed: Formatting empty size on disk values 2024-08-11 08:46:56 -07:00
Bogdan 363f8fc347
New: Match search releases using IMDb ID if available 2024-08-11 11:46:46 -04:00
RaZaSB 0877a6718d
New: Remove all single quote characters from searches 2024-08-11 11:46:02 -04:00
Bogdan 8b253c36ea
Validation for bulk series editor 2024-08-11 11:45:15 -04:00
Bogdan e6f82270a9
Parse TVDB ID for releases from HDBits
ignore-downstream
2024-08-11 11:45:00 -04:00
Mark McDowall 813965e6a2 New: Configurable log file size limit 2024-08-11 08:44:35 -07:00
Mark McDowall 0d914f4c53 New: Add Compact Log Event Format option for console logging
Closes #7045
2024-08-11 08:44:35 -07:00
Mark McDowall ae7f73208a Upgrade nlog to 5.3.2 2024-08-11 08:44:35 -07:00
Weblate 4c86d673ea Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ano10 <arnaudthommeray+github@ik.me>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-08-11 08:44:27 -07:00
Weblate b1527f9abb Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: iMohmmedSA <i.mohmmed.i+1@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-07-31 22:26:09 -07:00
Bogdan 291d792810
Fixed: Moving files on import for usenet clients
Closes #7043
2024-08-01 01:17:10 -04:00
Mark McDowall 9b528eb829
New: Default file log level changed to debug 2024-08-01 01:16:24 -04:00
Mark McDowall 4c0b896174 Improve messaging for for Send Notifications setting in Emby / Jellyfin
Closes #7042
2024-07-31 22:16:01 -07:00
Bogdan 4ff83f9efc
Fixed: Persist Indexer Flags for automatic imports
Revert "Fixed: Persist Indexer Flags when manual importing from queue"

This reverts commit 217611d716.
2024-08-01 01:15:36 -04:00
Bogdan 217611d716
Fixed: Persist Indexer Flags when manual importing from queue 2024-07-31 00:28:01 -04:00
Mark McDowall 1299a97579 Update React Lint rules for TSX 2024-07-30 21:27:33 -07:00
Mark McDowall 4c0de55672 Fixed: Setting page size in Queue, History and Blocklist
Closes #7035
2024-07-30 21:27:33 -07:00
Bogdan 78a0def46a
Fixed: Moving files for torrents when Remove Completed is disabled 2024-07-31 00:27:19 -04:00
Mark McDowall 11a9dcb389
New: Return downloading magnets from Transmission
Closes #7029
2024-07-31 00:26:24 -04:00
Mark McDowall 4eab168267
New: Add metadata links to telegram messages
Closes #5342
---------

Co-authored-by: Ivar Stangeby <istangeby@gmail.com>
2024-07-31 00:25:48 -04:00
Bogdan c9b5a1258a New: Title filter for Series Index 2024-07-30 21:25:10 -07:00
Mark McDowall 9127a91dfc Fixed: Allow leading/trailing spaces on non-Windows
Closes #6971
2024-07-30 21:25:00 -07:00
Weblate cc85a28ff7 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Wolfy The Broccoly <theproviderofsolace@gmail.com>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-07-30 21:24:50 -07:00
Mark McDowall 72db8099e0 Convert System to TypeScript 2024-07-28 17:47:08 -07:00
165 changed files with 1917 additions and 1796 deletions

View File

@ -59,7 +59,7 @@ app_guid=$(echo "$app_guid" | tr -d ' ')
app_guid=${app_guid:-media}
echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory"
echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that that user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories"
echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that the selected user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories"
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
# Create User / Group as needed
@ -114,7 +114,7 @@ case "$ARCH" in
esac
echo ""
echo "Removing previous tarballs"
# -f to Force so we fail if it doesnt exist
# -f to Force so we fail if it doesn't exist
rm -f "${app^}".*.tar.gz
echo ""
echo "Downloading..."

View File

@ -359,11 +359,16 @@ module.exports = {
],
rules: Object.assign(typescriptEslintRecommended.rules, {
'no-shadow': 'off',
// These should be enabled after cleaning things up
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{
args: 'after-used',
argsIgnorePattern: '^_',
ignoreRestSiblings: true
}
],
'@typescript-eslint/explicit-function-return-type': 'off',
'react/prop-types': 'off',
'no-shadow': 'off',
'prettier/prettier': 'error',
'simple-import-sort/imports': [
'error',
@ -376,7 +381,41 @@ module.exports = {
['^@?\\w', `^(${dirs})(/.*|$)`, '^\\.', '^\\..*css$']
]
}
]
],
// React Hooks
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
// React
'react/function-component-definition': 'error',
'react/hook-use-state': 'error',
'react/jsx-boolean-value': ['error', 'always'],
'react/jsx-curly-brace-presence': [
'error',
{ props: 'never', children: 'never' }
],
'react/jsx-fragments': 'error',
'react/jsx-handler-names': [
'error',
{
eventHandlerPrefix: 'on',
eventHandlerPropPrefix: 'on'
}
],
'react/jsx-no-bind': ['error', { ignoreRefs: true }],
'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }],
'react/jsx-pascal-case': ['error', { allowAllCaps: true }],
'react/jsx-sort-props': [
'error',
{
callbacksLast: true,
noSortAlphabetically: true,
reservedFirst: true
}
],
'react/prop-types': 'off',
'react/self-closing-comp': 'error'
})
},
{

View File

@ -59,6 +59,7 @@ function Blocklist() {
sortKey,
sortDirection,
page,
pageSize,
totalPages,
totalRecords,
isRemoving,
@ -223,6 +224,7 @@ function Blocklist() {
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
columns={columns}
pageSize={pageSize}
onTableOptionChange={handleTableOptionChange}
>
<PageToolbarButton
@ -264,6 +266,7 @@ function Blocklist() {
allSelected={allSelected}
allUnselected={allUnselected}
columns={columns}
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
onTableOptionChange={handleTableOptionChange}

View File

@ -53,6 +53,7 @@ function History() {
sortKey,
sortDirection,
page,
pageSize,
totalPages,
totalRecords,
} = useSelector((state: AppState) => state.history);
@ -154,6 +155,7 @@ function History() {
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
columns={columns}
pageSize={pageSize}
onTableOptionChange={handleTableOptionChange}
>
<PageToolbarButton
@ -193,6 +195,7 @@ function History() {
<div>
<Table
columns={columns}
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
onTableOptionChange={handleTableOptionChange}

View File

@ -73,6 +73,7 @@ function Queue() {
sortKey,
sortDirection,
page,
pageSize,
totalPages,
totalRecords,
isGrabbing,
@ -269,8 +270,10 @@ function Queue() {
allSelected={allSelected}
allUnselected={allUnselected}
columns={columns}
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
optionsComponent={QueueOptions}
onTableOptionChange={handleTableOptionChange}
onSelectAllChange={handleSelectAllChange}
onSortPress={handleSortPress}
@ -344,6 +347,7 @@ function Queue() {
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
columns={columns}
pageSize={pageSize}
maxPageSize={200}
optionsComponent={QueueOptions}
onTableOptionChange={handleTableOptionChange}

View File

@ -1,11 +1,11 @@
import React, { Fragment, useCallback } from 'react';
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { inputTypes } from 'Helpers/Props';
import { setQueueOption } from 'Store/Actions/queueActions';
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
import { CheckInputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
@ -22,24 +22,26 @@ function QueueOptions() {
[name]: value,
})
);
if (name === 'includeUnknownSeriesItems') {
dispatch(gotoQueuePage({ page: 1 }));
}
},
[dispatch]
);
return (
<Fragment>
<FormGroup>
<FormLabel>{translate('ShowUnknownSeriesItems')}</FormLabel>
<FormGroup>
<FormLabel>{translate('ShowUnknownSeriesItems')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeUnknownSeriesItems"
value={includeUnknownSeriesItems}
helpText={translate('ShowUnknownSeriesItemsHelpText')}
onChange={handleOptionChange}
/>
</FormGroup>
</Fragment>
<FormInputGroup
type={inputTypes.CHECK}
name="includeUnknownSeriesItems"
value={includeUnknownSeriesItems}
helpText={translate('ShowUnknownSeriesItemsHelpText')}
onChange={handleOptionChange}
/>
</FormGroup>
);
}

View File

@ -26,4 +26,5 @@
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 70px;
text-align: right;
}

View File

@ -35,6 +35,10 @@ import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
import MissingConnector from 'Wanted/Missing/MissingConnector';
function RedirectWithUrlBase() {
return <Redirect to={getPathWithUrlBase('/')} />;
}
function AppRoutes() {
return (
<Switch>
@ -51,9 +55,7 @@ function AppRoutes() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
addUrlBase={false}
render={() => {
return <Redirect to={getPathWithUrlBase('/')} />;
}}
render={RedirectWithUrlBase}
/>
)}
@ -61,21 +63,9 @@ function AppRoutes() {
<Route path="/add/import" component={ImportSeries} />
<Route
path="/serieseditor"
exact={true}
render={() => {
return <Redirect to={getPathWithUrlBase('/')} />;
}}
/>
<Route path="/serieseditor" exact={true} render={RedirectWithUrlBase} />
<Route
path="/seasonpass"
exact={true}
render={() => {
return <Redirect to={getPathWithUrlBase('/')} />;
}}
/>
<Route path="/seasonpass" exact={true} render={RedirectWithUrlBase} />
<Route path="/series/:titleSlug" component={SeriesDetailsPageConnector} />

View File

@ -20,7 +20,9 @@ import UiSettings from 'typings/Settings/UiSettings';
export interface DownloadClientAppState
extends AppSectionState<DownloadClient>,
AppSectionDeleteState,
AppSectionSaveState {}
AppSectionSaveState {
isTestingAll: boolean;
}
export type GeneralAppState = AppSectionItemState<General>;
@ -32,7 +34,9 @@ export interface ImportListAppState
export interface IndexerAppState
extends AppSectionState<Indexer>,
AppSectionDeleteState,
AppSectionSaveState {}
AppSectionSaveState {
isTestingAll: boolean;
}
export interface NotificationAppState
extends AppSectionState<Notification>,

View File

@ -1,13 +1,22 @@
import DiskSpace from 'typings/DiskSpace';
import Health from 'typings/Health';
import SystemStatus from 'typings/SystemStatus';
import Task from 'typings/Task';
import Update from 'typings/Update';
import AppSectionState, { AppSectionItemState } from './AppSectionState';
export type DiskSpaceAppState = AppSectionState<DiskSpace>;
export type HealthAppState = AppSectionState<Health>;
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
export type UpdateAppState = AppSectionState<Update>;
export type TaskAppState = AppSectionState<Task>;
interface SystemAppState {
diskSpace: DiskSpaceAppState;
health: HealthAppState;
updates: UpdateAppState;
status: SystemStatusAppState;
tasks: TaskAppState;
}
export default SystemAppState;

View File

@ -64,7 +64,7 @@ function ErrorBoundaryError(props: ErrorBoundaryErrorProps) {
<div>{info.componentStack}</div>
)}
{<div className={styles.version}>Version: {window.Sonarr.version}</div>}
<div className={styles.version}>Version: {window.Sonarr.version}</div>
</details>
</div>
);

View File

@ -9,7 +9,7 @@ import Scroller from 'Components/Scroller/Scroller';
import { icons } from 'Helpers/Props';
import locationShape from 'Helpers/Props/Shapes/locationShape';
import dimensions from 'Styles/Variables/dimensions';
import HealthStatusConnector from 'System/Status/Health/HealthStatusConnector';
import HealthStatus from 'System/Status/Health/HealthStatus';
import translate from 'Utilities/String/translate';
import MessagesConnector from './Messages/MessagesConnector';
import PageSidebarItem from './PageSidebarItem';
@ -147,7 +147,7 @@ const links = [
{
title: () => translate('Status'),
to: '/system/status',
statusComponent: HealthStatusConnector
statusComponent: HealthStatus
},
{
title: () => translate('Tasks'),

View File

@ -6,6 +6,7 @@ type PropertyFunction<T> = () => T;
interface Column {
name: string;
label: string | PropertyFunction<string> | React.ReactNode;
className?: string;
columnLabel?: string;
isSortable?: boolean;
isVisible: boolean;

View File

@ -1,4 +1,4 @@
import React, { Fragment } from 'react';
import React from 'react';
import Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
@ -82,9 +82,7 @@ function EpisodeNumber(props: EpisodeNumberProps) {
<Popover
anchor={
<span>
{showSeasonNumber && seasonNumber != null && (
<Fragment>{seasonNumber}x</Fragment>
)}
{showSeasonNumber && seasonNumber != null && <>{seasonNumber}x</>}
{showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber}
@ -111,9 +109,7 @@ function EpisodeNumber(props: EpisodeNumberProps) {
/>
) : (
<span>
{showSeasonNumber && seasonNumber != null && (
<Fragment>{seasonNumber}x</Fragment>
)}
{showSeasonNumber && seasonNumber != null && <>{seasonNumber}x</>}
{showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber}

View File

@ -3,15 +3,15 @@ import { useCallback, useState } from 'react';
export default function useModalOpenState(
initialState: boolean
): [boolean, () => void, () => void] {
const [isOpen, setOpen] = useState(initialState);
const [isOpen, setIsOpen] = useState(initialState);
const setModalOpen = useCallback(() => {
setOpen(true);
}, [setOpen]);
setIsOpen(true);
}, [setIsOpen]);
const setModalClosed = useCallback(() => {
setOpen(false);
}, [setOpen]);
setIsOpen(false);
}, [setIsOpen]);
return [isOpen, setModalOpen, setModalClosed];
}

View File

@ -857,7 +857,7 @@ function InteractiveImportModalContent(
<MenuContent>
<SelectedMenuItem
name={'all'}
name="all"
isSelected={!filterExistingFiles}
onPress={onFilterExistingFilesChange}
>
@ -865,7 +865,7 @@ function InteractiveImportModalContent(
</SelectedMenuItem>
<SelectedMenuItem
name={'new'}
name="new"
isSelected={filterExistingFiles}
onPress={onFilterExistingFilesChange}
>
@ -945,7 +945,7 @@ function InteractiveImportModalContent(
<SelectInput
className={styles.bulkSelect}
name="select"
value={'select'}
value="select"
values={bulkSelectOptions}
isDisabled={!selectedIds.length}
onChange={onSelectModalSelect}

View File

@ -17,7 +17,7 @@ function SelectLanguageModal(props: SelectLanguageModalProps) {
props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose} size={sizes.MEDIUM}>
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={onModalClose}>
<SelectLanguageModalContent
languageIds={languageIds}
modalTitle={modalTitle}

View File

@ -64,19 +64,20 @@ interface RowItemData {
onSeriesSelect(seriesId: number): void;
}
const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
index,
style,
data,
}) => {
function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
const { items, columns, onSeriesSelect } = data;
const series = index >= items.length ? null : items[index];
if (index >= items.length) {
const handlePress = useCallback(() => {
if (series?.id) {
onSeriesSelect(series.id);
}
}, [series?.id, onSeriesSelect]);
if (series == null) {
return null;
}
const series = items[index];
return (
<VirtualTableRowButton
style={{
@ -84,7 +85,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
justifyContent: 'space-between',
...style,
}}
onPress={() => onSeriesSelect(series.id)}
onPress={handlePress}
>
<SelectSeriesRow
key={series.id}
@ -98,7 +99,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
/>
</VirtualTableRowButton>
);
};
}
function SelectSeriesModalContent(props: SelectSeriesModalContentProps) {
const { modalTitle, onSeriesSelect, onModalClose } = props;
@ -197,9 +198,9 @@ function SelectSeriesModalContent(props: SelectSeriesModalContentProps) {
/>
<Scroller
ref={scrollerRef}
className={styles.scroller}
autoFocus={false}
ref={scrollerRef}
>
<SelectSeriesModalTableHeader columns={columns} />
<List<RowItemData>

View File

@ -17,7 +17,7 @@ function SelectDownloadClientModal(props: SelectDownloadClientModalProps) {
props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose} size={sizes.MEDIUM}>
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={onModalClose}>
<SelectDownloadClientModalContent
protocol={protocol}
modalTitle={modalTitle}

View File

@ -1,4 +1,4 @@
import React, { Fragment, useCallback, useState } from 'react';
import React, { useCallback, useState } from 'react';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import { icons } from 'Helpers/Props';
import ParseModal from 'Parse/ParseModal';
@ -16,7 +16,7 @@ function ParseToolbarButton() {
}, [setIsParseModalOpen]);
return (
<Fragment>
<>
<PageToolbarButton
label={translate('TestParsing')}
iconName={icons.PARSE}
@ -24,7 +24,7 @@ function ParseToolbarButton() {
/>
<ParseModal isOpen={isParseModalOpen} onModalClose={onParseModalClose} />
</Fragment>
</>
);
}

View File

@ -21,7 +21,7 @@ interface RootFolderRowProps {
}
function RootFolderRow(props: RootFolderRowProps) {
const { id, path, accessible, freeSpace, unmappedFolders = [] } = props;
const { id, path, accessible, freeSpace = 0, unmappedFolders = [] } = props;
const isUnavailable = !accessible;

View File

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
@ -6,14 +5,19 @@ import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import styles from './SeasonInfo.css';
function SeasonInfo(props) {
const {
totalEpisodeCount,
monitoredEpisodeCount,
episodeFileCount,
sizeOnDisk
} = props;
interface SeasonInfoProps {
totalEpisodeCount: number;
monitoredEpisodeCount: number;
episodeFileCount: number;
sizeOnDisk: number;
}
function SeasonInfo({
totalEpisodeCount,
monitoredEpisodeCount,
episodeFileCount,
sizeOnDisk,
}: SeasonInfoProps) {
return (
<DescriptionList>
<DescriptionListItem
@ -47,11 +51,4 @@ function SeasonInfo(props) {
);
}
SeasonInfo.propTypes = {
totalEpisodeCount: PropTypes.number.isRequired,
monitoredEpisodeCount: PropTypes.number.isRequired,
episodeFileCount: PropTypes.number.isRequired,
sizeOnDisk: PropTypes.number.isRequired
};
export default SeasonInfo;

View File

@ -212,8 +212,8 @@ class SeriesDetails extends Component {
} = this.props;
const {
episodeFileCount,
sizeOnDisk
episodeFileCount = 0,
sizeOnDisk = 0
} = statistics;
const {
@ -454,10 +454,9 @@ class SeriesDetails extends Component {
name={icons.DRIVE}
size={17}
/>
<span className={styles.sizeOnDisk}>
{
formatBytes(sizeOnDisk || 0)
}
{formatBytes(sizeOnDisk)}
</span>
</div>
</Label>

View File

@ -194,10 +194,12 @@ function getInfoRowProps(
}
if (name === 'sizeOnDisk') {
const { sizeOnDisk = 0 } = props;
return {
title: translate('SizeOnDisk'),
iconName: icons.DRIVE,
label: formatBytes(props.sizeOnDisk),
label: formatBytes(sizeOnDisk),
};
}

View File

@ -42,11 +42,7 @@ interface SeriesIndexOverviewsProps {
isSmallScreen: boolean;
}
const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
index,
style,
data,
}) => {
function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
const { items, ...otherData } = data;
if (index >= items.length) {
@ -60,7 +56,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
<SeriesIndexOverview seriesId={series.id} {...otherData} />
</div>
);
};
}
function getWindowScrollTopPosition() {
return document.documentElement.scrollTop || document.body.scrollTop || 0;

View File

@ -37,7 +37,7 @@ function SeriesIndexPosterInfo(props: SeriesIndexPosterInfoProps) {
added,
seasonCount,
path,
sizeOnDisk,
sizeOnDisk = 0,
tags,
sortKey,
showRelativeDates,

View File

@ -60,12 +60,12 @@ const seriesIndexSelector = createSelector(
}
);
const Cell: React.FC<GridChildComponentProps<CellItemData>> = ({
function Cell({
columnIndex,
rowIndex,
style,
data,
}) => {
}: GridChildComponentProps<CellItemData>) {
const { layout, items, sortKey, isSelectMode } = data;
const { columnCount, padding, posterWidth, posterHeight } = layout;
const index = rowIndex * columnCount + columnIndex;
@ -92,7 +92,7 @@ const Cell: React.FC<GridChildComponentProps<CellItemData>> = ({
/>
</div>
);
};
}
function getWindowScrollTopPosition() {
return document.documentElement.scrollTop || document.body.scrollTop || 0;

View File

@ -45,11 +45,7 @@ const columnsSelector = createSelector(
(columns) => columns
);
const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
index,
style,
data,
}) => {
function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
const { items, sortKey, columns, isSelectMode } = data;
if (index >= items.length) {
@ -75,7 +71,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
/>
</div>
);
};
}
function getWindowScrollTopPosition() {
return document.documentElement.scrollTop || document.body.scrollTop || 0;

View File

@ -1,4 +1,4 @@
import React, { Fragment, useCallback } from 'react';
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
@ -32,7 +32,7 @@ function SeriesIndexTableOptions(props: SeriesIndexTableOptionsProps) {
);
return (
<Fragment>
<>
<FormGroup>
<FormLabel>{translate('ShowBanners')}</FormLabel>
@ -56,7 +56,7 @@ function SeriesIndexTableOptions(props: SeriesIndexTableOptionsProps) {
onChange={onTableOptionChangeWrapper}
/>
</FormGroup>
</Fragment>
</>
);
}

View File

@ -1,4 +1,4 @@
import React, { Fragment } from 'react';
import React from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import PageContent from 'Components/Page/PageContent';
@ -17,11 +17,11 @@ function CustomFormatSettingsPage() {
// @ts-ignore
showSave={false}
additionalButtons={
<Fragment>
<>
<PageToolbarSeparator />
<ParseToolbarButton />
</Fragment>
</>
}
/>

View File

@ -231,9 +231,9 @@ function ManageDownloadClientsModalContent(
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
sortKey={sortKey}
sortDirection={sortDirection}
onSelectAllChange={onSelectAllChange}
onSortPress={onSortPress}
>
<TableBody>
@ -286,9 +286,9 @@ function ManageDownloadClientsModalContent(
<ManageDownloadClientsEditModal
isOpen={isEditModalOpen}
downloadClientIds={selectedIds}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
downloadClientIds={selectedIds}
/>
<TagsModal

View File

@ -32,7 +32,7 @@ function EditImportListExclusionModal(
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={onModalClosePress}>
<EditImportListExclusionModalContent
{...otherProps}
onModalClose={onModalClose}
onModalClose={onModalClosePress}
/>
</Modal>
);

View File

@ -261,9 +261,9 @@ function ManageImportListsModalContent(
<ManageImportListsEditModal
isOpen={isEditModalOpen}
importListIds={selectedIds}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
importListIds={selectedIds}
/>
<TagsModal

View File

@ -226,9 +226,9 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
sortKey={sortKey}
sortDirection={sortDirection}
onSelectAllChange={onSelectAllChange}
onSortPress={onSortPress}
>
<TableBody>
@ -281,9 +281,9 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
<ManageIndexersEditModal
isOpen={isEditModalOpen}
indexerIds={selectedIds}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
indexerIds={selectedIds}
/>
<TagsModal

View File

@ -7,7 +7,7 @@ function createRemoveItemHandler(section, url) {
return function(getState, payload, dispatch) {
const {
id,
...queryParams
queryParams
} = payload;
dispatch(set({ section, isDeleting: true }));

View File

@ -251,6 +251,11 @@ export const filterBuilderProps = [
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.SERIES_TYPES
},
{
name: 'title',
label: () => translate('Title'),
type: filterBuilderTypes.STRING
},
{
name: 'network',
label: () => translate('Network'),

View File

@ -1,135 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import FieldSet from 'Components/FieldSet';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import StartTime from './StartTime';
import styles from './About.css';
class About extends Component {
//
// Render
render() {
const {
version,
packageVersion,
packageAuthor,
isNetCore,
isDocker,
runtimeVersion,
databaseVersion,
databaseType,
migrationVersion,
appData,
startupPath,
mode,
startTime,
timeFormat,
longDateFormat
} = this.props;
return (
<FieldSet legend={translate('About')}>
<DescriptionList className={styles.descriptionList}>
<DescriptionListItem
title={translate('Version')}
data={version}
/>
{
packageVersion &&
<DescriptionListItem
title={translate('PackageVersion')}
data={(packageAuthor ?
<InlineMarkdown data={translate('PackageVersionInfo', {
packageVersion,
packageAuthor
})}
/> :
packageVersion
)}
/>
}
{
isNetCore &&
<DescriptionListItem
title={translate('DotNetVersion')}
data={`Yes (${runtimeVersion})`}
/>
}
{
isDocker &&
<DescriptionListItem
title={translate('Docker')}
data={'Yes'}
/>
}
<DescriptionListItem
title={translate('Database')}
data={`${titleCase(databaseType)} ${databaseVersion}`}
/>
<DescriptionListItem
title={translate('DatabaseMigration')}
data={migrationVersion}
/>
<DescriptionListItem
title={translate('AppDataDirectory')}
data={appData}
/>
<DescriptionListItem
title={translate('StartupDirectory')}
data={startupPath}
/>
<DescriptionListItem
title={translate('Mode')}
data={titleCase(mode)}
/>
<DescriptionListItem
title={translate('Uptime')}
data={
<StartTime
startTime={startTime}
timeFormat={timeFormat}
longDateFormat={longDateFormat}
/>
}
/>
</DescriptionList>
</FieldSet>
);
}
}
About.propTypes = {
version: PropTypes.string.isRequired,
packageVersion: PropTypes.string,
packageAuthor: PropTypes.string,
isNetCore: PropTypes.bool.isRequired,
runtimeVersion: PropTypes.string.isRequired,
isDocker: PropTypes.bool.isRequired,
databaseType: PropTypes.string.isRequired,
databaseVersion: PropTypes.string.isRequired,
migrationVersion: PropTypes.number.isRequired,
appData: PropTypes.string.isRequired,
startupPath: PropTypes.string.isRequired,
mode: PropTypes.string.isRequired,
startTime: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired
};
export default About;

View File

@ -0,0 +1,103 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import FieldSet from 'Components/FieldSet';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import { fetchStatus } from 'Store/Actions/systemActions';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import StartTime from './StartTime';
import styles from './About.css';
function About() {
const dispatch = useDispatch();
const { item } = useSelector((state: AppState) => state.system.status);
const {
version,
packageVersion,
packageAuthor,
isNetCore,
isDocker,
runtimeVersion,
databaseVersion,
databaseType,
migrationVersion,
appData,
startupPath,
mode,
startTime,
} = item;
useEffect(() => {
dispatch(fetchStatus());
}, [dispatch]);
return (
<FieldSet legend={translate('About')}>
<DescriptionList className={styles.descriptionList}>
<DescriptionListItem title={translate('Version')} data={version} />
{packageVersion && (
<DescriptionListItem
title={translate('PackageVersion')}
data={
packageAuthor ? (
<InlineMarkdown
data={translate('PackageVersionInfo', {
packageVersion,
packageAuthor,
})}
/>
) : (
packageVersion
)
}
/>
)}
{isNetCore ? (
<DescriptionListItem
title={translate('DotNetVersion')}
data={`Yes (${runtimeVersion})`}
/>
) : null}
{isDocker ? (
<DescriptionListItem title={translate('Docker')} data="Yes" />
) : null}
<DescriptionListItem
title={translate('Database')}
data={`${titleCase(databaseType)} ${databaseVersion}`}
/>
<DescriptionListItem
title={translate('DatabaseMigration')}
data={migrationVersion}
/>
<DescriptionListItem
title={translate('AppDataDirectory')}
data={appData}
/>
<DescriptionListItem
title={translate('StartupDirectory')}
data={startupPath}
/>
<DescriptionListItem title={translate('Mode')} data={titleCase(mode)} />
<DescriptionListItem
title={translate('Uptime')}
data={<StartTime startTime={startTime} />}
/>
</DescriptionList>
</FieldSet>
);
}
export default About;

View File

@ -1,52 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchStatus } from 'Store/Actions/systemActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import About from './About';
function createMapStateToProps() {
return createSelector(
(state) => state.system.status,
createUISettingsSelector(),
(status, uiSettings) => {
return {
...status.item,
timeFormat: uiSettings.timeFormat,
longDateFormat: uiSettings.longDateFormat
};
}
);
}
const mapDispatchToProps = {
fetchStatus
};
class AboutConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchStatus();
}
//
// Render
render() {
return (
<About
{...this.props}
/>
);
}
}
AboutConnector.propTypes = {
fetchStatus: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AboutConnector);

View File

@ -1,93 +0,0 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
function getUptime(startTime) {
return formatTimeSpan(moment().diff(startTime));
}
class StartTime extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
startTime,
timeFormat,
longDateFormat
} = props;
this._timeoutId = null;
this.state = {
uptime: getUptime(startTime),
startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true })
};
}
componentDidMount() {
this._timeoutId = setTimeout(this.onTimeout, 1000);
}
componentDidUpdate(prevProps) {
const {
startTime,
timeFormat,
longDateFormat
} = this.props;
if (
startTime !== prevProps.startTime ||
timeFormat !== prevProps.timeFormat ||
longDateFormat !== prevProps.longDateFormat
) {
this.setState({
uptime: getUptime(startTime),
startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true })
});
}
}
componentWillUnmount() {
if (this._timeoutId) {
this._timeoutId = clearTimeout(this._timeoutId);
}
}
//
// Listeners
onTimeout = () => {
this.setState({ uptime: getUptime(this.props.startTime) });
this._timeoutId = setTimeout(this.onTimeout, 1000);
};
//
// Render
render() {
const {
uptime,
startTime
} = this.state;
return (
<span title={startTime}>
{uptime}
</span>
);
}
}
StartTime.propTypes = {
startTime: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired
};
export default StartTime;

View File

@ -0,0 +1,44 @@
import moment from 'moment';
import React, { useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
interface StartTimeProps {
startTime: string;
}
function StartTime(props: StartTimeProps) {
const { startTime } = props;
const { timeFormat, longDateFormat } = useSelector(
createUISettingsSelector()
);
const [time, setTime] = useState(Date.now());
const { formattedStartTime, uptime } = useMemo(() => {
return {
uptime: formatTimeSpan(moment(time).diff(startTime)),
formattedStartTime: formatDateTime(
startTime,
longDateFormat,
timeFormat,
{
includeSeconds: true,
}
),
};
}, [startTime, time, longDateFormat, timeFormat]);
useEffect(() => {
const interval = setInterval(() => setTime(Date.now()), 1000);
return () => {
clearInterval(interval);
};
}, [setTime]);
return <span title={formattedStartTime}>{uptime}</span>;
}
export default StartTime;

View File

@ -1,121 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FieldSet from 'Components/FieldSet';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ProgressBar from 'Components/ProgressBar';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableRow from 'Components/Table/TableRow';
import { kinds, sizes } from 'Helpers/Props';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import styles from './DiskSpace.css';
const columns = [
{
name: 'path',
label: () => translate('Location'),
isVisible: true
},
{
name: 'freeSpace',
label: () => translate('FreeSpace'),
isVisible: true
},
{
name: 'totalSpace',
label: () => translate('TotalSpace'),
isVisible: true
},
{
name: 'progress',
isVisible: true
}
];
class DiskSpace extends Component {
//
// Render
render() {
const {
isFetching,
items
} = this.props;
return (
<FieldSet legend={translate('DiskSpace')}>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching &&
<Table
columns={columns}
>
<TableBody>
{
items.map((item) => {
const {
freeSpace,
totalSpace
} = item;
const diskUsage = (100 - freeSpace / totalSpace * 100);
let diskUsageKind = kinds.PRIMARY;
if (diskUsage > 90) {
diskUsageKind = kinds.DANGER;
} else if (diskUsage > 80) {
diskUsageKind = kinds.WARNING;
}
return (
<TableRow key={item.path}>
<TableRowCell>
{item.path}
{
item.label &&
` (${item.label})`
}
</TableRowCell>
<TableRowCell className={styles.space}>
{formatBytes(freeSpace)}
</TableRowCell>
<TableRowCell className={styles.space}>
{formatBytes(totalSpace)}
</TableRowCell>
<TableRowCell className={styles.space}>
<ProgressBar
progress={diskUsage}
kind={diskUsageKind}
size={sizes.MEDIUM}
/>
</TableRowCell>
</TableRow>
);
})
}
</TableBody>
</Table>
}
</FieldSet>
);
}
}
DiskSpace.propTypes = {
isFetching: PropTypes.bool.isRequired,
items: PropTypes.array.isRequired
};
export default DiskSpace;

View File

@ -0,0 +1,111 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FieldSet from 'Components/FieldSet';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ProgressBar from 'Components/ProgressBar';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableRow from 'Components/Table/TableRow';
import { kinds, sizes } from 'Helpers/Props';
import { fetchDiskSpace } from 'Store/Actions/systemActions';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import styles from './DiskSpace.css';
const columns: Column[] = [
{
name: 'path',
label: () => translate('Location'),
isVisible: true,
},
{
name: 'freeSpace',
label: () => translate('FreeSpace'),
isVisible: true,
},
{
name: 'totalSpace',
label: () => translate('TotalSpace'),
isVisible: true,
},
{
name: 'progress',
label: '',
isVisible: true,
},
];
function createDiskSpaceSelector() {
return createSelector(
(state: AppState) => state.system.diskSpace,
(diskSpace) => {
return diskSpace;
}
);
}
function DiskSpace() {
const dispatch = useDispatch();
const { isFetching, items } = useSelector(createDiskSpaceSelector());
useEffect(() => {
dispatch(fetchDiskSpace());
}, [dispatch]);
return (
<FieldSet legend={translate('DiskSpace')}>
{isFetching ? <LoadingIndicator /> : null}
{isFetching ? null : (
<Table columns={columns}>
<TableBody>
{items.map((item) => {
const { freeSpace, totalSpace } = item;
const diskUsage = 100 - (freeSpace / totalSpace) * 100;
let diskUsageKind = kinds.PRIMARY;
if (diskUsage > 90) {
diskUsageKind = kinds.DANGER;
} else if (diskUsage > 80) {
diskUsageKind = kinds.WARNING;
}
return (
<TableRow key={item.path}>
<TableRowCell>
{item.path}
{item.label && ` (${item.label})`}
</TableRowCell>
<TableRowCell className={styles.space}>
{formatBytes(freeSpace)}
</TableRowCell>
<TableRowCell className={styles.space}>
{formatBytes(totalSpace)}
</TableRowCell>
<TableRowCell className={styles.space}>
<ProgressBar
progress={diskUsage}
kind={diskUsageKind}
size={sizes.MEDIUM}
/>
</TableRowCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</FieldSet>
);
}
export default DiskSpace;

View File

@ -1,54 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchDiskSpace } from 'Store/Actions/systemActions';
import DiskSpace from './DiskSpace';
function createMapStateToProps() {
return createSelector(
(state) => state.system.diskSpace,
(diskSpace) => {
const {
isFetching,
items
} = diskSpace;
return {
isFetching,
items
};
}
);
}
const mapDispatchToProps = {
fetchDiskSpace
};
class DiskSpaceConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchDiskSpace();
}
//
// Render
render() {
return (
<DiskSpace
{...this.props}
/>
);
}
}
DiskSpaceConnector.propTypes = {
fetchDiskSpace: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(DiskSpaceConnector);

View File

@ -1,242 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableRow from 'Components/Table/TableRow';
import { icons, kinds } from 'Helpers/Props';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import styles from './Health.css';
function getInternalLink(source) {
switch (source) {
case 'IndexerRssCheck':
case 'IndexerSearchCheck':
case 'IndexerStatusCheck':
case 'IndexerJackettAllCheck':
case 'IndexerLongTermStatusCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/settings/indexers"
/>
);
case 'DownloadClientCheck':
case 'DownloadClientStatusCheck':
case 'ImportMechanismCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/settings/downloadclients"
/>
);
case 'NotificationStatusCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/settings/connect"
/>
);
case 'RootFolderCheck':
return (
<IconButton
name={icons.SERIES_CONTINUING}
title={translate('SeriesEditor')}
to="/serieseditor"
/>
);
case 'UpdateCheck':
return (
<IconButton
name={icons.UPDATE}
title={translate('Updates')}
to="/system/updates"
/>
);
default:
return;
}
}
function getTestLink(source, props) {
switch (source) {
case 'IndexerStatusCheck':
case 'IndexerLongTermStatusCheck':
return (
<SpinnerIconButton
name={icons.TEST}
title={translate('TestAll')}
isSpinning={props.isTestingAllIndexers}
onPress={props.dispatchTestAllIndexers}
/>
);
case 'DownloadClientCheck':
case 'DownloadClientStatusCheck':
return (
<SpinnerIconButton
name={icons.TEST}
title={translate('TestAll')}
isSpinning={props.isTestingAllDownloadClients}
onPress={props.dispatchTestAllDownloadClients}
/>
);
default:
break;
}
}
const columns = [
{
className: styles.status,
name: 'type',
isVisible: true
},
{
name: 'message',
label: () => translate('Message'),
isVisible: true
},
{
name: 'actions',
label: () => translate('Actions'),
isVisible: true
}
];
class Health extends Component {
//
// Render
render() {
const {
isFetching,
isPopulated,
items
} = this.props;
const healthIssues = !!items.length;
return (
<FieldSet
legend={
<div className={styles.legend}>
{translate('Health')}
{
isFetching && isPopulated &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
</div>
}
>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!healthIssues &&
<div className={styles.healthOk}>
{translate('NoIssuesWithYourConfiguration')}
</div>
}
{
healthIssues &&
<Table
columns={columns}
>
<TableBody>
{
items.map((item) => {
const internalLink = getInternalLink(item.source);
const testLink = getTestLink(item.source, this.props);
let kind = kinds.WARNING;
switch (item.type.toLowerCase()) {
case 'error':
kind = kinds.DANGER;
break;
default:
case 'warning':
kind = kinds.WARNING;
break;
case 'notice':
kind = kinds.INFO;
break;
}
return (
<TableRow key={`health${item.message}`}>
<TableRowCell>
<Icon
name={icons.DANGER}
kind={kind}
title={titleCase(item.type)}
/>
</TableRowCell>
<TableRowCell>{item.message}</TableRowCell>
<TableRowCell>
<IconButton
name={icons.WIKI}
to={item.wikiUrl}
title={translate('ReadTheWikiForMoreInformation')}
/>
{
internalLink
}
{
!!testLink &&
testLink
}
</TableRowCell>
</TableRow>
);
})
}
</TableBody>
</Table>
}
{
healthIssues &&
<Alert kind={kinds.INFO}>
<InlineMarkdown data={translate('HealthMessagesInfoBox', { link: '/system/logs/files' })} />
</Alert>
}
</FieldSet>
);
}
}
Health.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
items: PropTypes.array.isRequired,
isTestingAllDownloadClients: PropTypes.bool.isRequired,
isTestingAllIndexers: PropTypes.bool.isRequired,
dispatchTestAllDownloadClients: PropTypes.func.isRequired,
dispatchTestAllIndexers: PropTypes.func.isRequired
};
export default Health;

View File

@ -0,0 +1,174 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableRow from 'Components/Table/TableRow';
import { icons, kinds } from 'Helpers/Props';
import {
testAllDownloadClients,
testAllIndexers,
} from 'Store/Actions/settingsActions';
import { fetchHealth } from 'Store/Actions/systemActions';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import createHealthSelector from './createHealthSelector';
import HealthItemLink from './HealthItemLink';
import styles from './Health.css';
const columns: Column[] = [
{
className: styles.status,
name: 'type',
label: '',
isVisible: true,
},
{
name: 'message',
label: () => translate('Message'),
isVisible: true,
},
{
name: 'actions',
label: () => translate('Actions'),
isVisible: true,
},
];
function Health() {
const dispatch = useDispatch();
const { isFetching, isPopulated, items } = useSelector(
createHealthSelector()
);
const isTestingAllDownloadClients = useSelector(
(state: AppState) => state.settings.downloadClients.isTestingAll
);
const isTestingAllIndexers = useSelector(
(state: AppState) => state.settings.indexers.isTestingAll
);
const healthIssues = !!items.length;
const handleTestAllDownloadClientsPress = useCallback(() => {
dispatch(testAllDownloadClients());
}, [dispatch]);
const handleTestAllIndexersPress = useCallback(() => {
dispatch(testAllIndexers());
}, [dispatch]);
useEffect(() => {
dispatch(fetchHealth());
}, [dispatch]);
return (
<FieldSet
legend={
<div className={styles.legend}>
{translate('Health')}
{isFetching && isPopulated ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
</div>
}
>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{isPopulated && !healthIssues ? (
<div className={styles.healthOk}>
{translate('NoIssuesWithYourConfiguration')}
</div>
) : null}
{healthIssues ? (
<>
<Table columns={columns}>
<TableBody>
{items.map((item) => {
const source = item.source;
let kind = kinds.WARNING;
switch (item.type.toLowerCase()) {
case 'error':
kind = kinds.DANGER;
break;
default:
case 'warning':
kind = kinds.WARNING;
break;
case 'notice':
kind = kinds.INFO;
break;
}
return (
<TableRow key={`health${item.message}`}>
<TableRowCell>
<Icon
name={icons.DANGER}
kind={kind}
title={titleCase(item.type)}
/>
</TableRowCell>
<TableRowCell>{item.message}</TableRowCell>
<TableRowCell>
<IconButton
name={icons.WIKI}
to={item.wikiUrl}
title={translate('ReadTheWikiForMoreInformation')}
/>
<HealthItemLink source={source} />
{source === 'IndexerStatusCheck' ||
source === 'IndexerLongTermStatusCheck' ? (
<SpinnerIconButton
name={icons.TEST}
title={translate('TestAll')}
isSpinning={isTestingAllIndexers}
onPress={handleTestAllIndexersPress}
/>
) : null}
{source === 'DownloadClientCheck' ||
source === 'DownloadClientStatusCheck' ? (
<SpinnerIconButton
name={icons.TEST}
title={translate('TestAll')}
isSpinning={isTestingAllDownloadClients}
onPress={handleTestAllDownloadClientsPress}
/>
) : null}
</TableRowCell>
</TableRow>
);
})}
</TableBody>
</Table>
<Alert kind={kinds.INFO}>
<InlineMarkdown
data={translate('HealthMessagesInfoBox', {
link: '/system/logs/files',
})}
/>
</Alert>
</>
) : null}
</FieldSet>
);
}
export default Health;

View File

@ -1,68 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { testAllDownloadClients, testAllIndexers } from 'Store/Actions/settingsActions';
import { fetchHealth } from 'Store/Actions/systemActions';
import Health from './Health';
function createMapStateToProps() {
return createSelector(
(state) => state.system.health,
(state) => state.settings.downloadClients.isTestingAll,
(state) => state.settings.indexers.isTestingAll,
(health, isTestingAllDownloadClients, isTestingAllIndexers) => {
const {
isFetching,
isPopulated,
items
} = health;
return {
isFetching,
isPopulated,
items,
isTestingAllDownloadClients,
isTestingAllIndexers
};
}
);
}
const mapDispatchToProps = {
dispatchFetchHealth: fetchHealth,
dispatchTestAllDownloadClients: testAllDownloadClients,
dispatchTestAllIndexers: testAllIndexers
};
class HealthConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchHealth();
}
//
// Render
render() {
const {
dispatchFetchHealth,
...otherProps
} = this.props;
return (
<Health
{...otherProps}
/>
);
}
}
HealthConnector.propTypes = {
dispatchFetchHealth: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(HealthConnector);

View File

@ -0,0 +1,65 @@
import React from 'react';
import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
interface HealthItemLinkProps {
source: string;
}
function HealthItemLink(props: HealthItemLinkProps) {
const { source } = props;
switch (source) {
case 'IndexerRssCheck':
case 'IndexerSearchCheck':
case 'IndexerStatusCheck':
case 'IndexerJackettAllCheck':
case 'IndexerLongTermStatusCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/settings/indexers"
/>
);
case 'DownloadClientCheck':
case 'DownloadClientStatusCheck':
case 'ImportMechanismCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/settings/downloadclients"
/>
);
case 'NotificationStatusCheck':
return (
<IconButton
name={icons.SETTINGS}
title={translate('Settings')}
to="/settings/connect"
/>
);
case 'RootFolderCheck':
return (
<IconButton
name={icons.SERIES_CONTINUING}
title={translate('SeriesEditor')}
to="/serieseditor"
/>
);
case 'UpdateCheck':
return (
<IconButton
name={icons.UPDATE}
title={translate('Updates')}
to="/system/updates"
/>
);
default:
return null;
}
}
export default HealthItemLink;

View File

@ -0,0 +1,56 @@
import React, { useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { fetchHealth } from 'Store/Actions/systemActions';
import createHealthSelector from './createHealthSelector';
function HealthStatus() {
const dispatch = useDispatch();
const { isConnected, isReconnecting } = useSelector(
(state: AppState) => state.app
);
const { isPopulated, items } = useSelector(createHealthSelector());
const wasReconnecting = usePrevious(isReconnecting);
const { count, errors, warnings } = useMemo(() => {
let errors = false;
let warnings = false;
items.forEach((item) => {
if (item.type === 'error') {
errors = true;
}
if (item.type === 'warning') {
warnings = true;
}
});
return {
count: items.length,
errors,
warnings,
};
}, [items]);
useEffect(() => {
if (!isPopulated) {
dispatch(fetchHealth());
}
}, [isPopulated, dispatch]);
useEffect(() => {
if (isConnected && wasReconnecting) {
dispatch(fetchHealth());
}
}, [isConnected, wasReconnecting, dispatch]);
return (
<PageSidebarStatus count={count} errors={errors} warnings={warnings} />
);
}
export default HealthStatus;

View File

@ -1,79 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
import { fetchHealth } from 'Store/Actions/systemActions';
function createMapStateToProps() {
return createSelector(
(state) => state.app,
(state) => state.system.health,
(app, health) => {
const count = health.items.length;
let errors = false;
let warnings = false;
health.items.forEach((item) => {
if (item.type === 'error') {
errors = true;
}
if (item.type === 'warning') {
warnings = true;
}
});
return {
isConnected: app.isConnected,
isReconnecting: app.isReconnecting,
isPopulated: health.isPopulated,
count,
errors,
warnings
};
}
);
}
const mapDispatchToProps = {
fetchHealth
};
class HealthStatusConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.isPopulated) {
this.props.fetchHealth();
}
}
componentDidUpdate(prevProps) {
if (this.props.isConnected && prevProps.isReconnecting) {
this.props.fetchHealth();
}
}
//
// Render
render() {
return (
<PageSidebarStatus
{...this.props}
/>
);
}
}
HealthStatusConnector.propTypes = {
isConnected: PropTypes.bool.isRequired,
isReconnecting: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
fetchHealth: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(HealthStatusConnector);

View File

@ -0,0 +1,13 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createHealthSelector() {
return createSelector(
(state: AppState) => state.system.health,
(health) => {
return health;
}
);
}
export default createHealthSelector;

View File

@ -1,101 +0,0 @@
import React, { Component } from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
import FieldSet from 'Components/FieldSet';
import Link from 'Components/Link/Link';
import translate from 'Utilities/String/translate';
class MoreInfo extends Component {
//
// Render
render() {
return (
<FieldSet legend={translate('MoreInfo')}>
<DescriptionList>
<DescriptionListItemTitle>
{translate('HomePage')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://sonarr.tv/">sonarr.tv</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('Wiki')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://wiki.servarr.com/sonarr">wiki.servarr.com/sonarr</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('Forums')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://forums.sonarr.tv/">forums.sonarr.tv</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('Twitter')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://twitter.com/sonarrtv">@sonarrtv</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('Discord')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://discord.sonarr.tv/">discord.sonarr.tv</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('IRC')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="irc://irc.libera.chat/#sonarr">
{translate('IRCLinkText')}
</Link>
</DescriptionListItemDescription>
<DescriptionListItemDescription>
<Link to="https://web.libera.chat/?channels=#sonarr">
{translate('LiberaWebchat')}
</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('Donations')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://sonarr.tv/donate">sonarr.tv/donate</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('Source')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://github.com/Sonarr/Sonarr/">github.com/Sonarr/Sonarr</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('FeatureRequests')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://forums.sonarr.tv/">forums.sonarr.tv</Link>
</DescriptionListItemDescription>
<DescriptionListItemDescription>
<Link to="https://github.com/Sonarr/Sonarr/issues">github.com/Sonarr/Sonarr/issues</Link>
</DescriptionListItemDescription>
</DescriptionList>
</FieldSet>
);
}
}
MoreInfo.propTypes = {
};
export default MoreInfo;

View File

@ -0,0 +1,92 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
import FieldSet from 'Components/FieldSet';
import Link from 'Components/Link/Link';
import translate from 'Utilities/String/translate';
function MoreInfo() {
return (
<FieldSet legend={translate('MoreInfo')}>
<DescriptionList>
<DescriptionListItemTitle>
{translate('HomePage')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://sonarr.tv/">sonarr.tv</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>{translate('Wiki')}</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://wiki.servarr.com/sonarr">
wiki.servarr.com/sonarr
</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('Forums')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://forums.sonarr.tv/">forums.sonarr.tv</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('Twitter')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://twitter.com/sonarrtv">@sonarrtv</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('Discord')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://discord.sonarr.tv/">discord.sonarr.tv</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>{translate('IRC')}</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="irc://irc.libera.chat/#sonarr">
{translate('IRCLinkText')}
</Link>
</DescriptionListItemDescription>
<DescriptionListItemDescription>
<Link to="https://web.libera.chat/?channels=#sonarr">
{translate('LiberaWebchat')}
</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('Donations')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://sonarr.tv/donate">sonarr.tv/donate</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('Source')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://github.com/Sonarr/Sonarr/">
github.com/Sonarr/Sonarr
</Link>
</DescriptionListItemDescription>
<DescriptionListItemTitle>
{translate('FeatureRequests')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to="https://forums.sonarr.tv/">forums.sonarr.tv</Link>
</DescriptionListItemDescription>
<DescriptionListItemDescription>
<Link to="https://github.com/Sonarr/Sonarr/issues">
github.com/Sonarr/Sonarr/issues
</Link>
</DescriptionListItemDescription>
</DescriptionList>
</FieldSet>
);
}
export default MoreInfo;

View File

@ -2,13 +2,12 @@ import React, { Component } from 'react';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import translate from 'Utilities/String/translate';
import AboutConnector from './About/AboutConnector';
import DiskSpaceConnector from './DiskSpace/DiskSpaceConnector';
import HealthConnector from './Health/HealthConnector';
import About from './About/About';
import DiskSpace from './DiskSpace/DiskSpace';
import Health from './Health/Health';
import MoreInfo from './MoreInfo/MoreInfo';
class Status extends Component {
//
// Render
@ -16,15 +15,14 @@ class Status extends Component {
return (
<PageContent title={translate('Status')}>
<PageContentBody>
<HealthConnector />
<DiskSpaceConnector />
<AboutConnector />
<Health />
<DiskSpace />
<About />
<MoreInfo />
</PageContentBody>
</PageContent>
);
}
}
export default Status;

View File

@ -1,203 +0,0 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import { icons } from 'Helpers/Props';
import formatDate from 'Utilities/Date/formatDate';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import styles from './ScheduledTaskRow.css';
function getFormattedDates(props) {
const {
lastExecution,
nextExecution,
interval,
showRelativeDates,
shortDateFormat
} = props;
const isDisabled = interval === 0;
if (showRelativeDates) {
return {
lastExecutionTime: moment(lastExecution).fromNow(),
nextExecutionTime: isDisabled ? '-' : moment(nextExecution).fromNow()
};
}
return {
lastExecutionTime: formatDate(lastExecution, shortDateFormat),
nextExecutionTime: isDisabled ? '-' : formatDate(nextExecution, shortDateFormat)
};
}
class ScheduledTaskRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = getFormattedDates(props);
this._updateTimeoutId = null;
}
componentDidMount() {
this.setUpdateTimer();
}
componentDidUpdate(prevProps) {
const {
lastExecution,
nextExecution
} = this.props;
if (
lastExecution !== prevProps.lastExecution ||
nextExecution !== prevProps.nextExecution
) {
this.setState(getFormattedDates(this.props));
}
}
componentWillUnmount() {
if (this._updateTimeoutId) {
this._updateTimeoutId = clearTimeout(this._updateTimeoutId);
}
}
//
// Listeners
setUpdateTimer() {
const { interval } = this.props;
const timeout = interval < 60 ? 10000 : 60000;
this._updateTimeoutId = setTimeout(() => {
this.setState(getFormattedDates(this.props));
this.setUpdateTimer();
}, timeout);
}
//
// Render
render() {
const {
name,
interval,
lastExecution,
lastStartTime,
lastDuration,
nextExecution,
isQueued,
isExecuting,
longDateFormat,
timeFormat,
onExecutePress
} = this.props;
const {
lastExecutionTime,
nextExecutionTime
} = this.state;
const isDisabled = interval === 0;
const executeNow = !isDisabled && moment().isAfter(nextExecution);
const hasNextExecutionTime = !isDisabled && !executeNow;
const duration = moment.duration(interval, 'minutes').humanize().replace(/an?(?=\s)/, '1');
const hasLastStartTime = moment(lastStartTime).isAfter('2010-01-01');
return (
<TableRow>
<TableRowCell>{name}</TableRowCell>
<TableRowCell
className={styles.interval}
>
{isDisabled ? 'disabled' : duration}
</TableRowCell>
<TableRowCell
className={styles.lastExecution}
title={formatDateTime(lastExecution, longDateFormat, timeFormat)}
>
{lastExecutionTime}
</TableRowCell>
{
!hasLastStartTime &&
<TableRowCell className={styles.lastDuration}>-</TableRowCell>
}
{
hasLastStartTime &&
<TableRowCell
className={styles.lastDuration}
title={lastDuration}
>
{formatTimeSpan(lastDuration)}
</TableRowCell>
}
{
isDisabled &&
<TableRowCell className={styles.nextExecution}>-</TableRowCell>
}
{
executeNow && isQueued &&
<TableRowCell className={styles.nextExecution}>queued</TableRowCell>
}
{
executeNow && !isQueued &&
<TableRowCell className={styles.nextExecution}>now</TableRowCell>
}
{
hasNextExecutionTime &&
<TableRowCell
className={styles.nextExecution}
title={formatDateTime(nextExecution, longDateFormat, timeFormat, { includeSeconds: true })}
>
{nextExecutionTime}
</TableRowCell>
}
<TableRowCell
className={styles.actions}
>
<SpinnerIconButton
name={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isExecuting}
onPress={onExecutePress}
/>
</TableRowCell>
</TableRow>
);
}
}
ScheduledTaskRow.propTypes = {
name: PropTypes.string.isRequired,
interval: PropTypes.number.isRequired,
lastExecution: PropTypes.string.isRequired,
lastStartTime: PropTypes.string.isRequired,
lastDuration: PropTypes.string.isRequired,
nextExecution: PropTypes.string.isRequired,
isQueued: PropTypes.bool.isRequired,
isExecuting: PropTypes.bool.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
onExecutePress: PropTypes.func.isRequired
};
export default ScheduledTaskRow;

View File

@ -0,0 +1,170 @@
import moment from 'moment';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import { fetchTask } from 'Store/Actions/systemActions';
import createCommandSelector from 'Store/Selectors/createCommandSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { isCommandExecuting } from 'Utilities/Command';
import formatDate from 'Utilities/Date/formatDate';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import styles from './ScheduledTaskRow.css';
interface ScheduledTaskRowProps {
id: number;
taskName: string;
name: string;
interval: number;
lastExecution: string;
lastStartTime: string;
lastDuration: string;
nextExecution: string;
}
function ScheduledTaskRow(props: ScheduledTaskRowProps) {
const {
id,
taskName,
name,
interval,
lastExecution,
lastStartTime,
lastDuration,
nextExecution,
} = props;
const dispatch = useDispatch();
const { showRelativeDates, longDateFormat, shortDateFormat, timeFormat } =
useSelector(createUISettingsSelector());
const command = useSelector(createCommandSelector(taskName));
const [time, setTime] = useState(Date.now());
const isQueued = !!(command && command.status === 'queued');
const isExecuting = isCommandExecuting(command);
const wasExecuting = usePrevious(isExecuting);
const isDisabled = interval === 0;
const executeNow = !isDisabled && moment().isAfter(nextExecution);
const hasNextExecutionTime = !isDisabled && !executeNow;
const hasLastStartTime = moment(lastStartTime).isAfter('2010-01-01');
const duration = useMemo(() => {
return moment
.duration(interval, 'minutes')
.humanize()
.replace(/an?(?=\s)/, '1');
}, [interval]);
const { lastExecutionTime, nextExecutionTime } = useMemo(() => {
const isDisabled = interval === 0;
if (showRelativeDates && time) {
return {
lastExecutionTime: moment(lastExecution).fromNow(),
nextExecutionTime: isDisabled ? '-' : moment(nextExecution).fromNow(),
};
}
return {
lastExecutionTime: formatDate(lastExecution, shortDateFormat),
nextExecutionTime: isDisabled
? '-'
: formatDate(nextExecution, shortDateFormat),
};
}, [
time,
interval,
lastExecution,
nextExecution,
showRelativeDates,
shortDateFormat,
]);
const handleExecutePress = useCallback(() => {
dispatch(
executeCommand({
name: taskName,
})
);
}, [taskName, dispatch]);
useEffect(() => {
if (!isExecuting && wasExecuting) {
setTimeout(() => {
dispatch(fetchTask({ id }));
}, 1000);
}
}, [id, isExecuting, wasExecuting, dispatch]);
useEffect(() => {
const interval = setInterval(() => setTime(Date.now()), 1000);
return () => {
clearInterval(interval);
};
}, [setTime]);
return (
<TableRow>
<TableRowCell>{name}</TableRowCell>
<TableRowCell className={styles.interval}>
{isDisabled ? 'disabled' : duration}
</TableRowCell>
<TableRowCell
className={styles.lastExecution}
title={formatDateTime(lastExecution, longDateFormat, timeFormat)}
>
{lastExecutionTime}
</TableRowCell>
{hasLastStartTime ? (
<TableRowCell className={styles.lastDuration} title={lastDuration}>
{formatTimeSpan(lastDuration)}
</TableRowCell>
) : (
<TableRowCell className={styles.lastDuration}>-</TableRowCell>
)}
{isDisabled ? (
<TableRowCell className={styles.nextExecution}>-</TableRowCell>
) : null}
{executeNow && isQueued ? (
<TableRowCell className={styles.nextExecution}>queued</TableRowCell>
) : null}
{executeNow && !isQueued ? (
<TableRowCell className={styles.nextExecution}>now</TableRowCell>
) : null}
{hasNextExecutionTime ? (
<TableRowCell
className={styles.nextExecution}
title={formatDateTime(nextExecution, longDateFormat, timeFormat, {
includeSeconds: true,
})}
>
{nextExecutionTime}
</TableRowCell>
) : null}
<TableRowCell className={styles.actions}>
<SpinnerIconButton
name={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isExecuting}
onPress={handleExecutePress}
/>
</TableRowCell>
</TableRow>
);
}
export default ScheduledTaskRow;

View File

@ -1,92 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { executeCommand } from 'Store/Actions/commandActions';
import { fetchTask } from 'Store/Actions/systemActions';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { findCommand, isCommandExecuting } from 'Utilities/Command';
import ScheduledTaskRow from './ScheduledTaskRow';
function createMapStateToProps() {
return createSelector(
(state, { taskName }) => taskName,
createCommandsSelector(),
createUISettingsSelector(),
(taskName, commands, uiSettings) => {
const command = findCommand(commands, { name: taskName });
return {
isQueued: !!(command && command.state === 'queued'),
isExecuting: isCommandExecuting(command),
showRelativeDates: uiSettings.showRelativeDates,
shortDateFormat: uiSettings.shortDateFormat,
longDateFormat: uiSettings.longDateFormat,
timeFormat: uiSettings.timeFormat
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
const taskName = props.taskName;
return {
dispatchFetchTask() {
dispatch(fetchTask({
id: props.id
}));
},
onExecutePress() {
dispatch(executeCommand({
name: taskName
}));
}
};
}
class ScheduledTaskRowConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps) {
const {
isExecuting,
dispatchFetchTask
} = this.props;
if (!isExecuting && prevProps.isExecuting) {
// Give the host a moment to update after the command completes
setTimeout(() => {
dispatchFetchTask();
}, 1000);
}
}
//
// Render
render() {
const {
dispatchFetchTask,
...otherProps
} = this.props;
return (
<ScheduledTaskRow
{...otherProps}
/>
);
}
}
ScheduledTaskRowConnector.propTypes = {
id: PropTypes.number.isRequired,
isExecuting: PropTypes.bool.isRequired,
dispatchFetchTask: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, createMapDispatchToProps)(ScheduledTaskRowConnector);

View File

@ -1,85 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import FieldSet from 'Components/FieldSet';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import translate from 'Utilities/String/translate';
import ScheduledTaskRowConnector from './ScheduledTaskRowConnector';
const columns = [
{
name: 'name',
label: () => translate('Name'),
isVisible: true
},
{
name: 'interval',
label: () => translate('Interval'),
isVisible: true
},
{
name: 'lastExecution',
label: () => translate('LastExecution'),
isVisible: true
},
{
name: 'lastDuration',
label: () => translate('LastDuration'),
isVisible: true
},
{
name: 'nextExecution',
label: () => translate('NextExecution'),
isVisible: true
},
{
name: 'actions',
isVisible: true
}
];
function ScheduledTasks(props) {
const {
isFetching,
isPopulated,
items
} = props;
return (
<FieldSet legend={translate('Scheduled')}>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
isPopulated &&
<Table
columns={columns}
>
<TableBody>
{
items.map((item) => {
return (
<ScheduledTaskRowConnector
key={item.id}
{...item}
/>
);
})
}
</TableBody>
</Table>
}
</FieldSet>
);
}
ScheduledTasks.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
items: PropTypes.array.isRequired
};
export default ScheduledTasks;

View File

@ -0,0 +1,73 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FieldSet from 'Components/FieldSet';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { fetchTasks } from 'Store/Actions/systemActions';
import translate from 'Utilities/String/translate';
import ScheduledTaskRow from './ScheduledTaskRow';
const columns: Column[] = [
{
name: 'name',
label: () => translate('Name'),
isVisible: true,
},
{
name: 'interval',
label: () => translate('Interval'),
isVisible: true,
},
{
name: 'lastExecution',
label: () => translate('LastExecution'),
isVisible: true,
},
{
name: 'lastDuration',
label: () => translate('LastDuration'),
isVisible: true,
},
{
name: 'nextExecution',
label: () => translate('NextExecution'),
isVisible: true,
},
{
name: 'actions',
label: '',
isVisible: true,
},
];
function ScheduledTasks() {
const dispatch = useDispatch();
const { isFetching, isPopulated, items } = useSelector(
(state: AppState) => state.system.tasks
);
useEffect(() => {
dispatch(fetchTasks());
}, [dispatch]);
return (
<FieldSet legend={translate('Scheduled')}>
{isFetching && !isPopulated && <LoadingIndicator />}
{isPopulated && (
<Table columns={columns}>
<TableBody>
{items.map((item) => {
return <ScheduledTaskRow key={item.id} {...item} />;
})}
</TableBody>
</Table>
)}
</FieldSet>
);
}
export default ScheduledTasks;

View File

@ -1,46 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchTasks } from 'Store/Actions/systemActions';
import ScheduledTasks from './ScheduledTasks';
function createMapStateToProps() {
return createSelector(
(state) => state.system.tasks,
(tasks) => {
return tasks;
}
);
}
const mapDispatchToProps = {
dispatchFetchTasks: fetchTasks
};
class ScheduledTasksConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchTasks();
}
//
// Render
render() {
return (
<ScheduledTasks
{...this.props}
/>
);
}
}
ScheduledTasksConnector.propTypes = {
dispatchFetchTasks: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ScheduledTasksConnector);

View File

@ -3,13 +3,13 @@ import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import translate from 'Utilities/String/translate';
import QueuedTasks from './Queued/QueuedTasks';
import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector';
import ScheduledTasks from './Scheduled/ScheduledTasks';
function Tasks() {
return (
<PageContent title={translate('Tasks')}>
<PageContentBody>
<ScheduledTasksConnector />
<ScheduledTasks />
<QueuedTasks />
</PageContentBody>
</PageContent>

View File

@ -1,10 +1,4 @@
import React, {
Fragment,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
@ -158,7 +152,7 @@ function Updates() {
{translate('InstallLatest')}
</SpinnerButton>
) : (
<Fragment>
<>
<Icon name={icons.WARNING} kind={kinds.WARNING} size={30} />
<div className={styles.message}>
@ -171,7 +165,7 @@ function Updates() {
}
/>
</div>
</Fragment>
</>
)}
{isFetching ? (

View File

@ -1,10 +1,6 @@
import { filesize } from 'filesize';
function formatBytes(input?: string | number) {
if (!input) {
return '';
}
function formatBytes(input: string | number) {
const size = Number(input);
if (isNaN(size)) {

View File

@ -0,0 +1,8 @@
interface DiskSpace {
path: string;
label: string;
freeSpace: number;
totalSpace: number;
}
export default DiskSpace;

View File

@ -0,0 +1,8 @@
interface Health {
source: string;
type: string;
message: string;
wikiUrl: string;
}
export default Health;

View File

@ -4,6 +4,8 @@ interface SystemStatus {
authentication: string;
branch: string;
buildTime: string;
databaseVersion: string;
databaseType: string;
instanceName: string;
isAdmin: boolean;
isDebug: boolean;
@ -18,8 +20,10 @@ interface SystemStatus {
mode: string;
osName: string;
osVersion: string;
packageAuthor: string;
packageUpdateMechanism: string;
packageUpdateMechanismMessage: string;
packageVersion: string;
runtimeName: string;
runtimeVersion: string;
sqliteVersion: string;

View File

@ -0,0 +1,13 @@
import ModelBase from 'App/ModelBase';
interface Task extends ModelBase {
name: string;
taskName: string;
interval: number;
lastExecution: string;
lastStartTime: string;
nextExecution: string;
lastDuration: string;
}
export default Task;

View File

@ -1,11 +1,11 @@
using FluentAssertions;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.Extensions;
namespace NzbDrone.Common.Test.ExtensionTests
{
[TestFixture]
public class Int64ExtensionFixture
public class NumberExtensionFixture
{
[TestCase(0, "0 B")]
[TestCase(1000, "1,000.0 B")]

View File

@ -380,8 +380,17 @@ namespace NzbDrone.Common.Test
[TestCase(@" C:\Test\TV\")]
[TestCase(@" C:\Test\TV")]
public void IsPathValid_should_be_false(string path)
public void IsPathValid_should_be_false_on_windows(string path)
{
WindowsOnly();
path.IsPathValid(PathValidationType.CurrentOs).Should().BeFalse();
}
[TestCase(@"")]
[TestCase(@"relative/path")]
public void IsPathValid_should_be_false_on_unix(string path)
{
PosixOnly();
path.AsOsAgnostic().IsPathValid(PathValidationType.CurrentOs).Should().BeFalse();
}
}

View File

@ -1,9 +1,9 @@
using System;
using System;
using System.Globalization;
namespace NzbDrone.Common.Extensions
{
public static class Int64Extensions
public static class NumberExtensions
{
private static readonly string[] SizeSuffixes = { "B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB" };
@ -26,5 +26,25 @@ namespace NzbDrone.Common.Extensions
return string.Format(CultureInfo.InvariantCulture, "{0:n1} {1}", adjustedSize, SizeSuffixes[mag]);
}
public static long Megabytes(this int megabytes)
{
return Convert.ToInt64(megabytes * 1024L * 1024L);
}
public static long Gigabytes(this int gigabytes)
{
return Convert.ToInt64(gigabytes * 1024L * 1024L * 1024L);
}
public static long Megabytes(this double megabytes)
{
return Convert.ToInt64(megabytes * 1024L * 1024L);
}
public static long Gigabytes(this double gigabytes)
{
return Convert.ToInt64(gigabytes * 1024L * 1024L * 1024L);
}
}
}

View File

@ -152,16 +152,20 @@ namespace NzbDrone.Common.Extensions
return false;
}
var directoryInfo = new DirectoryInfo(path);
while (directoryInfo != null)
// Only check for leading or trailing spaces for path when running on Windows.
if (OsInfo.IsWindows)
{
if (directoryInfo.Name.Trim() != directoryInfo.Name)
{
return false;
}
var directoryInfo = new DirectoryInfo(path);
directoryInfo = directoryInfo.Parent;
while (directoryInfo != null)
{
if (directoryInfo.Name.Trim() != directoryInfo.Name)
{
return false;
}
directoryInfo = directoryInfo.Parent;
}
}
if (validationType == PathValidationType.AnyOs)

View File

@ -1,6 +1,6 @@
using System.Linq;
using System.Collections.Generic;
using System.Linq;
using NLog;
using NLog.Fluent;
namespace NzbDrone.Common.Instrumentation.Extensions
{
@ -8,47 +8,46 @@ namespace NzbDrone.Common.Instrumentation.Extensions
{
public static readonly Logger SentryLogger = LogManager.GetLogger("Sentry");
public static LogBuilder SentryFingerprint(this LogBuilder logBuilder, params string[] fingerprint)
public static LogEventBuilder SentryFingerprint(this LogEventBuilder logBuilder, params string[] fingerprint)
{
return logBuilder.Property("Sentry", fingerprint);
}
public static LogBuilder WriteSentryDebug(this LogBuilder logBuilder, params string[] fingerprint)
public static LogEventBuilder WriteSentryDebug(this LogEventBuilder logBuilder, params string[] fingerprint)
{
return LogSentryMessage(logBuilder, LogLevel.Debug, fingerprint);
}
public static LogBuilder WriteSentryInfo(this LogBuilder logBuilder, params string[] fingerprint)
public static LogEventBuilder WriteSentryInfo(this LogEventBuilder logBuilder, params string[] fingerprint)
{
return LogSentryMessage(logBuilder, LogLevel.Info, fingerprint);
}
public static LogBuilder WriteSentryWarn(this LogBuilder logBuilder, params string[] fingerprint)
public static LogEventBuilder WriteSentryWarn(this LogEventBuilder logBuilder, params string[] fingerprint)
{
return LogSentryMessage(logBuilder, LogLevel.Warn, fingerprint);
}
public static LogBuilder WriteSentryError(this LogBuilder logBuilder, params string[] fingerprint)
public static LogEventBuilder WriteSentryError(this LogEventBuilder logBuilder, params string[] fingerprint)
{
return LogSentryMessage(logBuilder, LogLevel.Error, fingerprint);
}
private static LogBuilder LogSentryMessage(LogBuilder logBuilder, LogLevel level, string[] fingerprint)
private static LogEventBuilder LogSentryMessage(LogEventBuilder logBuilder, LogLevel level, string[] fingerprint)
{
SentryLogger.Log(level)
.CopyLogEvent(logBuilder.LogEventInfo)
SentryLogger.ForLogEvent(level)
.CopyLogEvent(logBuilder.LogEvent)
.SentryFingerprint(fingerprint)
.Write();
.Log();
return logBuilder.Property("Sentry", null);
return logBuilder.Property<string>("Sentry", null);
}
private static LogBuilder CopyLogEvent(this LogBuilder logBuilder, LogEventInfo logEvent)
private static LogEventBuilder CopyLogEvent(this LogEventBuilder logBuilder, LogEventInfo logEvent)
{
return logBuilder.LoggerName(logEvent.LoggerName)
.TimeStamp(logEvent.TimeStamp)
return logBuilder.TimeStamp(logEvent.TimeStamp)
.Message(logEvent.Message, logEvent.Parameters)
.Properties(logEvent.Properties.ToDictionary(v => v.Key, v => v.Value))
.Properties(logEvent.Properties.Select(p => new KeyValuePair<string, object>(p.Key.ToString(), p.Value)))
.Exception(logEvent.Exception);
}
}

View File

@ -1,13 +1,15 @@
using NLog;
using System.Text;
using NLog;
using NLog.Targets;
namespace NzbDrone.Common.Instrumentation
{
public class NzbDroneFileTarget : FileTarget
{
protected override string GetFormattedMessage(LogEventInfo logEvent)
protected override void RenderFormattedMessage(LogEventInfo logEvent, StringBuilder target)
{
return CleanseLogMessage.Cleanse(Layout.Render(logEvent));
var result = CleanseLogMessage.Cleanse(Layout.Render(logEvent));
target.Append(result);
}
}
}

View File

@ -3,6 +3,7 @@ using System.Diagnostics;
using System.IO;
using NLog;
using NLog.Config;
using NLog.Layouts.ClefJsonLayout;
using NLog.Targets;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
@ -13,6 +14,8 @@ namespace NzbDrone.Common.Instrumentation
public static class NzbDroneLogger
{
private const string FILE_LOG_LAYOUT = @"${date:format=yyyy-MM-dd HH\:mm\:ss.f}|${level}|${logger}|${message}${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}}";
public const string ConsoleLogLayout = "[${level}] ${logger}: ${message} ${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}}";
public static CompactJsonLayout ClefLogLayout = new CompactJsonLayout();
private static bool _isConfigured;
@ -34,6 +37,8 @@ namespace NzbDrone.Common.Instrumentation
var appFolderInfo = new AppFolderInfo(startupContext);
RegisterGlobalFilters();
if (Debugger.IsAttached)
{
RegisterDebugger();
@ -122,7 +127,16 @@ namespace NzbDrone.Common.Instrumentation
var coloredConsoleTarget = new ColoredConsoleTarget();
coloredConsoleTarget.Name = "consoleLogger";
coloredConsoleTarget.Layout = "[${level}] ${logger}: ${message} ${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}}";
var logFormat = Enum.TryParse<ConsoleLogFormat>(Environment.GetEnvironmentVariable("SONARR__LOG__CONSOLEFORMAT"), out var formatEnumValue)
? formatEnumValue
: ConsoleLogFormat.Standard;
coloredConsoleTarget.Layout = logFormat switch
{
ConsoleLogFormat.Clef => ClefLogLayout,
_ => ConsoleLogLayout
};
var loggingRule = new LoggingRule("*", level, coloredConsoleTarget);
@ -148,7 +162,7 @@ namespace NzbDrone.Common.Instrumentation
fileTarget.ConcurrentWrites = false;
fileTarget.ConcurrentWriteAttemptDelay = 50;
fileTarget.ConcurrentWriteAttempts = 10;
fileTarget.ArchiveAboveSize = 1024000;
fileTarget.ArchiveAboveSize = 1.Megabytes();
fileTarget.MaxArchiveFiles = maxArchiveFiles;
fileTarget.EnableFileDelete = true;
fileTarget.ArchiveNumbering = ArchiveNumberingMode.Rolling;
@ -196,6 +210,17 @@ namespace NzbDrone.Common.Instrumentation
LogManager.Configuration.LoggingRules.Insert(0, rule);
}
private static void RegisterGlobalFilters()
{
LogManager.Setup().LoadConfiguration(c =>
{
c.ForLogger("System.*").WriteToNil(LogLevel.Warn);
c.ForLogger("Microsoft.*").WriteToNil(LogLevel.Warn);
c.ForLogger("Microsoft.Hosting.Lifetime*").WriteToNil(LogLevel.Info);
c.ForLogger("Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware").WriteToNil(LogLevel.Fatal);
});
}
public static Logger GetLogger(Type obj)
{
return LogManager.GetLogger(obj.Name.Replace("NzbDrone.", ""));
@ -206,4 +231,10 @@ namespace NzbDrone.Common.Instrumentation
return GetLogger(obj.GetType());
}
}
public enum ConsoleLogFormat
{
Standard,
Clef
}
}

View File

@ -5,8 +5,10 @@ public class LogOptions
public string Level { get; set; }
public bool? FilterSentryEvents { get; set; }
public int? Rotate { get; set; }
public int? SizeLimit { get; set; }
public bool? Sql { get; set; }
public string ConsoleLevel { get; set; }
public string ConsoleFormat { get; set; }
public bool? AnalyticsEnabled { get; set; }
public string SyslogServer { get; set; }
public int? SyslogPort { get; set; }

View File

@ -8,9 +8,10 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NLog" Version="4.7.14" />
<PackageReference Include="NLog.Targets.Syslog" Version="6.0.3" />
<PackageReference Include="NLog.Extensions.Logging" Version="1.7.4" />
<PackageReference Include="NLog" Version="5.3.2" />
<PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.0" />
<PackageReference Include="NLog.Targets.Syslog" Version="7.0.0" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.11" />
<PackageReference Include="Sentry" Version="4.0.2" />
<PackageReference Include="SharpZipLib" Version="1.4.2" />
<PackageReference Include="System.Text.Json" Version="6.0.9" />

View File

@ -2,6 +2,7 @@ using System.Collections.Generic;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser.Model;

View File

@ -3,6 +3,7 @@ using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser.Model;

View File

@ -2,6 +2,7 @@ using System.Collections.Generic;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser.Model;

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Datastore.Migration;
using NzbDrone.Core.MediaFiles.MediaInfo;

View File

@ -4,6 +4,7 @@ using FizzWare.NBuilder;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Qualities;

View File

@ -64,7 +64,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
};
Mocker.GetMock<IParsingService>()
.Setup(c => c.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<SearchCriteriaBase>()))
.Setup(c => c.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), It.IsAny<SearchCriteriaBase>()))
.Returns(_remoteEpisode);
}
@ -154,7 +154,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Subject.GetRssDecision(_reports).ToList();
Mocker.GetMock<IParsingService>().Verify(c => c.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<SearchCriteriaBase>()), Times.Never());
Mocker.GetMock<IParsingService>().Verify(c => c.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), It.IsAny<SearchCriteriaBase>()), Times.Never());
_pass1.Verify(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null), Times.Never());
_pass2.Verify(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null), Times.Never());
@ -169,7 +169,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var results = Subject.GetRssDecision(_reports).ToList();
Mocker.GetMock<IParsingService>().Verify(c => c.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<SearchCriteriaBase>()), Times.Never());
Mocker.GetMock<IParsingService>().Verify(c => c.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), It.IsAny<SearchCriteriaBase>()), Times.Never());
_pass1.Verify(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null), Times.Never());
_pass2.Verify(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null), Times.Never());
@ -186,7 +186,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Subject.GetSearchDecision(_reports, new SingleEpisodeSearchCriteria()).ToList();
Mocker.GetMock<IParsingService>().Verify(c => c.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<SearchCriteriaBase>()), Times.Never());
Mocker.GetMock<IParsingService>().Verify(c => c.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), It.IsAny<SearchCriteriaBase>()), Times.Never());
_pass1.Verify(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null), Times.Never());
_pass2.Verify(c => c.IsSatisfiedBy(It.IsAny<RemoteEpisode>(), null), Times.Never());
@ -212,7 +212,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{
GivenSpecifications(_pass1);
Mocker.GetMock<IParsingService>().Setup(c => c.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<SearchCriteriaBase>()))
Mocker.GetMock<IParsingService>().Setup(c => c.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), It.IsAny<SearchCriteriaBase>()))
.Throws<TestException>();
_reports = new List<ReleaseInfo>
@ -224,7 +224,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Subject.GetRssDecision(_reports);
Mocker.GetMock<IParsingService>().Verify(c => c.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<SearchCriteriaBase>()), Times.Exactly(_reports.Count));
Mocker.GetMock<IParsingService>().Verify(c => c.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), It.IsAny<SearchCriteriaBase>()), Times.Exactly(_reports.Count));
ExceptionVerification.ExpectedErrors(3);
}
@ -263,8 +263,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
}).ToList();
Mocker.GetMock<IParsingService>()
.Setup(v => v.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<SearchCriteriaBase>()))
.Returns<ParsedEpisodeInfo, int, int, SearchCriteriaBase>((p, tvdbid, tvrageid, c) =>
.Setup(v => v.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), It.IsAny<SearchCriteriaBase>()))
.Returns<ParsedEpisodeInfo, int, int, string, SearchCriteriaBase>((p, _, _, _, _) =>
new RemoteEpisode
{
DownloadAllowed = true,
@ -318,7 +318,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{
GivenSpecifications(_pass1);
Mocker.GetMock<IParsingService>().Setup(c => c.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<SearchCriteriaBase>()))
Mocker.GetMock<IParsingService>().Setup(c => c.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), It.IsAny<SearchCriteriaBase>()))
.Throws<TestException>();
_reports = new List<ReleaseInfo>

View File

@ -4,6 +4,8 @@ using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Download.Aggregation.Aggregators;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Indexers.TorrentRss;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Test.Framework;
@ -62,6 +64,63 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Subject.Aggregate(_remoteEpisode).Languages.Should().Equal(_remoteEpisode.ParsedEpisodeInfo.Languages);
}
[Test]
public void should_return_multi_languages_when_indexer_has_multi_languages_configuration()
{
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
var indexerDefinition = new IndexerDefinition
{
Settings = new TorrentRssIndexerSettings { MultiLanguages = new List<int> { Language.Original.Id, Language.French.Id } }
};
Mocker.GetMock<IIndexerFactory>()
.Setup(v => v.Get(1))
.Returns(indexerDefinition);
_remoteEpisode.ParsedEpisodeInfo = GetParsedEpisodeInfo(new List<Language> { }, releaseTitle);
_remoteEpisode.Release.IndexerId = 1;
_remoteEpisode.Release.Title = releaseTitle;
Subject.Aggregate(_remoteEpisode).Languages.Should().BeEquivalentTo(new List<Language> { _series.OriginalLanguage, Language.French });
}
[Test]
public void should_return_multi_languages_when_release_as_unknown_as_default_language_and_indexer_has_multi_languages_configuration()
{
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
var indexerDefinition = new IndexerDefinition
{
Settings = new TorrentRssIndexerSettings { MultiLanguages = new List<int> { Language.Original.Id, Language.French.Id } }
};
Mocker.GetMock<IIndexerFactory>()
.Setup(v => v.Get(1))
.Returns(indexerDefinition);
_remoteEpisode.ParsedEpisodeInfo = GetParsedEpisodeInfo(new List<Language> { Language.Unknown }, releaseTitle);
_remoteEpisode.Release.IndexerId = 1;
_remoteEpisode.Release.Title = releaseTitle;
Subject.Aggregate(_remoteEpisode).Languages.Should().BeEquivalentTo(new List<Language> { _series.OriginalLanguage, Language.French });
}
[Test]
public void should_return_original_when_indexer_has_no_multi_languages_configuration()
{
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
var indexerDefinition = new IndexerDefinition
{
Settings = new TorrentRssIndexerSettings { }
};
Mocker.GetMock<IIndexerFactory>()
.Setup(v => v.Get(1))
.Returns(indexerDefinition);
_remoteEpisode.ParsedEpisodeInfo = GetParsedEpisodeInfo(new List<Language> { }, releaseTitle);
_remoteEpisode.Release.IndexerId = 1;
_remoteEpisode.Release.Title = releaseTitle;
Subject.Aggregate(_remoteEpisode).Languages.Should().BeEquivalentTo(new List<Language> { _series.OriginalLanguage });
}
[Test]
public void should_exclude_language_that_is_part_of_episode_title_when_release_tokens_contains_episode_title()
{

View File

@ -10,7 +10,6 @@ using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Download;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.IndexerSearch.Definitions;
using NzbDrone.Core.Localization;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model;
@ -35,7 +34,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests
.Returns(30);
Mocker.GetMock<IParsingService>()
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), (SearchCriteriaBase)null))
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), null))
.Returns(() => CreateRemoteEpisode());
Mocker.GetMock<IHttpClient>()

View File

@ -49,10 +49,13 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests
}
[Test]
public void magnet_download_should_not_return_the_item()
public void magnet_download_should_be_returned_as_queued()
{
PrepareClientToReturnMagnetItem();
Subject.GetItems().Count().Should().Be(0);
var item = Subject.GetItems().Single();
item.Status.Should().Be(DownloadItemStatus.Queued);
}
[Test]

View File

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
@ -60,7 +60,10 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.VuzeTests
public void magnet_download_should_not_return_the_item()
{
PrepareClientToReturnMagnetItem();
Subject.GetItems().Count().Should().Be(0);
var item = Subject.GetItems().Single();
item.Status.Should().Be(DownloadItemStatus.Queued);
}
[Test]

View File

@ -118,7 +118,7 @@ namespace NzbDrone.Core.Test.Download.TrackedDownloads
.Returns(remoteEpisode);
Mocker.GetMock<IParsingService>()
.Setup(s => s.ParseSpecialEpisodeTitle(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>(), null))
.Setup(s => s.ParseSpecialEpisodeTitle(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), null))
.Returns(remoteEpisode.ParsedEpisodeInfo);
var client = new DownloadClientDefinition()
@ -169,7 +169,7 @@ namespace NzbDrone.Core.Test.Download.TrackedDownloads
};
Mocker.GetMock<IParsingService>()
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), null))
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), null))
.Returns(remoteEpisode);
Mocker.GetMock<IHistoryService>()
@ -199,7 +199,7 @@ namespace NzbDrone.Core.Test.Download.TrackedDownloads
Subject.GetTrackedDownloads().Should().HaveCount(1);
Mocker.GetMock<IParsingService>()
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), null))
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), null))
.Returns(default(RemoteEpisode));
Subject.Handle(new EpisodeInfoRefreshedEvent(remoteEpisode.Series, new List<Episode>(), new List<Episode>(), remoteEpisode.Episodes));
@ -228,7 +228,7 @@ namespace NzbDrone.Core.Test.Download.TrackedDownloads
};
Mocker.GetMock<IParsingService>()
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), null))
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), null))
.Returns(default(RemoteEpisode));
Mocker.GetMock<IHistoryService>()
@ -258,7 +258,7 @@ namespace NzbDrone.Core.Test.Download.TrackedDownloads
Subject.GetTrackedDownloads().Should().HaveCount(1);
Mocker.GetMock<IParsingService>()
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), null))
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), null))
.Returns(default(RemoteEpisode));
Subject.Handle(new EpisodeInfoRefreshedEvent(remoteEpisode.Series, new List<Episode>(), new List<Episode>(), remoteEpisode.Episodes));
@ -287,7 +287,7 @@ namespace NzbDrone.Core.Test.Download.TrackedDownloads
};
Mocker.GetMock<IParsingService>()
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), null))
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), null))
.Returns(default(RemoteEpisode));
Mocker.GetMock<IHistoryService>()
@ -317,7 +317,7 @@ namespace NzbDrone.Core.Test.Download.TrackedDownloads
Subject.GetTrackedDownloads().Should().HaveCount(1);
Mocker.GetMock<IParsingService>()
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), null))
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), null))
.Returns(default(RemoteEpisode));
Subject.Handle(new SeriesDeletedEvent(new List<Series> { remoteEpisode.Series }, true, true));

View File

@ -15,6 +15,10 @@ namespace NzbDrone.Core.Test.IndexerSearchTests
[TestCase("Franklin & Bash", "Franklin+and+Bash")]
[TestCase("Chicago P.D.", "Chicago+PD")]
[TestCase("Kourtney And Khlo\u00E9 Take The Hamptons", "Kourtney+And+Khloe+Take+The+Hamptons")]
[TestCase("Betty White`s Off Their Rockers", "Betty+Whites+Off+Their+Rockers")]
[TestCase("Betty White\u00b4s Off Their Rockers", "Betty+Whites+Off+Their+Rockers")]
[TestCase("Betty Whites Off Their Rockers", "Betty+Whites+Off+Their+Rockers")]
[TestCase("Betty Whites Off Their Rockers", "Betty+Whites+Off+Their+Rockers")]
public void should_replace_some_special_characters(string input, string expected)
{
Subject.SceneTitles = new List<string> { input };

View File

@ -7,6 +7,7 @@ using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Download;
using NzbDrone.Core.Download.TrackedDownloads;
using NzbDrone.Core.MediaFiles;

View File

@ -6,6 +6,7 @@ using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Download;
using NzbDrone.Core.History;
@ -74,8 +75,8 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
.Returns(new List<EpisodeHistory>());
_downloadClientItem = Builder<DownloadClientItem>.CreateNew()
.With(d => d.OutputPath = new OsPath(outputPath))
.Build();
.With(d => d.OutputPath = new OsPath(outputPath))
.Build();
}
private void GivenNewDownload()

View File

@ -1,10 +1,11 @@
using System.IO;
using System.IO;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications;
using NzbDrone.Core.Parser.Model;

View File

@ -233,11 +233,11 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications
GivenEpisodes(actualInfo, actualInfo.EpisodeNumbers);
Mocker.GetMock<IParsingService>()
.Setup(v => v.ParseSpecialEpisodeTitle(fileInfo, It.IsAny<string>(), 0, 0, null))
.Setup(v => v.ParseSpecialEpisodeTitle(fileInfo, It.IsAny<string>(), 0, 0, null, null))
.Returns(actualInfo);
Mocker.GetMock<IParsingService>()
.Setup(v => v.ParseSpecialEpisodeTitle(folderInfo, It.IsAny<string>(), 0, 0, null))
.Setup(v => v.ParseSpecialEpisodeTitle(folderInfo, It.IsAny<string>(), 0, 0, null, null))
.Returns(actualInfo);
Subject.IsSatisfiedBy(localEpisode, null).Accepted.Should().BeTrue();

View File

@ -91,7 +91,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
GivenDailySeries();
GivenDailyParseResult();
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId);
Mocker.GetMock<IEpisodeService>()
.Verify(v => v.FindEpisode(It.IsAny<int>(), It.IsAny<string>(), null), Times.Once());
@ -103,7 +103,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
GivenDailySeries();
GivenDailyParseResult();
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId, _singleEpisodeSearchCriteria);
Mocker.GetMock<IEpisodeService>()
.Verify(v => v.FindEpisode(It.IsAny<int>(), It.IsAny<string>(), null), Times.Never());
@ -115,7 +115,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
GivenDailySeries();
_parsedEpisodeInfo.AirDate = DateTime.Today.AddDays(-5).ToString(Episode.AIR_DATE_FORMAT);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId, _singleEpisodeSearchCriteria);
Mocker.GetMock<IEpisodeService>()
.Verify(v => v.FindEpisode(It.IsAny<int>(), It.IsAny<string>(), null), Times.Once());
@ -128,7 +128,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
GivenDailyParseResult();
_parsedEpisodeInfo.DailyPart = 1;
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId);
Mocker.GetMock<IEpisodeService>()
.Verify(v => v.FindEpisode(It.IsAny<int>(), It.IsAny<string>(), 1), Times.Once());
@ -143,7 +143,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
.Setup(s => s.FindEpisodesBySceneNumbering(It.IsAny<int>(), It.IsAny<int>()))
.Returns(new List<Episode>());
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId, _singleEpisodeSearchCriteria);
Mocker.GetMock<IEpisodeService>()
.Verify(v => v.FindEpisode(It.IsAny<int>(), It.IsAny<string>(), null), Times.Never());
@ -154,7 +154,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
{
GivenSceneNumberingSeries();
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId);
Mocker.GetMock<IEpisodeService>()
.Verify(v => v.FindEpisodesBySceneNumbering(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<int>()), Times.Once());
@ -165,7 +165,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
{
GivenSceneNumberingSeries();
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId, _singleEpisodeSearchCriteria);
Mocker.GetMock<IEpisodeService>()
.Verify(v => v.FindEpisodesBySceneNumbering(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<int>()), Times.Never());
@ -177,7 +177,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
GivenSceneNumberingSeries();
_episodes.First().SceneEpisodeNumber = 10;
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId, _singleEpisodeSearchCriteria);
Mocker.GetMock<IEpisodeService>()
.Verify(v => v.FindEpisodesBySceneNumbering(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<int>()), Times.Once());
@ -186,7 +186,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
[Test]
public void should_find_episode()
{
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId);
Mocker.GetMock<IEpisodeService>()
.Verify(v => v.FindEpisode(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<int>()), Times.Once());
@ -195,7 +195,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
[Test]
public void should_match_episode_with_search_criteria()
{
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId, _singleEpisodeSearchCriteria);
Mocker.GetMock<IEpisodeService>()
.Verify(v => v.FindEpisode(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<int>()), Times.Never());
@ -206,7 +206,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
{
_episodes.First().EpisodeNumber = 10;
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId, _singleEpisodeSearchCriteria);
Mocker.GetMock<IEpisodeService>()
.Verify(v => v.FindEpisode(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<int>()), Times.Once());
@ -537,7 +537,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
.With(e => e.EpisodeNumber = 1)
.Build());
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId);
Mocker.GetMock<IEpisodeService>()
.Verify(v => v.FindEpisode(_series.TvdbId, 0, 1), Times.Once());
@ -555,7 +555,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
.Setup(s => s.FindEpisodeByTitle(_series.TvdbId, 0, _parsedEpisodeInfo.ReleaseTitle))
.Returns((Episode)null);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId);
Mocker.GetMock<IEpisodeService>()
.Verify(v => v.FindEpisode(_series.TvdbId, _parsedEpisodeInfo.SeasonNumber, _parsedEpisodeInfo.EpisodeNumbers.First()), Times.Once());

View File

@ -86,7 +86,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
{
GivenMatchBySeriesTitle();
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.FindByTitle(It.IsAny<string>()), Times.Once());
@ -97,7 +97,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
{
GivenMatchByTvdbId();
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.FindByTvdbId(It.IsAny<int>()), Times.Once());
@ -108,7 +108,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
{
GivenMatchByTvRageId();
Subject.Map(_parsedEpisodeInfo, 0, _series.TvRageId);
Subject.Map(_parsedEpisodeInfo, 0, _series.TvRageId, null);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.FindByTvRageId(It.IsAny<int>()), Times.Once());
@ -123,7 +123,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
.Setup(v => v.FindSceneMapping(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
.Returns(new SceneMapping { TvdbId = 10 });
var result = Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId);
var result = Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.FindByTvRageId(It.IsAny<int>()), Times.Never());
@ -136,7 +136,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
{
GivenMatchBySeriesTitle();
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId, _singleEpisodeSearchCriteria);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.FindByTitle(It.IsAny<string>()), Times.Never());
@ -147,7 +147,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
{
GivenParseResultSeriesDoesntMatchSearchCriteria();
Subject.Map(_parsedEpisodeInfo, 10, 10, _singleEpisodeSearchCriteria);
Subject.Map(_parsedEpisodeInfo, 10, 10, null, _singleEpisodeSearchCriteria);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.FindByTitle(It.IsAny<string>()), Times.Once());
@ -169,7 +169,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
.Setup(s => s.FindByTitle(_parsedEpisodeInfo.SeriesTitleInfo.TitleWithoutYear, _parsedEpisodeInfo.SeriesTitleInfo.Year))
.Returns(_series);
Subject.Map(_parsedEpisodeInfo, 10, 10, _singleEpisodeSearchCriteria);
Subject.Map(_parsedEpisodeInfo, 10, 10, null, _singleEpisodeSearchCriteria);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.FindByTitle(It.IsAny<string>(), It.IsAny<int>()), Times.Once());
@ -180,7 +180,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
{
GivenParseResultSeriesDoesntMatchSearchCriteria();
Subject.Map(_parsedEpisodeInfo, 10, 10, _singleEpisodeSearchCriteria);
Subject.Map(_parsedEpisodeInfo, 10, 10, null, _singleEpisodeSearchCriteria);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.FindByTvdbId(It.IsAny<int>()), Times.Once());
@ -191,7 +191,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
{
GivenParseResultSeriesDoesntMatchSearchCriteria();
Subject.Map(_parsedEpisodeInfo, 0, 10, _singleEpisodeSearchCriteria);
Subject.Map(_parsedEpisodeInfo, 0, 10, null, _singleEpisodeSearchCriteria);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.FindByTvRageId(It.IsAny<int>()), Times.Once());
@ -202,12 +202,34 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
{
GivenParseResultSeriesDoesntMatchSearchCriteria();
Subject.Map(_parsedEpisodeInfo, 10, 10, _singleEpisodeSearchCriteria);
Subject.Map(_parsedEpisodeInfo, 10, 10, null, _singleEpisodeSearchCriteria);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.FindByTvRageId(It.IsAny<int>()), Times.Never());
}
[Test]
public void should_FindByImdbId_when_search_criteria_and_FindByTitle_matching_fails()
{
GivenParseResultSeriesDoesntMatchSearchCriteria();
Subject.Map(_parsedEpisodeInfo, 0, 0, "tt12345", _singleEpisodeSearchCriteria);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.FindByImdbId(It.IsAny<string>()), Times.Once());
}
[Test]
public void should_not_FindByImdbId_when_search_criteria_and_FindByTitle_matching_fails_and_tvdb_id_is_specified()
{
GivenParseResultSeriesDoesntMatchSearchCriteria();
Subject.Map(_parsedEpisodeInfo, 10, 10, "tt12345", _singleEpisodeSearchCriteria);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.FindByImdbId(It.IsAny<string>()), Times.Never());
}
[Test]
public void should_use_tvdbid_matching_when_alias_is_found()
{
@ -215,7 +237,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
.Setup(s => s.FindTvdbId(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
.Returns(_series.TvdbId);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId, _singleEpisodeSearchCriteria);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.FindByTitle(It.IsAny<string>()), Times.Never());
@ -226,7 +248,7 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
{
GivenParseResultSeriesDoesntMatchSearchCriteria();
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria);
Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _series.ImdbId, _singleEpisodeSearchCriteria);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.FindByTitle(It.IsAny<string>()), Times.Never());

View File

@ -185,8 +185,10 @@ namespace NzbDrone.Core.Blocklisting
Indexer = message.Data.GetValueOrDefault("indexer"),
Protocol = (DownloadProtocol)Convert.ToInt32(message.Data.GetValueOrDefault("protocol")),
Message = message.Message,
TorrentInfoHash = message.Data.GetValueOrDefault("torrentInfoHash"),
Languages = message.Languages
Languages = message.Languages,
TorrentInfoHash = message.TrackedDownload?.Protocol == DownloadProtocol.Torrent
? message.TrackedDownload.DownloadItem.DownloadId
: message.Data.GetValueOrDefault("torrentInfoHash", null)
};
if (Enum.TryParse(message.Data.GetValueOrDefault("indexerFlags"), true, out IndexerFlags flags))

View File

@ -10,6 +10,7 @@ using NzbDrone.Common.Cache;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation;
using NzbDrone.Common.Options;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration.Events;
@ -38,8 +39,10 @@ namespace NzbDrone.Core.Configuration
bool AnalyticsEnabled { get; }
string LogLevel { get; }
string ConsoleLogLevel { get; }
ConsoleLogFormat ConsoleLogFormat { get; }
bool LogSql { get; }
int LogRotate { get; }
int LogSizeLimit { get; }
bool FilterSentryEvents { get; }
string Branch { get; }
string ApiKey { get; }
@ -220,9 +223,14 @@ namespace NzbDrone.Core.Configuration
public string Branch => _updateOptions.Branch ?? GetValue("Branch", "main").ToLowerInvariant();
public string LogLevel => _logOptions.Level ?? GetValue("LogLevel", "info").ToLowerInvariant();
public string LogLevel => _logOptions.Level ?? GetValue("LogLevel", "debug").ToLowerInvariant();
public string ConsoleLogLevel => _logOptions.ConsoleLevel ?? GetValue("ConsoleLogLevel", string.Empty, persist: false);
public ConsoleLogFormat ConsoleLogFormat =>
Enum.TryParse<ConsoleLogFormat>(_logOptions.ConsoleFormat, out var enumValue)
? enumValue
: GetValueEnum("ConsoleLogFormat", ConsoleLogFormat.Standard, false);
public string Theme => _appOptions.Theme ?? GetValue("Theme", "auto", persist: false);
public string PostgresHost => _postgresOptions?.Host ?? GetValue("PostgresHost", string.Empty, persist: false);
@ -234,6 +242,7 @@ namespace NzbDrone.Core.Configuration
public bool LogDbEnabled => _logOptions.DbEnabled ?? GetValueBoolean("LogDbEnabled", true, persist: false);
public bool LogSql => _logOptions.Sql ?? GetValueBoolean("LogSql", false, persist: false);
public int LogRotate => _logOptions.Rotate ?? GetValueInt("LogRotate", 50, persist: false);
public int LogSizeLimit => Math.Min(Math.Max(_logOptions.SizeLimit ?? GetValueInt("LogSizeLimit", 1, persist: false), 0), 10);
public bool FilterSentryEvents => _logOptions.FilterSentryEvents ?? GetValueBoolean("FilterSentryEvents", true, persist: false);
public string SslCertPath => _serverOptions.SslCertPath ?? GetValue("SslCertPath", "");
public string SslCertPassword => _serverOptions.SslCertPassword ?? GetValue("SslCertPassword", "");

View File

@ -1,4 +1,5 @@
using FluentValidation;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Validation;

View File

@ -63,7 +63,9 @@ namespace NzbDrone.Core.DataAugmentation.Scene
sceneSeasonNumbers.Contains(n.SceneSeasonNumber ?? -1) ||
((n.SeasonNumber ?? -1) == -1 && (n.SceneSeasonNumber ?? -1) == -1 && n.SceneOrigin != "tvdb"))
.Where(n => IsEnglish(n.SearchTerm))
.Select(n => n.SearchTerm).Distinct().ToList();
.Select(n => n.SearchTerm)
.Distinct(StringComparer.InvariantCultureIgnoreCase)
.ToList();
return names;
}

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Parser.Model;

View File

@ -80,7 +80,7 @@ namespace NzbDrone.Core.DecisionEngine
if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode)
{
var specialEpisodeInfo = _parsingService.ParseSpecialEpisodeTitle(parsedEpisodeInfo, report.Title, report.TvdbId, report.TvRageId, searchCriteria);
var specialEpisodeInfo = _parsingService.ParseSpecialEpisodeTitle(parsedEpisodeInfo, report.Title, report.TvdbId, report.TvRageId, report.ImdbId, searchCriteria);
if (specialEpisodeInfo != null)
{
@ -90,7 +90,7 @@ namespace NzbDrone.Core.DecisionEngine
if (parsedEpisodeInfo != null && !parsedEpisodeInfo.SeriesTitle.IsNullOrWhiteSpace())
{
var remoteEpisode = _parsingService.Map(parsedEpisodeInfo, report.TvdbId, report.TvRageId, searchCriteria);
var remoteEpisode = _parsingService.Map(parsedEpisodeInfo, report.TvdbId, report.TvRageId, report.ImdbId, searchCriteria);
remoteEpisode.Release = report;
if (remoteEpisode.Series == null)

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