1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-18 21:35:27 -04:00

Compare commits

..

1 Commits

Author SHA1 Message Date
Mark McDowall 9484904f60 New: Add button to close side bar
Closes #7757
2025-10-01 20:23:27 -07:00
49 changed files with 637 additions and 1470 deletions
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef } from 'react'; import React from 'react';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton'; import SpinnerButton from 'Components/Link/SpinnerButton';
import Modal from 'Components/Modal/Modal'; import Modal from 'Components/Modal/Modal';
@@ -9,7 +9,6 @@ import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import { HistoryData, HistoryEventType } from 'typings/History'; import { HistoryData, HistoryEventType } from 'typings/History';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import { useMarkAsFailed } from '../useHistory';
import HistoryDetails from './HistoryDetails'; import HistoryDetails from './HistoryDetails';
import styles from './HistoryDetailsModal.css'; import styles from './HistoryDetailsModal.css';
@@ -34,32 +33,26 @@ function getHeaderTitle(eventType: HistoryEventType) {
interface HistoryDetailsModalProps { interface HistoryDetailsModalProps {
isOpen: boolean; isOpen: boolean;
id: number;
eventType: HistoryEventType; eventType: HistoryEventType;
sourceTitle: string; sourceTitle: string;
data: HistoryData; data: HistoryData;
downloadId?: string; downloadId?: string;
isMarkingAsFailed: boolean;
onMarkAsFailedPress: () => void;
onModalClose: () => void; onModalClose: () => void;
} }
function HistoryDetailsModal(props: HistoryDetailsModalProps) { function HistoryDetailsModal(props: HistoryDetailsModalProps) {
const { isOpen, id, eventType, sourceTitle, data, downloadId, onModalClose } = const {
props; isOpen,
eventType,
const { markAsFailed, isMarkingAsFailed, markAsFailedError } = sourceTitle,
useMarkAsFailed(id); data,
downloadId,
const wasMarkingAsFailed = useRef(isMarkingAsFailed); isMarkingAsFailed = false,
onMarkAsFailedPress,
const handleMarkAsFailedPress = useCallback(() => { onModalClose,
markAsFailed(); } = props;
}, [markAsFailed]);
useEffect(() => {
if (wasMarkingAsFailed && !isMarkingAsFailed && !markAsFailedError) {
onModalClose();
}
}, [wasMarkingAsFailed, isMarkingAsFailed, markAsFailedError, onModalClose]);
return ( return (
<Modal isOpen={isOpen} onModalClose={onModalClose}> <Modal isOpen={isOpen} onModalClose={onModalClose}>
@@ -81,7 +74,7 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
className={styles.markAsFailedButton} className={styles.markAsFailedButton}
kind={kinds.DANGER} kind={kinds.DANGER}
isSpinning={isMarkingAsFailed} isSpinning={isMarkingAsFailed}
onPress={handleMarkAsFailedPress} onPress={onMarkAsFailedPress}
> >
{translate('MarkAsFailed')} {translate('MarkAsFailed')}
</SpinnerButton> </SpinnerButton>
+69 -49
View File
@@ -1,9 +1,6 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { import AppState from 'App/State/AppState';
setQueueOption,
setQueueOptions,
} from 'Activity/Queue/queueOptionsStore';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu'; import FilterMenu from 'Components/Menu/FilterMenu';
@@ -16,11 +13,20 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager'; import TablePager from 'Components/Table/TablePager';
import usePaging from 'Components/Table/usePaging';
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector'; import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import { align, icons, kinds } from 'Helpers/Props'; import { align, icons, kinds } from 'Helpers/Props';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions'; import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
import {
clearHistory,
fetchHistory,
gotoHistoryPage,
setHistoryFilter,
setHistorySort,
setHistoryTableOption,
} from 'Store/Actions/historyActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import HistoryItem from 'typings/History'; import HistoryItem from 'typings/History';
import { TableOptionsChangePayload } from 'typings/Table'; import { TableOptionsChangePayload } from 'typings/Table';
@@ -31,90 +37,100 @@ import {
} from 'Utilities/pagePopulator'; } from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import HistoryFilterModal from './HistoryFilterModal'; import HistoryFilterModal from './HistoryFilterModal';
import { useHistoryOptions } from './historyOptionsStore';
import HistoryRow from './HistoryRow'; import HistoryRow from './HistoryRow';
import useHistory, { useFilters } from './useHistory';
function History() { function History() {
const requestCurrentPage = useCurrentPage();
const { const {
records, isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
totalPages, totalPages,
totalRecords, totalRecords,
error, } = useSelector((state: AppState) => state.history);
isFetching,
isFetched,
isLoading,
page,
goToPage,
refetch,
} = useHistory();
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useHistoryOptions();
const filters = useFilters();
const requestCurrentPage = useCurrentPage();
const { isEpisodesFetching, isEpisodesPopulated, episodesError } = const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
useSelector(createEpisodesFetchingSelector()); useSelector(createEpisodesFetchingSelector());
const customFilters = useSelector(createCustomFiltersSelector('history')); const customFilters = useSelector(createCustomFiltersSelector('history'));
const dispatch = useDispatch(); const dispatch = useDispatch();
const isFetchingAny = isLoading || isEpisodesFetching; const isFetchingAny = isFetching || isEpisodesFetching;
const isAllPopulated = isFetched && (isEpisodesPopulated || !records.length); const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length);
const hasError = error || episodesError; const hasError = error || episodesError;
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoHistoryPage,
});
const handleFilterSelect = useCallback( const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => { (selectedFilterKey: string | number) => {
setQueueOption('selectedFilterKey', selectedFilterKey); dispatch(setHistoryFilter({ selectedFilterKey }));
}, },
[] [dispatch]
); );
const handleSortPress = useCallback((sortKey: string) => { const handleSortPress = useCallback(
setQueueOption('sortKey', sortKey); (sortKey: string) => {
}, []); dispatch(setHistorySort({ sortKey }));
},
[dispatch]
);
const handleTableOptionChange = useCallback( const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => { (payload: TableOptionsChangePayload) => {
setQueueOptions(payload); dispatch(setHistoryTableOption(payload));
if (payload.pageSize) { if (payload.pageSize) {
goToPage(1); dispatch(gotoHistoryPage({ page: 1 }));
} }
}, },
[goToPage] [dispatch]
); );
const handleRefreshPress = useCallback(() => {
goToPage(1);
refetch();
}, [goToPage, refetch]);
useEffect(() => { useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchHistory());
} else {
dispatch(gotoHistoryPage({ page: 1 }));
}
return () => { return () => {
dispatch(clearHistory());
dispatch(clearEpisodes()); dispatch(clearEpisodes());
dispatch(clearEpisodeFiles()); dispatch(clearEpisodeFiles());
}; };
}, [requestCurrentPage, dispatch]); }, [requestCurrentPage, dispatch]);
useEffect(() => { useEffect(() => {
const episodeIds = selectUniqueIds<HistoryItem, number>( const episodeIds = selectUniqueIds<HistoryItem, number>(items, 'episodeId');
records,
'episodeId'
);
if (episodeIds.length) { if (episodeIds.length) {
dispatch(fetchEpisodes({ episodeIds })); dispatch(fetchEpisodes({ episodeIds }));
} else { } else {
dispatch(clearEpisodes()); dispatch(clearEpisodes());
} }
}, [records, dispatch]); }, [items, dispatch]);
useEffect(() => { useEffect(() => {
const repopulate = () => { const repopulate = () => {
refetch(); dispatch(fetchHistory());
}; };
registerPagePopulator(repopulate); registerPagePopulator(repopulate);
@@ -122,7 +138,7 @@ function History() {
return () => { return () => {
unregisterPagePopulator(repopulate); unregisterPagePopulator(repopulate);
}; };
}, [refetch]); }, [dispatch]);
return ( return (
<PageContent title={translate('History')}> <PageContent title={translate('History')}>
@@ -132,7 +148,7 @@ function History() {
label={translate('Refresh')} label={translate('Refresh')}
iconName={icons.REFRESH} iconName={icons.REFRESH}
isSpinning={isFetching} isSpinning={isFetching}
onPress={handleRefreshPress} onPress={handleFirstPagePress}
/> />
</PageToolbarSection> </PageToolbarSection>
@@ -170,12 +186,12 @@ function History() {
// If history isPopulated and it's empty show no history found and don't // If history isPopulated and it's empty show no history found and don't
// wait for the episodes to populate because they are never coming. // wait for the episodes to populate because they are never coming.
isFetched && !hasError && !records.length ? ( isPopulated && !hasError && !items.length ? (
<Alert kind={kinds.INFO}>{translate('NoHistoryFound')}</Alert> <Alert kind={kinds.INFO}>{translate('NoHistoryFound')}</Alert>
) : null ) : null
} }
{isAllPopulated && !hasError && records.length ? ( {isAllPopulated && !hasError && items.length ? (
<div> <div>
<Table <Table
columns={columns} columns={columns}
@@ -186,7 +202,7 @@ function History() {
onSortPress={handleSortPress} onSortPress={handleSortPress}
> >
<TableBody> <TableBody>
{records.map((item) => { {items.map((item) => {
return ( return (
<HistoryRow key={item.id} columns={columns} {...item} /> <HistoryRow key={item.id} columns={columns} {...item} />
); );
@@ -199,7 +215,11 @@ function History() {
totalPages={totalPages} totalPages={totalPages}
totalRecords={totalRecords} totalRecords={totalRecords}
isFetching={isFetching} isFetching={isFetching}
onPageSelect={goToPage} onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/> />
</div> </div>
) : null} ) : null}
@@ -1,25 +1,48 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal'; import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setHistoryOption } from './historyOptionsStore'; import { setHistoryFilter } from 'Store/Actions/historyActions';
import useHistory, { FILTER_BUILDER } from './useHistory';
function createHistorySelector() {
return createSelector(
(state: AppState) => state.history.items,
(queueItems) => {
return queueItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.history.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
type HistoryFilterModalProps = FilterModalProps<History>; type HistoryFilterModalProps = FilterModalProps<History>;
export default function HistoryFilterModal(props: HistoryFilterModalProps) { export default function HistoryFilterModal(props: HistoryFilterModalProps) {
const { records } = useHistory(); const sectionItems = useSelector(createHistorySelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const dispatch = useDispatch();
const dispatchSetFilter = useCallback( const dispatchSetFilter = useCallback(
({ selectedFilterKey }: { selectedFilterKey: string | number }) => { (payload: { selectedFilterKey: string | number }) => {
setHistoryOption('selectedFilterKey', selectedFilterKey); dispatch(setHistoryFilter(payload));
}, },
[] [dispatch]
); );
return ( return (
<FilterModal <FilterModal
{...props} {...props}
sectionItems={records} sectionItems={sectionItems}
filterBuilderProps={FILTER_BUILDER} filterBuilderProps={filterBuilderProps}
customFilterType="history" customFilterType="history"
dispatchSetFilter={dispatchSetFilter} dispatchSetFilter={dispatchSetFilter}
/> />
+27 -2
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
@@ -12,11 +13,13 @@ import EpisodeQuality from 'Episode/EpisodeQuality';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import useEpisode from 'Episode/useEpisode'; import useEpisode from 'Episode/useEpisode';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons, tooltipPositions } from 'Helpers/Props'; import { icons, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language'; import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality'; import { QualityModel } from 'Quality/Quality';
import SeriesTitleLink from 'Series/SeriesTitleLink'; import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
import CustomFormat from 'typings/CustomFormat'; import CustomFormat from 'typings/CustomFormat';
import { HistoryData, HistoryEventType } from 'typings/History'; import { HistoryData, HistoryEventType } from 'typings/History';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
@@ -58,9 +61,13 @@ function HistoryRow(props: HistoryRowProps) {
date, date,
data, data,
downloadId, downloadId,
isMarkingAsFailed = false,
markAsFailedError,
columns, columns,
} = props; } = props;
const wasMarkingAsFailed = usePrevious(isMarkingAsFailed);
const dispatch = useDispatch();
const series = useSeries(seriesId); const series = useSeries(seriesId);
const episode = useEpisode(episodeId, 'episodes'); const episode = useEpisode(episodeId, 'episodes');
@@ -74,6 +81,23 @@ function HistoryRow(props: HistoryRowProps) {
setIsDetailsModalOpen(false); setIsDetailsModalOpen(false);
}, [setIsDetailsModalOpen]); }, [setIsDetailsModalOpen]);
const handleMarkAsFailedPress = useCallback(() => {
dispatch(markAsFailed({ id }));
}, [id, dispatch]);
useEffect(() => {
if (wasMarkingAsFailed && !isMarkingAsFailed && !markAsFailedError) {
setIsDetailsModalOpen(false);
dispatch(fetchHistory());
}
}, [
wasMarkingAsFailed,
isMarkingAsFailed,
markAsFailedError,
setIsDetailsModalOpen,
dispatch,
]);
if (!series || !episode) { if (!series || !episode) {
return null; return null;
} }
@@ -230,12 +254,13 @@ function HistoryRow(props: HistoryRowProps) {
})} })}
<HistoryDetailsModal <HistoryDetailsModal
id={id}
isOpen={isDetailsModalOpen} isOpen={isDetailsModalOpen}
eventType={eventType} eventType={eventType}
sourceTitle={sourceTitle} sourceTitle={sourceTitle}
data={data} data={data}
downloadId={downloadId} downloadId={downloadId}
isMarkingAsFailed={isMarkingAsFailed}
onMarkAsFailedPress={handleMarkAsFailedPress}
onModalClose={handleDetailsModalClose} onModalClose={handleDetailsModalClose}
/> />
</TableRow> </TableRow>
@@ -1,109 +0,0 @@
import React from 'react';
import Icon from 'Components/Icon';
import {
createOptionsStore,
PageableOptions,
} from 'Helpers/Hooks/useOptionsStore';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
export type HistoryOptions = PageableOptions;
const { useOptions, useOption, setOptions, setOption } =
createOptionsStore<HistoryOptions>('history_options', () => {
return {
includeUnknownSeriesItems: true,
pageSize: 20,
selectedFilterKey: 'all',
sortKey: 'time',
sortDirection: 'descending',
columns: [
{
name: 'eventType',
label: '',
columnLabel: () => translate('EventType'),
isVisible: true,
isModifiable: false,
},
{
name: 'series.sortTitle',
label: () => translate('Series'),
isSortable: true,
isVisible: true,
},
{
name: 'episode',
label: () => translate('Episode'),
isVisible: true,
},
{
name: 'episodes.title',
label: () => translate('EpisodeTitle'),
isVisible: true,
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: false,
},
{
name: 'quality',
label: () => translate('Quality'),
isVisible: true,
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true,
},
{
name: 'date',
label: () => translate('Date'),
isSortable: true,
isVisible: true,
},
{
name: 'downloadClient',
label: () => translate('DownloadClient'),
isVisible: false,
},
{
name: 'indexer',
label: () => translate('Indexer'),
isVisible: false,
},
{
name: 'releaseGroup',
label: () => translate('ReleaseGroup'),
isVisible: false,
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isVisible: false,
},
{
name: 'customFormatScore',
columnLabel: () => translate('CustomFormatScore'),
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isVisible: false,
},
{
name: 'details',
label: '',
columnLabel: () => translate('Details'),
isVisible: true,
isModifiable: false,
},
],
};
});
export const useHistoryOptions = useOptions;
export const setHistoryOptions = setOptions;
export const useHistoryOption = useOption;
export const setHistoryOption = setOption;
-186
View File
@@ -1,186 +0,0 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
import { filterBuilderValueTypes } from 'Helpers/Props';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import History from 'typings/History';
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
import translate from 'Utilities/String/translate';
import { useHistoryOptions } from './historyOptionsStore';
export const FILTERS: Filter[] = [
{
key: 'all',
label: () => translate('All'),
filters: [],
},
{
key: 'grabbed',
label: () => translate('Grabbed'),
filters: [
{
key: 'eventType',
value: '1',
type: 'equal',
},
],
},
{
key: 'imported',
label: () => translate('Imported'),
filters: [
{
key: 'eventType',
value: '3',
type: 'equal',
},
],
},
{
key: 'failed',
label: () => translate('Failed'),
filters: [
{
key: 'eventType',
value: '4',
type: 'equal',
},
],
},
{
key: 'deleted',
label: () => translate('Deleted'),
filters: [
{
key: 'eventType',
value: '5',
type: 'equal',
},
],
},
{
key: 'renamed',
label: () => translate('Renamed'),
filters: [
{
key: 'eventType',
value: '6',
type: 'equal',
},
],
},
{
key: 'ignored',
label: () => translate('Ignored'),
filters: [
{
key: 'eventType',
value: '7',
type: 'equal',
},
],
},
];
export const FILTER_BUILDER: FilterBuilderProp<History>[] = [
{
name: 'eventType',
label: () => translate('EventType'),
type: 'equal',
valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE,
},
{
name: 'seriesIds',
label: () => translate('Series'),
type: 'equal',
valueType: filterBuilderValueTypes.SERIES,
},
{
name: 'quality',
label: () => translate('Quality'),
type: 'equal',
valueType: filterBuilderValueTypes.QUALITY,
},
{
name: 'languages',
label: () => translate('Languages'),
type: 'contains',
valueType: filterBuilderValueTypes.LANGUAGE,
},
];
const useHistory = () => {
const { page, goToPage } = usePage('history');
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
useHistoryOptions();
const customFilters = useSelector(
createCustomFiltersSelector('history')
) as CustomFilter[];
const filters = useMemo(() => {
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
}, [selectedFilterKey, customFilters]);
const { refetch, ...query } = usePagedApiQuery<History>({
path: '/history',
page,
pageSize,
filters,
sortKey,
sortDirection,
queryOptions: {
placeholderData: keepPreviousData,
},
});
const handleGoToPage = useCallback(
(page: number) => {
goToPage(page);
},
[goToPage]
);
return {
...query,
goToPage: handleGoToPage,
page,
refetch,
};
};
export default useHistory;
export const useFilters = () => {
return FILTERS;
};
export const useMarkAsFailed = (id: number) => {
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const { mutate, isPending } = useApiMutation<unknown, void>({
path: `/history/failed/${id}`,
method: 'POST',
mutationOptions: {
onMutate: () => {
setError(null);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/history'] });
},
onError: () => {
setError('Error marking history item as failed');
},
},
});
return {
markAsFailed: mutate,
isMarkingAsFailed: isPending,
markAsFailedError: error,
};
};
+10 -4
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
@@ -9,6 +9,7 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import usePrevious from 'Helpers/Hooks/usePrevious'; import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import { fetchUpdates } from 'Store/Actions/systemActions';
import UpdateChanges from 'System/Updates/UpdateChanges'; import UpdateChanges from 'System/Updates/UpdateChanges';
import useUpdates from 'System/Updates/useUpdates'; import useUpdates from 'System/Updates/useUpdates';
import Update from 'typings/Update'; import Update from 'typings/Update';
@@ -63,8 +64,9 @@ interface AppUpdatedModalContentProps {
} }
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) { function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
const dispatch = useDispatch();
const { version, prevVersion } = useSelector((state: AppState) => state.app); const { version, prevVersion } = useSelector((state: AppState) => state.app);
const { isFetched, error, data, refetch } = useUpdates(); const { isFetched, error, data } = useUpdates();
const previousVersion = usePrevious(version); const previousVersion = usePrevious(version);
const { onModalClose } = props; const { onModalClose } = props;
@@ -75,11 +77,15 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
window.location.href = `${window.Sonarr.urlBase}/system/updates`; window.location.href = `${window.Sonarr.urlBase}/system/updates`;
}, []); }, []);
useEffect(() => {
dispatch(fetchUpdates());
}, [dispatch]);
useEffect(() => { useEffect(() => {
if (version !== previousVersion) { if (version !== previousVersion) {
refetch(); dispatch(fetchUpdates());
} }
}, [version, previousVersion, refetch]); }, [version, previousVersion, dispatch]);
return ( return (
<ModalContent onModalClose={onModalClose}> <ModalContent onModalClose={onModalClose}>
+1
View File
@@ -90,6 +90,7 @@ interface AppState {
episodeHistory: HistoryAppState; episodeHistory: HistoryAppState;
episodes: EpisodesAppState; episodes: EpisodesAppState;
episodesSelection: EpisodesAppState; episodesSelection: EpisodesAppState;
history: HistoryAppState;
importSeries: ImportSeriesAppState; importSeries: ImportSeriesAppState;
interactiveImport: InteractiveImportAppState; interactiveImport: InteractiveImportAppState;
oAuth: OAuthAppState; oAuth: OAuthAppState;
+7 -10
View File
@@ -1,4 +1,5 @@
import classNames from 'classnames'; import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider'; import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
@@ -14,7 +15,6 @@ import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
import formatTime from 'Utilities/Date/formatTime'; import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber'; import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
@@ -58,8 +58,9 @@ function AgendaEvent(props: AgendaEventProps) {
const series = useSeries(seriesId)!; const series = useSeries(seriesId)!;
const episodeFile = useEpisodeFile(episodeFileId); const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useQueueItemForEpisode(id); const queueItem = useQueueItemForEpisode(id);
const { timeFormat, longDateFormat, enableColorImpairedMode, timeZone } = const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector(
useSelector(createUISettingsSelector()); createUISettingsSelector()
);
const { const {
showEpisodeInformation, showEpisodeInformation,
@@ -70,11 +71,8 @@ function AgendaEvent(props: AgendaEventProps) {
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const startTime = convertToTimezone(airDateUtc, timeZone); const startTime = moment(airDateUtc);
const endTime = convertToTimezone(airDateUtc, timeZone).add( const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
series.runtime,
'minutes'
);
const downloading = !!(queueItem || grabbed); const downloading = !!(queueItem || grabbed);
const isMonitored = series.monitored && monitored; const isMonitored = series.monitored && monitored;
const statusStyle = getStatusStyle( const statusStyle = getStatusStyle(
@@ -112,10 +110,9 @@ function AgendaEvent(props: AgendaEventProps) {
)} )}
> >
<div className={styles.time}> <div className={styles.time}>
{formatTime(airDateUtc, timeFormat, { timeZone })} -{' '} {formatTime(airDateUtc, timeFormat)} -{' '}
{formatTime(endTime.toISOString(), timeFormat, { {formatTime(endTime.toISOString(), timeFormat, {
includeMinuteZero: true, includeMinuteZero: true,
timeZone,
})} })}
</div> </div>
@@ -1,4 +1,5 @@
import classNames from 'classnames'; import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider'; import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
@@ -13,7 +14,6 @@ import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
import formatTime from 'Utilities/Date/formatTime'; import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber'; import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
@@ -60,7 +60,7 @@ function CalendarEvent(props: CalendarEventProps) {
const episodeFile = useEpisodeFile(episodeFileId); const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useQueueItemForEpisode(id); const queueItem = useQueueItemForEpisode(id);
const { timeFormat, enableColorImpairedMode, timeZone } = useSelector( const { timeFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector() createUISettingsSelector()
); );
@@ -88,11 +88,8 @@ function CalendarEvent(props: CalendarEventProps) {
return null; return null;
} }
const startTime = convertToTimezone(airDateUtc, timeZone); const startTime = moment(airDateUtc);
const endTime = convertToTimezone(airDateUtc, timeZone).add( const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
series.runtime,
'minutes'
);
const isDownloading = !!(queueItem || grabbed); const isDownloading = !!(queueItem || grabbed);
const isMonitored = series.monitored && monitored; const isMonitored = series.monitored && monitored;
const statusStyle = getStatusStyle( const statusStyle = getStatusStyle(
@@ -220,10 +217,9 @@ function CalendarEvent(props: CalendarEventProps) {
) : null} ) : null}
<div className={styles.airTime}> <div className={styles.airTime}>
{formatTime(airDateUtc, timeFormat, { timeZone })} -{' '} {formatTime(airDateUtc, timeFormat)} -{' '}
{formatTime(endTime.toISOString(), timeFormat, { {formatTime(endTime.toISOString(), timeFormat, {
includeMinuteZero: true, includeMinuteZero: true,
timeZone,
})} })}
</div> </div>
</div> </div>
@@ -1,4 +1,5 @@
import classNames from 'classnames'; import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useIsDownloadingEpisodes } from 'Activity/Queue/Details/QueueDetailsProvider'; import { useIsDownloadingEpisodes } from 'Activity/Queue/Details/QueueDetailsProvider';
@@ -11,7 +12,6 @@ import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { CalendarItem } from 'typings/Calendar'; import { CalendarItem } from 'typings/Calendar';
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
import formatTime from 'Utilities/Date/formatTime'; import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber'; import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
@@ -34,7 +34,7 @@ function CalendarEventGroup({
const isDownloading = useIsDownloadingEpisodes(episodeIds); const isDownloading = useIsDownloadingEpisodes(episodeIds);
const series = useSeries(seriesId)!; const series = useSeries(seriesId)!;
const { timeFormat, enableColorImpairedMode, timeZone } = useSelector( const { timeFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector() createUISettingsSelector()
); );
@@ -46,11 +46,8 @@ function CalendarEventGroup({
const firstEpisode = events[0]; const firstEpisode = events[0];
const lastEpisode = events[events.length - 1]; const lastEpisode = events[events.length - 1];
const airDateUtc = firstEpisode.airDateUtc; const airDateUtc = firstEpisode.airDateUtc;
const startTime = convertToTimezone(airDateUtc, timeZone); const startTime = moment(airDateUtc);
const endTime = convertToTimezone(lastEpisode.airDateUtc, timeZone).add( const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
series.runtime,
'minutes'
);
const seasonNumber = firstEpisode.seasonNumber; const seasonNumber = firstEpisode.seasonNumber;
const { allDownloaded, anyGrabbed, anyMonitored, allAbsoluteEpisodeNumbers } = const { allDownloaded, anyGrabbed, anyMonitored, allAbsoluteEpisodeNumbers } =
@@ -197,10 +194,9 @@ function CalendarEventGroup({
<div className={styles.airingInfo}> <div className={styles.airingInfo}>
<div className={styles.airTime}> <div className={styles.airTime}>
{formatTime(airDateUtc, timeFormat, { timeZone })} -{' '} {formatTime(airDateUtc, timeFormat)} -{' '}
{formatTime(endTime.toISOString(), timeFormat, { {formatTime(endTime.toISOString(), timeFormat, {
includeMinuteZero: true, includeMinuteZero: true,
timeZone,
})} })}
</div> </div>
-2
View File
@@ -5,14 +5,12 @@ import { create } from 'zustand';
interface PageStore { interface PageStore {
blocklist: number; blocklist: number;
events: number; events: number;
history: number;
queue: number; queue: number;
} }
const pageStore = create<PageStore>(() => ({ const pageStore = create<PageStore>(() => ({
blocklist: 1, blocklist: 1,
events: 1, events: 1,
history: 1,
queue: 1, queue: 1,
})); }));
@@ -116,7 +116,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
onGrabPress, onGrabPress,
} = props; } = props;
const { longDateFormat, timeFormat, timeZone } = useSelector( const { longDateFormat, timeFormat } = useSelector(
createUISettingsSelector() createUISettingsSelector()
); );
@@ -174,7 +174,6 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
className={styles.age} className={styles.age}
title={formatDateTime(publishDate, longDateFormat, timeFormat, { title={formatDateTime(publishDate, longDateFormat, timeFormat, {
includeSeconds: true, includeSeconds: true,
timeZone,
})} })}
> >
{formatAge(age, ageHours, ageMinutes)} {formatAge(age, ageHours, ageMinutes)}
-13
View File
@@ -21,7 +21,6 @@ import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import themes from 'Styles/Themes'; import themes from 'Styles/Themes';
import { InputChanged } from 'typings/inputs'; import { InputChanged } from 'typings/inputs';
import timeZoneOptions from 'Utilities/Date/timeZoneOptions';
import titleCase from 'Utilities/String/titleCase'; import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
@@ -219,18 +218,6 @@ function UISettings() {
/> />
</FormGroup> </FormGroup>
<FormGroup>
<FormLabel>{translate('TimeZone')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="timeZone"
values={timeZoneOptions}
onChange={handleInputChange}
{...settings.timeZone}
/>
</FormGroup>
<FormGroup> <FormGroup>
<FormLabel>{translate('ShowRelativeDates')}</FormLabel> <FormLabel>{translate('ShowRelativeDates')}</FormLabel>
<FormInputGroup <FormInputGroup
@@ -0,0 +1,327 @@
import React from 'react';
import { createAction } from 'redux-actions';
import Icon from 'Components/Icon';
import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, icons, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/State/serverSideCollectionHandlers';
import translate from 'Utilities/String/translate';
import { updateItem } from './baseActions';
import createHandleActions from './Creators/createHandleActions';
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
import createClearReducer from './Creators/Reducers/createClearReducer';
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
//
// Variables
export const section = 'history';
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
pageSize: 20,
sortKey: 'date',
sortDirection: sortDirections.DESCENDING,
items: [],
columns: [
{
name: 'eventType',
columnLabel: () => translate('EventType'),
isVisible: true,
isModifiable: false
},
{
name: 'series.sortTitle',
label: () => translate('Series'),
isSortable: true,
isVisible: true
},
{
name: 'episode',
label: () => translate('Episode'),
isVisible: true
},
{
name: 'episodes.title',
label: () => translate('EpisodeTitle'),
isVisible: true
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: false
},
{
name: 'quality',
label: () => translate('Quality'),
isVisible: true
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true
},
{
name: 'date',
label: () => translate('Date'),
isSortable: true,
isVisible: true
},
{
name: 'downloadClient',
label: () => translate('DownloadClient'),
isVisible: false
},
{
name: 'indexer',
label: () => translate('Indexer'),
isVisible: false
},
{
name: 'releaseGroup',
label: () => translate('ReleaseGroup'),
isVisible: false
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isVisible: false
},
{
name: 'customFormatScore',
columnLabel: () => translate('CustomFormatScore'),
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
}),
isVisible: false
},
{
name: 'details',
columnLabel: () => translate('Details'),
isVisible: true,
isModifiable: false
}
],
selectedFilterKey: 'all',
filters: [
{
key: 'all',
label: () => translate('All'),
filters: []
},
{
key: 'grabbed',
label: () => translate('Grabbed'),
filters: [
{
key: 'eventType',
value: '1',
type: filterTypes.EQUAL
}
]
},
{
key: 'imported',
label: () => translate('Imported'),
filters: [
{
key: 'eventType',
value: '3',
type: filterTypes.EQUAL
}
]
},
{
key: 'failed',
label: () => translate('Failed'),
filters: [
{
key: 'eventType',
value: '4',
type: filterTypes.EQUAL
}
]
},
{
key: 'deleted',
label: () => translate('Deleted'),
filters: [
{
key: 'eventType',
value: '5',
type: filterTypes.EQUAL
}
]
},
{
key: 'renamed',
label: () => translate('Renamed'),
filters: [
{
key: 'eventType',
value: '6',
type: filterTypes.EQUAL
}
]
},
{
key: 'ignored',
label: () => translate('Ignored'),
filters: [
{
key: 'eventType',
value: '7',
type: filterTypes.EQUAL
}
]
}
],
filterBuilderProps: [
{
name: 'eventType',
label: () => translate('EventType'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE
},
{
name: 'seriesIds',
label: () => translate('Series'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.SERIES
},
{
name: 'quality',
label: () => translate('Quality'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.QUALITY
},
{
name: 'languages',
label: () => translate('Languages'),
type: filterBuilderTypes.CONTAINS,
valueType: filterBuilderValueTypes.LANGUAGE
}
]
};
export const persistState = [
'history.pageSize',
'history.sortKey',
'history.sortDirection',
'history.selectedFilterKey',
'history.columns'
];
//
// Actions Types
export const FETCH_HISTORY = 'history/fetchHistory';
export const GOTO_FIRST_HISTORY_PAGE = 'history/gotoHistoryFirstPage';
export const GOTO_PREVIOUS_HISTORY_PAGE = 'history/gotoHistoryPreviousPage';
export const GOTO_NEXT_HISTORY_PAGE = 'history/gotoHistoryNextPage';
export const GOTO_LAST_HISTORY_PAGE = 'history/gotoHistoryLastPage';
export const GOTO_HISTORY_PAGE = 'history/gotoHistoryPage';
export const SET_HISTORY_SORT = 'history/setHistorySort';
export const SET_HISTORY_FILTER = 'history/setHistoryFilter';
export const SET_HISTORY_TABLE_OPTION = 'history/setHistoryTableOption';
export const CLEAR_HISTORY = 'history/clearHistory';
export const MARK_AS_FAILED = 'history/markAsFailed';
//
// Action Creators
export const fetchHistory = createThunk(FETCH_HISTORY);
export const gotoHistoryFirstPage = createThunk(GOTO_FIRST_HISTORY_PAGE);
export const gotoHistoryPreviousPage = createThunk(GOTO_PREVIOUS_HISTORY_PAGE);
export const gotoHistoryNextPage = createThunk(GOTO_NEXT_HISTORY_PAGE);
export const gotoHistoryLastPage = createThunk(GOTO_LAST_HISTORY_PAGE);
export const gotoHistoryPage = createThunk(GOTO_HISTORY_PAGE);
export const setHistorySort = createThunk(SET_HISTORY_SORT);
export const setHistoryFilter = createThunk(SET_HISTORY_FILTER);
export const setHistoryTableOption = createAction(SET_HISTORY_TABLE_OPTION);
export const clearHistory = createAction(CLEAR_HISTORY);
export const markAsFailed = createThunk(MARK_AS_FAILED);
//
// Action Handlers
export const actionHandlers = handleThunks({
...createServerSideCollectionHandlers(
section,
'/history',
fetchHistory,
{
[serverSideCollectionHandlers.FETCH]: FETCH_HISTORY,
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_HISTORY_PAGE,
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_HISTORY_PAGE,
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_HISTORY_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_HISTORY_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_HISTORY_PAGE,
[serverSideCollectionHandlers.SORT]: SET_HISTORY_SORT,
[serverSideCollectionHandlers.FILTER]: SET_HISTORY_FILTER
}),
[MARK_AS_FAILED]: function(getState, payload, dispatch) {
const id = payload.id;
dispatch(updateItem({
section,
id,
isMarkingAsFailed: true
}));
const promise = createAjaxRequest({
url: `/history/failed/${id}`,
method: 'POST',
dataType: 'json'
}).request;
promise.done(() => {
dispatch(updateItem({
section,
id,
isMarkingAsFailed: false,
markAsFailedError: null
}));
});
promise.fail((xhr) => {
dispatch(updateItem({
section,
id,
isMarkingAsFailed: false,
markAsFailedError: xhr
}));
});
}
});
//
// Reducers
export const reducers = createHandleActions({
[SET_HISTORY_TABLE_OPTION]: createSetTableOptionReducer(section),
[CLEAR_HISTORY]: createClearReducer(section, {
isFetching: false,
isPopulated: false,
error: null,
items: [],
totalPages: 0,
totalRecords: 0
})
}, defaultState, section);
+2
View File
@@ -7,6 +7,7 @@ import * as episodes from './episodeActions';
import * as episodeFiles from './episodeFileActions'; import * as episodeFiles from './episodeFileActions';
import * as episodeHistory from './episodeHistoryActions'; import * as episodeHistory from './episodeHistoryActions';
import * as episodeSelection from './episodeSelectionActions'; import * as episodeSelection from './episodeSelectionActions';
import * as history from './historyActions';
import * as importSeries from './importSeriesActions'; import * as importSeries from './importSeriesActions';
import * as interactiveImportActions from './interactiveImportActions'; import * as interactiveImportActions from './interactiveImportActions';
import * as oAuth from './oAuthActions'; import * as oAuth from './oAuthActions';
@@ -34,6 +35,7 @@ export default [
episodeFiles, episodeFiles,
episodeHistory, episodeHistory,
episodeSelection, episodeSelection,
history,
importSeries, importSeries,
interactiveImportActions, interactiveImportActions,
oAuth, oAuth,
@@ -62,6 +62,20 @@ export const defaultState = {
isPopulated: false, isPopulated: false,
error: null, error: null,
items: [] items: []
},
logFiles: {
isFetching: false,
isPopulated: false,
error: null,
items: []
},
updateLogFiles: {
isFetching: false,
isPopulated: false,
error: null,
items: []
} }
}; };
@@ -80,6 +94,11 @@ export const RESTORE_BACKUP = 'system/backups/restoreBackup';
export const CLEAR_RESTORE_BACKUP = 'system/backups/clearRestoreBackup'; export const CLEAR_RESTORE_BACKUP = 'system/backups/clearRestoreBackup';
export const DELETE_BACKUP = 'system/backups/deleteBackup'; export const DELETE_BACKUP = 'system/backups/deleteBackup';
export const FETCH_UPDATES = 'system/updates/fetchUpdates';
export const FETCH_LOG_FILES = 'system/logFiles/fetchLogFiles';
export const FETCH_UPDATE_LOG_FILES = 'system/updateLogFiles/fetchUpdateLogFiles';
export const RESTART = 'system/restart'; export const RESTART = 'system/restart';
export const SHUTDOWN = 'system/shutdown'; export const SHUTDOWN = 'system/shutdown';
@@ -98,6 +117,11 @@ export const restoreBackup = createThunk(RESTORE_BACKUP);
export const clearRestoreBackup = createAction(CLEAR_RESTORE_BACKUP); export const clearRestoreBackup = createAction(CLEAR_RESTORE_BACKUP);
export const deleteBackup = createThunk(DELETE_BACKUP); export const deleteBackup = createThunk(DELETE_BACKUP);
export const fetchUpdates = createThunk(FETCH_UPDATES);
export const fetchLogFiles = createThunk(FETCH_LOG_FILES);
export const fetchUpdateLogFiles = createThunk(FETCH_UPDATE_LOG_FILES);
export const restart = createThunk(RESTART); export const restart = createThunk(RESTART);
export const shutdown = createThunk(SHUTDOWN); export const shutdown = createThunk(SHUTDOWN);
@@ -176,6 +200,10 @@ export const actionHandlers = handleThunks({
[DELETE_BACKUP]: createRemoveItemHandler(backupsSection, '/system/backup'), [DELETE_BACKUP]: createRemoveItemHandler(backupsSection, '/system/backup'),
[FETCH_UPDATES]: createFetchHandler('system.updates', '/update'),
[FETCH_LOG_FILES]: createFetchHandler('system.logFiles', '/log/file'),
[FETCH_UPDATE_LOG_FILES]: createFetchHandler('system.updateLogFiles', '/log/file/update'),
[RESTART]: function(getState, payload, dispatch) { [RESTART]: function(getState, payload, dispatch) {
const promise = createAjaxRequest({ const promise = createAjaxRequest({
url: '/system/restart', url: '/system/restart',
+15 -8
View File
@@ -1,39 +1,46 @@
import React, { useCallback } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { fetchLogFiles } from 'Store/Actions/systemActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import LogFiles from '../LogFiles'; import LogFiles from '../LogFiles';
import useLogFiles from '../useLogFiles';
function AppLogFiles() { function AppLogFiles() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { data = [], isFetching, refetch } = useLogFiles(); const { isFetching, items } = useSelector(
(state: AppState) => state.system.logFiles
);
const isDeleteFilesExecuting = useSelector( const isDeleteFilesExecuting = useSelector(
createCommandExecutingSelector(commandNames.DELETE_LOG_FILES) createCommandExecutingSelector(commandNames.DELETE_LOG_FILES)
); );
const handleRefreshPress = useCallback(() => { const handleRefreshPress = useCallback(() => {
refetch(); dispatch(fetchLogFiles());
}, [refetch]); }, [dispatch]);
const handleDeleteFilesPress = useCallback(() => { const handleDeleteFilesPress = useCallback(() => {
dispatch( dispatch(
executeCommand({ executeCommand({
name: commandNames.DELETE_LOG_FILES, name: commandNames.DELETE_LOG_FILES,
commandFinished: () => { commandFinished: () => {
refetch(); dispatch(fetchLogFiles());
}, },
}) })
); );
}, [dispatch, refetch]); }, [dispatch]);
useEffect(() => {
dispatch(fetchLogFiles());
}, [dispatch]);
return ( return (
<LogFiles <LogFiles
isDeleteFilesExecuting={isDeleteFilesExecuting} isDeleteFilesExecuting={isDeleteFilesExecuting}
isFetching={isFetching} isFetching={isFetching}
items={data} items={items}
type="app" type="app"
onRefreshPress={handleRefreshPress} onRefreshPress={handleRefreshPress}
onDeleteFilesPress={handleDeleteFilesPress} onDeleteFilesPress={handleDeleteFilesPress}
@@ -1,39 +1,46 @@
import React, { useCallback } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { fetchUpdateLogFiles } from 'Store/Actions/systemActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import LogFiles from '../LogFiles'; import LogFiles from '../LogFiles';
import { useUpdateLogFiles } from '../useLogFiles';
function UpdateLogFiles() { function UpdateLogFiles() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { data = [], isFetching, refetch } = useUpdateLogFiles(); const { isFetching, items } = useSelector(
(state: AppState) => state.system.updateLogFiles
);
const isDeleteFilesExecuting = useSelector( const isDeleteFilesExecuting = useSelector(
createCommandExecutingSelector(commandNames.DELETE_UPDATE_LOG_FILES) createCommandExecutingSelector(commandNames.DELETE_UPDATE_LOG_FILES)
); );
const handleRefreshPress = useCallback(() => { const handleRefreshPress = useCallback(() => {
refetch(); dispatch(fetchUpdateLogFiles());
}, [refetch]); }, [dispatch]);
const handleDeleteFilesPress = useCallback(() => { const handleDeleteFilesPress = useCallback(() => {
dispatch( dispatch(
executeCommand({ executeCommand({
name: commandNames.DELETE_UPDATE_LOG_FILES, name: commandNames.DELETE_UPDATE_LOG_FILES,
commandFinished: () => { commandFinished: () => {
refetch(); dispatch(fetchUpdateLogFiles());
}, },
}) })
); );
}, [dispatch, refetch]); }, [dispatch]);
useEffect(() => {
dispatch(fetchUpdateLogFiles());
}, [dispatch]);
return ( return (
<LogFiles <LogFiles
isDeleteFilesExecuting={isDeleteFilesExecuting} isDeleteFilesExecuting={isDeleteFilesExecuting}
isFetching={isFetching} isFetching={isFetching}
items={data} items={items}
type="update" type="update"
onRefreshPress={handleRefreshPress} onRefreshPress={handleRefreshPress}
onDeleteFilesPress={handleDeleteFilesPress} onDeleteFilesPress={handleDeleteFilesPress}
-14
View File
@@ -1,14 +0,0 @@
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import LogFile from 'typings/LogFile';
export default function useLogFiles() {
return useApiQuery<LogFile[]>({
path: '/log/file',
});
}
export function useUpdateLogFiles() {
return useApiQuery<LogFile[]>({
path: '/log/file/update',
});
}
+2
View File
@@ -15,6 +15,7 @@ import { icons, kinds } from 'Helpers/Props';
import useUpdateSettings from 'Settings/General/useUpdateSettings'; import useUpdateSettings from 'Settings/General/useUpdateSettings';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { fetchGeneralSettings } from 'Store/Actions/settingsActions'; import { fetchGeneralSettings } from 'Store/Actions/settingsActions';
import { fetchUpdates } from 'Store/Actions/systemActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
@@ -113,6 +114,7 @@ function Updates() {
}, [setIsMajorUpdateModalOpen]); }, [setIsMajorUpdateModalOpen]);
useEffect(() => { useEffect(() => {
dispatch(fetchUpdates());
dispatch(fetchGeneralSettings()); dispatch(fetchGeneralSettings());
}, [dispatch]); }, [dispatch]);
@@ -1,26 +0,0 @@
import moment from 'moment-timezone';
export const convertToTimezone = (
date: moment.MomentInput,
timeZone: string
) => {
if (!date) {
return moment();
}
if (!timeZone) {
return moment(date);
}
try {
return moment.tz(date, timeZone);
} catch (error) {
console.warn(
`Error converting to timezone ${timeZone}. Using system timezone.`,
error
);
return moment(date);
}
};
export default convertToTimezone;
+7 -14
View File
@@ -1,15 +1,11 @@
import moment from 'moment-timezone'; import moment, { MomentInput } from 'moment';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import { convertToTimezone } from './convertToTimezone';
import formatTime from './formatTime'; import formatTime from './formatTime';
import isToday from './isToday'; import isToday from './isToday';
import isTomorrow from './isTomorrow'; import isTomorrow from './isTomorrow';
import isYesterday from './isYesterday'; import isYesterday from './isYesterday';
function getRelativeDay( function getRelativeDay(date: MomentInput, includeRelativeDate: boolean) {
date: moment.MomentInput,
includeRelativeDate: boolean
) {
if (!includeRelativeDate) { if (!includeRelativeDate) {
return ''; return '';
} }
@@ -30,23 +26,20 @@ function getRelativeDay(
} }
function formatDateTime( function formatDateTime(
date: moment.MomentInput, date: MomentInput,
dateFormat: string, dateFormat: string,
timeFormat: string, timeFormat: string,
{ includeSeconds = false, includeRelativeDay = false, timeZone = '' } = {} { includeSeconds = false, includeRelativeDay = false } = {}
) { ) {
if (!date) { if (!date) {
return ''; return '';
} }
const dateTime = convertToTimezone(date, timeZone); const relativeDay = getRelativeDay(date, includeRelativeDay);
const formattedDate = moment(date).format(dateFormat);
const relativeDay = getRelativeDay(dateTime, includeRelativeDay); const formattedTime = formatTime(date, timeFormat, {
const formattedDate = dateTime.format(dateFormat);
const formattedTime = formatTime(dateTime, timeFormat, {
includeMinuteZero: true, includeMinuteZero: true,
includeSeconds, includeSeconds,
timeZone,
}); });
if (relativeDay) { if (relativeDay) {
+4 -5
View File
@@ -1,16 +1,15 @@
import moment from 'moment-timezone'; import moment, { MomentInput } from 'moment';
import { convertToTimezone } from './convertToTimezone';
function formatTime( function formatTime(
date: moment.MomentInput, date: MomentInput,
timeFormat: string, timeFormat: string,
{ includeMinuteZero = false, includeSeconds = false, timeZone = '' } = {} { includeMinuteZero = false, includeSeconds = false } = {}
) { ) {
if (!date) { if (!date) {
return ''; return '';
} }
const time = convertToTimezone(date, timeZone); const time = moment(date);
if (includeSeconds) { if (includeSeconds) {
timeFormat = timeFormat.replace(/\(?:mm\)?/, ':mm:ss'); timeFormat = timeFormat.replace(/\(?:mm\)?/, ':mm:ss');
+5 -13
View File
@@ -1,10 +1,10 @@
import moment from 'moment';
import formatTime from 'Utilities/Date/formatTime'; import formatTime from 'Utilities/Date/formatTime';
import isInNextWeek from 'Utilities/Date/isInNextWeek'; import isInNextWeek from 'Utilities/Date/isInNextWeek';
import isToday from 'Utilities/Date/isToday'; import isToday from 'Utilities/Date/isToday';
import isTomorrow from 'Utilities/Date/isTomorrow'; import isTomorrow from 'Utilities/Date/isTomorrow';
import isYesterday from 'Utilities/Date/isYesterday'; import isYesterday from 'Utilities/Date/isYesterday';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import { convertToTimezone } from './convertToTimezone';
import formatDateTime from './formatDateTime'; import formatDateTime from './formatDateTime';
interface GetRelativeDateOptions { interface GetRelativeDateOptions {
@@ -12,7 +12,6 @@ interface GetRelativeDateOptions {
shortDateFormat: string; shortDateFormat: string;
showRelativeDates: boolean; showRelativeDates: boolean;
timeFormat?: string; timeFormat?: string;
timeZone?: string;
includeSeconds?: boolean; includeSeconds?: boolean;
timeForToday?: boolean; timeForToday?: boolean;
includeTime?: boolean; includeTime?: boolean;
@@ -23,7 +22,6 @@ function getRelativeDate({
shortDateFormat, shortDateFormat,
showRelativeDates, showRelativeDates,
timeFormat, timeFormat,
timeZone = '',
includeSeconds = false, includeSeconds = false,
timeForToday = false, timeForToday = false,
includeTime = false, includeTime = false,
@@ -43,7 +41,6 @@ function getRelativeDate({
? formatTime(date, timeFormat, { ? formatTime(date, timeFormat, {
includeMinuteZero: true, includeMinuteZero: true,
includeSeconds, includeSeconds,
timeZone,
}) })
: ''; : '';
@@ -52,8 +49,7 @@ function getRelativeDate({
} }
if (!showRelativeDates) { if (!showRelativeDates) {
const dateTime = convertToTimezone(date, timeZone); return moment(date).format(shortDateFormat);
return dateTime.format(shortDateFormat);
} }
if (isYesterday(date)) { if (isYesterday(date)) {
@@ -73,18 +69,14 @@ function getRelativeDate({
} }
if (isInNextWeek(date)) { if (isInNextWeek(date)) {
const dateTime = convertToTimezone(date, timeZone); const day = moment(date).format('dddd');
const day = dateTime.format('dddd');
return includeTime ? translate('DayOfWeekAt', { day, time }) : day; return includeTime ? translate('DayOfWeekAt', { day, time }) : day;
} }
return includeTime && timeFormat return includeTime && timeFormat
? formatDateTime(date, shortDateFormat, timeFormat, { ? formatDateTime(date, shortDateFormat, timeFormat, { includeSeconds })
includeSeconds, : moment(date).format(shortDateFormat);
timeZone,
})
: convertToTimezone(date, timeZone).format(shortDateFormat);
} }
export default getRelativeDate; export default getRelativeDate;
@@ -1,192 +0,0 @@
import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput';
import translate from 'Utilities/String/translate';
export const timeZoneOptions: EnhancedSelectInputValue<string>[] = [
{
key: '',
get value() {
return translate('SystemDefault');
},
},
// UTC
{ key: 'UTC', value: 'UTC' },
// Africa (Major cities and unique timezones)
{ key: 'Africa/Abidjan', value: 'Africa/Abidjan' },
{ key: 'Africa/Algiers', value: 'Africa/Algiers' },
{ key: 'Africa/Cairo', value: 'Africa/Cairo' },
{ key: 'Africa/Casablanca', value: 'Africa/Casablanca' },
{ key: 'Africa/Johannesburg', value: 'Africa/Johannesburg' },
{ key: 'Africa/Lagos', value: 'Africa/Lagos' },
{ key: 'Africa/Nairobi', value: 'Africa/Nairobi' },
{ key: 'Africa/Tripoli', value: 'Africa/Tripoli' },
// America - North America (Major US/Canada zones)
{ key: 'America/New_York', value: 'America/New_York (Eastern)' },
{ key: 'America/Chicago', value: 'America/Chicago (Central)' },
{ key: 'America/Denver', value: 'America/Denver (Mountain)' },
{ key: 'America/Los_Angeles', value: 'America/Los_Angeles (Pacific)' },
{ key: 'America/Anchorage', value: 'America/Anchorage (Alaska)' },
{ key: 'America/Adak', value: 'America/Adak (Hawaii-Aleutian)' },
{ key: 'America/Phoenix', value: 'America/Phoenix (Arizona)' },
{ key: 'America/Toronto', value: 'America/Toronto' },
{ key: 'America/Vancouver', value: 'America/Vancouver' },
{ key: 'America/Halifax', value: 'America/Halifax' },
{ key: 'America/St_Johns', value: 'America/St_Johns (Newfoundland)' },
// America - Mexico
{ key: 'America/Mexico_City', value: 'America/Mexico_City' },
{ key: 'America/Cancun', value: 'America/Cancun' },
{ key: 'America/Tijuana', value: 'America/Tijuana' },
// America - Central America
{ key: 'America/Guatemala', value: 'America/Guatemala' },
{ key: 'America/Costa_Rica', value: 'America/Costa_Rica' },
{ key: 'America/Panama', value: 'America/Panama' },
// America - Caribbean
{ key: 'America/Havana', value: 'America/Havana' },
{ key: 'America/Jamaica', value: 'America/Jamaica' },
{ key: 'America/Puerto_Rico', value: 'America/Puerto_Rico' },
// America - South America
{ key: 'America/Bogota', value: 'America/Bogota' },
{ key: 'America/Caracas', value: 'America/Caracas' },
{ key: 'America/Guyana', value: 'America/Guyana' },
{ key: 'America/La_Paz', value: 'America/La_Paz' },
{ key: 'America/Lima', value: 'America/Lima' },
{ key: 'America/Santiago', value: 'America/Santiago' },
{ key: 'America/Asuncion', value: 'America/Asuncion' },
{ key: 'America/Montevideo', value: 'America/Montevideo' },
{
key: 'America/Argentina/Buenos_Aires',
value: 'America/Argentina/Buenos_Aires',
},
{ key: 'America/Sao_Paulo', value: 'America/Sao_Paulo' },
{ key: 'America/Manaus', value: 'America/Manaus' },
{ key: 'America/Fortaleza', value: 'America/Fortaleza' },
{ key: 'America/Noronha', value: 'America/Noronha' },
// Antarctica (Research stations)
{ key: 'Antarctica/McMurdo', value: 'Antarctica/McMurdo' },
{ key: 'Antarctica/Palmer', value: 'Antarctica/Palmer' },
// Arctic
{ key: 'Arctic/Longyearbyen', value: 'Arctic/Longyearbyen' },
// Asia - East Asia
{ key: 'Asia/Tokyo', value: 'Asia/Tokyo' },
{ key: 'Asia/Seoul', value: 'Asia/Seoul' },
{ key: 'Asia/Shanghai', value: 'Asia/Shanghai' },
{ key: 'Asia/Hong_Kong', value: 'Asia/Hong_Kong' },
{ key: 'Asia/Taipei', value: 'Asia/Taipei' },
{ key: 'Asia/Macau', value: 'Asia/Macau' },
// Asia - Southeast Asia
{ key: 'Asia/Singapore', value: 'Asia/Singapore' },
{ key: 'Asia/Kuala_Lumpur', value: 'Asia/Kuala_Lumpur' },
{ key: 'Asia/Jakarta', value: 'Asia/Jakarta' },
{ key: 'Asia/Manila', value: 'Asia/Manila' },
{ key: 'Asia/Bangkok', value: 'Asia/Bangkok' },
{ key: 'Asia/Ho_Chi_Minh', value: 'Asia/Ho_Chi_Minh' },
// Asia - South Asia
{ key: 'Asia/Kolkata', value: 'Asia/Kolkata' },
{ key: 'Asia/Dhaka', value: 'Asia/Dhaka' },
{ key: 'Asia/Karachi', value: 'Asia/Karachi' },
{ key: 'Asia/Kathmandu', value: 'Asia/Kathmandu' },
{ key: 'Asia/Colombo', value: 'Asia/Colombo' },
// Asia - Central Asia
{ key: 'Asia/Almaty', value: 'Asia/Almaty' },
{ key: 'Asia/Tashkent', value: 'Asia/Tashkent' },
{ key: 'Asia/Bishkek', value: 'Asia/Bishkek' },
{ key: 'Asia/Dushanbe', value: 'Asia/Dushanbe' },
// Asia - Western Asia/Middle East
{ key: 'Asia/Dubai', value: 'Asia/Dubai' },
{ key: 'Asia/Riyadh', value: 'Asia/Riyadh' },
{ key: 'Asia/Kuwait', value: 'Asia/Kuwait' },
{ key: 'Asia/Qatar', value: 'Asia/Qatar' },
{ key: 'Asia/Bahrain', value: 'Asia/Bahrain' },
{ key: 'Asia/Jerusalem', value: 'Asia/Jerusalem' },
{ key: 'Asia/Beirut', value: 'Asia/Beirut' },
{ key: 'Asia/Damascus', value: 'Asia/Damascus' },
{ key: 'Asia/Baghdad', value: 'Asia/Baghdad' },
{ key: 'Asia/Tehran', value: 'Asia/Tehran' },
// Asia - Russia
{ key: 'Europe/Moscow', value: 'Europe/Moscow' },
{ key: 'Asia/Yekaterinburg', value: 'Asia/Yekaterinburg' },
{ key: 'Asia/Novosibirsk', value: 'Asia/Novosibirsk' },
{ key: 'Asia/Krasnoyarsk', value: 'Asia/Krasnoyarsk' },
{ key: 'Asia/Irkutsk', value: 'Asia/Irkutsk' },
{ key: 'Asia/Yakutsk', value: 'Asia/Yakutsk' },
{ key: 'Asia/Vladivostok', value: 'Asia/Vladivostok' },
{ key: 'Asia/Sakhalin', value: 'Asia/Sakhalin' },
{ key: 'Asia/Kamchatka', value: 'Asia/Kamchatka' },
// Atlantic
{ key: 'Atlantic/Azores', value: 'Atlantic/Azores' },
{ key: 'Atlantic/Canary', value: 'Atlantic/Canary' },
{ key: 'Atlantic/Cape_Verde', value: 'Atlantic/Cape_Verde' },
{ key: 'Atlantic/Reykjavik', value: 'Atlantic/Reykjavik' },
// Australia & New Zealand
{ key: 'Australia/Sydney', value: 'Australia/Sydney' },
{ key: 'Australia/Melbourne', value: 'Australia/Melbourne' },
{ key: 'Australia/Brisbane', value: 'Australia/Brisbane' },
{ key: 'Australia/Perth', value: 'Australia/Perth' },
{ key: 'Australia/Adelaide', value: 'Australia/Adelaide' },
{ key: 'Australia/Darwin', value: 'Australia/Darwin' },
{ key: 'Australia/Hobart', value: 'Australia/Hobart' },
{ key: 'Pacific/Auckland', value: 'Pacific/Auckland' },
{ key: 'Pacific/Chatham', value: 'Pacific/Chatham' },
// Europe - Western Europe
{ key: 'Europe/London', value: 'Europe/London' },
{ key: 'Europe/Dublin', value: 'Europe/Dublin' },
{ key: 'Europe/Paris', value: 'Europe/Paris' },
{ key: 'Europe/Berlin', value: 'Europe/Berlin' },
{ key: 'Europe/Amsterdam', value: 'Europe/Amsterdam' },
{ key: 'Europe/Brussels', value: 'Europe/Brussels' },
{ key: 'Europe/Zurich', value: 'Europe/Zurich' },
{ key: 'Europe/Vienna', value: 'Europe/Vienna' },
{ key: 'Europe/Rome', value: 'Europe/Rome' },
{ key: 'Europe/Madrid', value: 'Europe/Madrid' },
{ key: 'Europe/Lisbon', value: 'Europe/Lisbon' },
// Europe - Northern Europe
{ key: 'Europe/Stockholm', value: 'Europe/Stockholm' },
{ key: 'Europe/Oslo', value: 'Europe/Oslo' },
{ key: 'Europe/Copenhagen', value: 'Europe/Copenhagen' },
{ key: 'Europe/Helsinki', value: 'Europe/Helsinki' },
// Europe - Eastern Europe
{ key: 'Europe/Warsaw', value: 'Europe/Warsaw' },
{ key: 'Europe/Prague', value: 'Europe/Prague' },
{ key: 'Europe/Budapest', value: 'Europe/Budapest' },
{ key: 'Europe/Bucharest', value: 'Europe/Bucharest' },
{ key: 'Europe/Sofia', value: 'Europe/Sofia' },
{ key: 'Europe/Athens', value: 'Europe/Athens' },
{ key: 'Europe/Istanbul', value: 'Europe/Istanbul' },
{ key: 'Europe/Kiev', value: 'Europe/Kiev' },
{ key: 'Europe/Minsk', value: 'Europe/Minsk' },
// Indian Ocean
{ key: 'Indian/Mauritius', value: 'Indian/Mauritius' },
{ key: 'Indian/Maldives', value: 'Indian/Maldives' },
// Pacific - Major Island Nations
{ key: 'Pacific/Honolulu', value: 'Pacific/Honolulu' },
{ key: 'Pacific/Fiji', value: 'Pacific/Fiji' },
{ key: 'Pacific/Guam', value: 'Pacific/Guam' },
{ key: 'Pacific/Tahiti', value: 'Pacific/Tahiti' },
{ key: 'Pacific/Apia', value: 'Pacific/Apia' },
{ key: 'Pacific/Tongatapu', value: 'Pacific/Tongatapu' },
{ key: 'Pacific/Port_Moresby', value: 'Pacific/Port_Moresby' },
{ key: 'Pacific/Noumea', value: 'Pacific/Noumea' },
];
export default timeZoneOptions;
@@ -4,7 +4,6 @@ export default interface UiSettings {
shortDateFormat: string; shortDateFormat: string;
longDateFormat: string; longDateFormat: string;
timeFormat: string; timeFormat: string;
timeZone: string;
firstDayOfWeek: number; firstDayOfWeek: number;
enableColorImpairedMode: boolean; enableColorImpairedMode: boolean;
calendarWeekColumnHeader: string; calendarWeekColumnHeader: string;
-2
View File
@@ -46,7 +46,6 @@
"lodash": "4.17.21", "lodash": "4.17.21",
"mobile-detect": "1.4.5", "mobile-detect": "1.4.5",
"moment": "2.30.1", "moment": "2.30.1",
"moment-timezone": "0.6.0",
"mousetrap": "1.6.5", "mousetrap": "1.6.5",
"normalize.css": "8.0.1", "normalize.css": "8.0.1",
"prop-types": "15.8.1", "prop-types": "15.8.1",
@@ -95,7 +94,6 @@
"@babel/preset-react": "7.27.1", "@babel/preset-react": "7.27.1",
"@babel/preset-typescript": "7.27.1", "@babel/preset-typescript": "7.27.1",
"@types/lodash": "4.14.195", "@types/lodash": "4.14.195",
"@types/moment-timezone": "0.5.30",
"@types/mousetrap": "1.6.15", "@types/mousetrap": "1.6.15",
"@types/qs": "6.9.16", "@types/qs": "6.9.16",
"@types/react-autosuggest": "10.1.11", "@types/react-autosuggest": "10.1.11",
@@ -65,12 +65,10 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Subject.Aggregate(_remoteEpisode).Languages.Should().Equal(_remoteEpisode.ParsedEpisodeInfo.Languages); Subject.Aggregate(_remoteEpisode).Languages.Should().Equal(_remoteEpisode.ParsedEpisodeInfo.Languages);
} }
[TestCase("Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup")] [Test]
[TestCase("Series Title (2025) [COMPLETA] [1080p H265 EAC3 MultiLang MultiSub][RlsGroup]")] public void should_return_multi_languages_when_indexer_id_has_multi_languages_configuration()
[TestCase("Series Title - Stagione 1 (2025) [COMPLETA] 720p H264 MULTILANG AAC 2.0 MULTISUB-RlsGroup")]
[TestCase("Series Title (2007) S01E01 [Multilang AC3 Sub Spa Eng Rus]")]
public void should_return_multi_languages_when_indexer_id_has_multi_languages_configuration(string releaseTitle)
{ {
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
var indexerDefinition = new IndexerDefinition var indexerDefinition = new IndexerDefinition
{ {
Id = 1, Id = 1,
@@ -89,12 +87,10 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls(); Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
} }
[TestCase("Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup")] [Test]
[TestCase("Series Title (2025) [COMPLETA] [1080p H265 EAC3 MultiLang MultiSub][RlsGroup]")] public void should_return_multi_languages_from_indexer_with_id_when_indexer_id_and_name_are_set()
[TestCase("Series Title - Stagione 1 (2025) [COMPLETA] 720p H264 MULTILANG AAC 2.0 MULTISUB-RlsGroup")]
[TestCase("Series Title (2007) S01E01 [Multilang AC3 Sub Spa Eng Rus]")]
public void should_return_multi_languages_from_indexer_with_id_when_indexer_id_and_name_are_set(string releaseTitle)
{ {
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
var indexerDefinition1 = new IndexerDefinition var indexerDefinition1 = new IndexerDefinition
{ {
Id = 1, Id = 1,
@@ -126,12 +122,10 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls(); Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
} }
[TestCase("Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup")] [Test]
[TestCase("Series Title (2025) [COMPLETA] [1080p H265 EAC3 MultiLang MultiSub][RlsGroup]")] public void should_return_multi_languages_when_indexer_name_has_multi_languages_configuration()
[TestCase("Series Title - Stagione 1 (2025) [COMPLETA] 720p H264 MULTILANG AAC 2.0 MULTISUB-RlsGroup")]
[TestCase("Series Title (2007) S01E01 [Multilang AC3 Sub Spa Eng Rus]")]
public void should_return_multi_languages_when_indexer_name_has_multi_languages_configuration(string releaseTitle)
{ {
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
var indexerDefinition = new IndexerDefinition var indexerDefinition = new IndexerDefinition
{ {
Id = 1, Id = 1,
@@ -152,12 +146,10 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls(); Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
} }
[TestCase("Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup")] [Test]
[TestCase("Series Title (2025) [COMPLETA] [1080p H265 EAC3 MultiLang MultiSub][RlsGroup]")] public void should_return_multi_languages_when_release_as_unknown_as_default_language_and_indexer_has_multi_languages_configuration()
[TestCase("Series Title - Stagione 1 (2025) [COMPLETA] 720p H264 MULTILANG AAC 2.0 MULTISUB-RlsGroup")]
[TestCase("Series Title (2007) S01E01 [Multilang AC3 Sub Spa Eng Rus]")]
public void should_return_multi_languages_when_release_as_unknown_as_default_language_and_indexer_has_multi_languages_configuration(string releaseTitle)
{ {
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
var indexerDefinition = new IndexerDefinition var indexerDefinition = new IndexerDefinition
{ {
Id = 1, Id = 1,
@@ -176,12 +168,10 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls(); Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
} }
[TestCase("Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup")] [Test]
[TestCase("Series Title (2025) [COMPLETA] [1080p H265 EAC3 MultiLang MultiSub][RlsGroup]")] public void should_return_multi_languages_when_release_as_specified_language_and_indexer_has_multi_languages_configuration()
[TestCase("Series Title - Stagione 1 (2025) [COMPLETA] 720p H264 MULTILANG AAC 2.0 MULTISUB-RlsGroup")]
[TestCase("Series Title (2007) S01E01 [Multilang AC3 Sub Spa Eng Rus]")]
public void should_return_multi_languages_when_release_as_specified_language_and_indexer_has_multi_languages_configuration(string releaseTitle)
{ {
var releaseTitle = "Series.Title.S01E01.MULTi.VFF.VFQ.1080p.BluRay.DTS.HDMA.x264-RlsGroup";
var indexerDefinition = new IndexerDefinition var indexerDefinition = new IndexerDefinition
{ {
Id = 1, Id = 1,
@@ -200,12 +190,10 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls(); Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
} }
[TestCase("Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup")] [Test]
[TestCase("Series Title (2025) [COMPLETA] [1080p H265 EAC3 MultiLang MultiSub][RlsGroup]")] public void should_return_multi_languages_when_release_as_other_language_and_indexer_has_multi_languages_configuration()
[TestCase("Series Title - Stagione 1 (2025) [COMPLETA] 720p H264 MULTILANG AAC 2.0 MULTISUB-RlsGroup")]
[TestCase("Series Title (2007) S01E01 [Multilang AC3 Sub Spa Eng Rus]")]
public void should_return_multi_languages_when_release_as_other_language_and_indexer_has_multi_languages_configuration(string releaseTitle)
{ {
var releaseTitle = "Series.Title.S01E01.MULTi.GERMAN.1080p.BluRay.DTS.HDMA.x264-RlsGroup";
var indexerDefinition = new IndexerDefinition var indexerDefinition = new IndexerDefinition
{ {
Id = 1, Id = 1,
@@ -224,12 +212,10 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls(); Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
} }
[TestCase("Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup")] [Test]
[TestCase("Series Title (2025) [COMPLETA] [1080p H265 EAC3 MultiLang MultiSub][RlsGroup]")] public void should_return_original_when_indexer_has_no_multi_languages_configuration()
[TestCase("Series Title - Stagione 1 (2025) [COMPLETA] 720p H264 MULTILANG AAC 2.0 MULTISUB-RlsGroup")]
[TestCase("Series Title (2007) S01E01 [Multilang AC3 Sub Spa Eng Rus]")]
public void should_return_original_when_indexer_has_no_multi_languages_configuration(string releaseTitle)
{ {
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
var indexerDefinition = new IndexerDefinition var indexerDefinition = new IndexerDefinition
{ {
Id = 1, Id = 1,
@@ -248,12 +234,11 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls(); Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
} }
[TestCase("Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup")] [Test]
[TestCase("Series Title (2025) [COMPLETA] [1080p H265 EAC3 MultiLang MultiSub][RlsGroup]")] public void should_return_original_when_no_indexer_value()
[TestCase("Series Title - Stagione 1 (2025) [COMPLETA] 720p H264 MULTILANG AAC 2.0 MULTISUB-RlsGroup")]
[TestCase("Series Title (2007) S01E01 [Multilang AC3 Sub Spa Eng Rus]")]
public void should_return_original_when_no_indexer_value(string releaseTitle)
{ {
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
_remoteEpisode.ParsedEpisodeInfo = GetParsedEpisodeInfo(new List<Language> { }, releaseTitle); _remoteEpisode.ParsedEpisodeInfo = GetParsedEpisodeInfo(new List<Language> { }, releaseTitle);
_remoteEpisode.Release.Title = releaseTitle; _remoteEpisode.Release.Title = releaseTitle;
@@ -343,13 +343,6 @@ namespace NzbDrone.Core.Configuration
set { SetValue("TimeFormat", value); } set { SetValue("TimeFormat", value); }
} }
public string TimeZone
{
get { return GetValue("TimeZone", ""); }
set { SetValue("TimeZone", value); }
}
public bool ShowRelativeDates public bool ShowRelativeDates
{ {
get { return GetValueBoolean("ShowRelativeDates", true); } get { return GetValueBoolean("ShowRelativeDates", true); }
@@ -68,7 +68,6 @@ namespace NzbDrone.Core.Configuration
string ShortDateFormat { get; set; } string ShortDateFormat { get; set; }
string LongDateFormat { get; set; } string LongDateFormat { get; set; }
string TimeFormat { get; set; } string TimeFormat { get; set; }
string TimeZone { get; set; }
bool ShowRelativeDates { get; set; } bool ShowRelativeDates { get; set; }
bool EnableColorImpairedMode { get; set; } bool EnableColorImpairedMode { get; set; }
int UILanguage { get; set; } int UILanguage { get; set; }
@@ -434,8 +434,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
} }
catch (HttpException ex) catch (HttpException ex)
{ {
_logger.Debug(ex, "qbitTorrent authentication failed."); _logger.Debug("qbitTorrent authentication failed.");
if (ex.Response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) if (ex.Response.StatusCode == HttpStatusCode.Forbidden)
{ {
throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent.", ex); throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent.", ex);
} }
@@ -447,7 +447,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
throw new DownloadClientUnavailableException("Failed to connect to qBittorrent, please check your settings.", ex); throw new DownloadClientUnavailableException("Failed to connect to qBittorrent, please check your settings.", ex);
} }
if (response.Content.IsNotNullOrWhiteSpace() && response.Content != "Ok.") if (response.Content != "Ok.")
{ {
// returns "Fails." on bad login // returns "Fails." on bad login
_logger.Debug("qbitTorrent authentication failed."); _logger.Debug("qbitTorrent authentication failed.");
@@ -715,11 +715,6 @@ namespace NzbDrone.Core.Notifications.Discord
return null; return null;
} }
if (episodes.Empty())
{
return series.Title.Replace("`", "\\`");
}
if (series.SeriesType == SeriesTypes.Daily) if (series.SeriesType == SeriesTypes.Daily)
{ {
var episode = episodes.First(); var episode = episodes.First();
@@ -727,7 +722,8 @@ namespace NzbDrone.Core.Notifications.Discord
return $"{series.Title} - {episode.AirDate} - {episode.Title}".Replace("`", "\\`"); return $"{series.Title} - {episode.AirDate} - {episode.Title}".Replace("`", "\\`");
} }
var episodeNumbers = string.Concat(episodes.Select(e => $"x{e.EpisodeNumber:00}")); var episodeNumbers = string.Concat(episodes.Select(e => e.EpisodeNumber)
.Select(i => string.Format("x{0:00}", i)));
var episodeTitles = string.Join(" + ", episodes.Select(e => e.Title)); var episodeTitles = string.Join(" + ", episodes.Select(e => e.Title));
@@ -49,30 +49,38 @@ namespace NzbDrone.Core.Notifications
{ {
var qualityString = GetQualityString(series, quality); var qualityString = GetQualityString(series, quality);
if (episodes.Empty())
{
return $"{series.Title} - [{qualityString}]";
}
if (series.SeriesType == SeriesTypes.Daily) if (series.SeriesType == SeriesTypes.Daily)
{ {
var episode = episodes.First(); var episode = episodes.First();
return $"{series.Title} - {episode.AirDate} - {episode.Title} [{qualityString}]"; return string.Format("{0} - {1} - {2} [{3}]",
series.Title,
episode.AirDate,
episode.Title,
qualityString);
} }
var episodeNumbers = string.Concat(episodes.Select(e => $"x{e.EpisodeNumber:00}")); var episodeNumbers = string.Concat(episodes.Select(e => e.EpisodeNumber)
.Select(i => string.Format("x{0:00}", i)));
var episodeTitles = string.Join(" + ", episodes.Select(e => e.Title)); var episodeTitles = string.Join(" + ", episodes.Select(e => e.Title));
return $"{series.Title} - {episodes.First().SeasonNumber}{episodeNumbers} - {episodeTitles} [{qualityString}]"; return string.Format("{0} - {1}{2} - {3} [{4}]",
series.Title,
episodes.First().SeasonNumber,
episodeNumbers,
episodeTitles,
qualityString);
} }
private string GetFullSeasonMessage(Series series, int seasonNumber, QualityModel quality) private string GetFullSeasonMessage(Series series, int seasonNumber, QualityModel quality)
{ {
var qualityString = GetQualityString(series, quality); var qualityString = GetQualityString(series, quality);
return $"{series.Title} - Season {seasonNumber} [{qualityString}]"; return string.Format("{0} - Season {1} [{2}]",
series.Title,
seasonNumber,
qualityString);
} }
private string GetQualityString(Series series, QualityModel quality) private string GetQualityString(Series series, QualityModel quality)
+2 -11
View File
@@ -266,16 +266,6 @@ namespace NzbDrone.Core.Notifications.Slack
private string GetTitle(Series series, List<Episode> episodes) private string GetTitle(Series series, List<Episode> episodes)
{ {
if (series == null)
{
return null;
}
if (episodes.Empty())
{
return series.Title;
}
if (series.SeriesType == SeriesTypes.Daily) if (series.SeriesType == SeriesTypes.Daily)
{ {
var episode = episodes.First(); var episode = episodes.First();
@@ -283,7 +273,8 @@ namespace NzbDrone.Core.Notifications.Slack
return $"{series.Title} - {episode.AirDate} - {episode.Title}"; return $"{series.Title} - {episode.AirDate} - {episode.Title}";
} }
var episodeNumbers = string.Concat(episodes.Select(e => $"x{e.EpisodeNumber:00}")); var episodeNumbers = string.Concat(episodes.Select(e => e.EpisodeNumber)
.Select(i => string.Format("x{0:00}", i)));
var episodeTitles = string.Join(" + ", episodes.Select(e => e.Title)); var episodeTitles = string.Join(" + ", episodes.Select(e => e.Title));
+1 -1
View File
@@ -544,7 +544,7 @@ namespace NzbDrone.Core.Parser
private static readonly string[] Numbers = new[] { "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine" }; private static readonly string[] Numbers = new[] { "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine" };
private static readonly Regex MultiRegex = new(@"[-_. \[](?<multi>multi|multilang|multilanguage)[-_. \]]", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex MultiRegex = new(@"[_. ](?<multi>multi)[_. ]", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Dictionary<string, int> ShortMonths = new() private static readonly Dictionary<string, int> ShortMonths = new()
{ {
+1 -2
View File
@@ -16,12 +16,11 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="System.Drawing.Common" Version="8.0.20" />
<PackageReference Include="System.Memory" Version="4.6.0" /> <PackageReference Include="System.Memory" Version="4.6.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="FluentMigrator.Runner.Core" Version="6.2.0" /> <PackageReference Include="FluentMigrator.Runner" Version="6.2.0" />
<PackageReference Include="FluentMigrator.Runner.SQLite" Version="6.2.0" /> <PackageReference Include="FluentMigrator.Runner.SQLite" Version="6.2.0" />
<PackageReference Include="FluentMigrator.Runner.Postgres" Version="6.2.0" /> <PackageReference Include="FluentMigrator.Runner.Postgres" Version="6.2.0" />
<PackageReference Include="FluentValidation" Version="9.5.4" /> <PackageReference Include="FluentValidation" Version="9.5.4" />
+3 -35
View File
@@ -1,16 +1,12 @@
using System; using System.IO;
using System.IO;
using Mono.Unix; using Mono.Unix;
using NLog;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation;
namespace NzbDrone.Mono.Disk namespace NzbDrone.Mono.Disk
{ {
public class ProcMount : IMount public class ProcMount : IMount
{ {
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(ProcMount));
private readonly UnixDriveInfo _unixDriveInfo; private readonly UnixDriveInfo _unixDriveInfo;
public ProcMount(DriveType driveType, string name, string mount, string type, MountOptions mountOptions) public ProcMount(DriveType driveType, string name, string mount, string type, MountOptions mountOptions)
@@ -38,37 +34,9 @@ namespace NzbDrone.Mono.Disk
public string RootDirectory { get; private set; } public string RootDirectory { get; private set; }
public long TotalFreeSpace public long TotalFreeSpace => _unixDriveInfo.TotalFreeSpace;
{
get
{
try
{
return _unixDriveInfo.TotalFreeSpace;
}
catch (OverflowException ex)
{
Logger.Warn(ex, "Failed to get total free space");
return long.MaxValue;
}
}
}
public long TotalSize public long TotalSize => _unixDriveInfo.TotalSize;
{
get
{
try
{
return _unixDriveInfo.TotalSize;
}
catch (OverflowException ex)
{
Logger.Warn(ex, "Failed to get total size");
return long.MaxValue;
}
}
}
public string VolumeLabel => _unixDriveInfo.VolumeLabel; public string VolumeLabel => _unixDriveInfo.VolumeLabel;
@@ -3,7 +3,6 @@ using System.Linq;
using System.Reflection; using System.Reflection;
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.Authentication; using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
@@ -26,7 +25,7 @@ namespace Sonarr.Api.V3.Config
public HostConfigController(IConfigFileProvider configFileProvider, public HostConfigController(IConfigFileProvider configFileProvider,
IConfigService configService, IConfigService configService,
IUserService userService, IUserService userService,
IDiskProvider diskProvider) FileExistsValidator fileExistsValidator)
{ {
_configFileProvider = configFileProvider; _configFileProvider = configFileProvider;
_configService = configService; _configService = configService;
@@ -60,14 +59,14 @@ namespace Sonarr.Api.V3.Config
.Cascade(CascadeMode.Stop) .Cascade(CascadeMode.Stop)
.NotEmpty() .NotEmpty()
.IsValidPath() .IsValidPath()
.SetValidator(new FileExistsValidator(diskProvider)) .SetValidator(fileExistsValidator)
.IsValidCertificate() .IsValidCertificate()
.When(c => c.EnableSsl); .When(c => c.EnableSsl);
SharedValidator.RuleFor(c => c.SslKeyPath) SharedValidator.RuleFor(c => c.SslKeyPath)
.NotEmpty() .NotEmpty()
.IsValidPath() .IsValidPath()
.SetValidator(new FileExistsValidator(diskProvider)) .SetValidator(fileExistsValidator)
.When(c => c.SslKeyPath.IsNotNullOrWhiteSpace()); .When(c => c.SslKeyPath.IsNotNullOrWhiteSpace());
SharedValidator.RuleFor(c => c.LogSizeLimit).InclusiveBetween(1, 10); SharedValidator.RuleFor(c => c.LogSizeLimit).InclusiveBetween(1, 10);
@@ -13,7 +13,6 @@ namespace Sonarr.Api.V3.Config
public string ShortDateFormat { get; set; } public string ShortDateFormat { get; set; }
public string LongDateFormat { get; set; } public string LongDateFormat { get; set; }
public string TimeFormat { get; set; } public string TimeFormat { get; set; }
public string TimeZone { get; set; }
public bool ShowRelativeDates { get; set; } public bool ShowRelativeDates { get; set; }
public bool EnableColorImpairedMode { get; set; } public bool EnableColorImpairedMode { get; set; }
@@ -33,7 +32,6 @@ namespace Sonarr.Api.V3.Config
ShortDateFormat = model.ShortDateFormat, ShortDateFormat = model.ShortDateFormat,
LongDateFormat = model.LongDateFormat, LongDateFormat = model.LongDateFormat,
TimeFormat = model.TimeFormat, TimeFormat = model.TimeFormat,
TimeZone = model.TimeZone,
ShowRelativeDates = model.ShowRelativeDates, ShowRelativeDates = model.ShowRelativeDates,
EnableColorImpairedMode = model.EnableColorImpairedMode, EnableColorImpairedMode = model.EnableColorImpairedMode,
@@ -1,134 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Download;
using NzbDrone.Core.History;
using NzbDrone.Core.Tv;
using Sonarr.Api.V5.Episodes;
using Sonarr.Api.V5.Series;
using Sonarr.Http;
using Sonarr.Http.Extensions;
namespace Sonarr.Api.V5.History;
[V5ApiController]
public class HistoryController : Controller
{
private readonly IHistoryService _historyService;
private readonly ICustomFormatCalculationService _formatCalculator;
private readonly IUpgradableSpecification _upgradableSpecification;
private readonly IFailedDownloadService _failedDownloadService;
private readonly ISeriesService _seriesService;
public HistoryController(IHistoryService historyService,
ICustomFormatCalculationService formatCalculator,
IUpgradableSpecification upgradableSpecification,
IFailedDownloadService failedDownloadService,
ISeriesService seriesService)
{
_historyService = historyService;
_formatCalculator = formatCalculator;
_upgradableSpecification = upgradableSpecification;
_failedDownloadService = failedDownloadService;
_seriesService = seriesService;
}
protected HistoryResource MapToResource(EpisodeHistory model, bool includeSeries, bool includeEpisode)
{
var resource = model.ToResource(_formatCalculator);
if (includeSeries)
{
resource.Series = model.Series.ToResource();
}
if (includeEpisode)
{
resource.Episode = model.Episode.ToResource();
}
if (model.Series != null)
{
resource.QualityCutoffNotMet = _upgradableSpecification.QualityCutoffNotMet(model.Series.QualityProfile.Value, model.Quality);
}
return resource;
}
[HttpGet]
[Produces("application/json")]
public PagingResource<HistoryResource> GetHistory([FromQuery] PagingRequestResource paging, bool includeSeries, bool includeEpisode, [FromQuery(Name = "eventType")] int[]? eventTypes, int? episodeId, string? downloadId, [FromQuery] int[]? seriesIds = null, [FromQuery] int[]? languages = null, [FromQuery] int[]? quality = null)
{
var pagingResource = new PagingResource<HistoryResource>(paging);
var pagingSpec = pagingResource.MapToPagingSpec<HistoryResource, EpisodeHistory>(
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"date",
"series.sortTitle"
},
"date",
SortDirection.Descending);
if (eventTypes != null && eventTypes.Any())
{
pagingSpec.FilterExpressions.Add(v => eventTypes.Contains((int)v.EventType));
}
if (episodeId.HasValue)
{
pagingSpec.FilterExpressions.Add(h => h.EpisodeId == episodeId);
}
if (downloadId.IsNotNullOrWhiteSpace())
{
pagingSpec.FilterExpressions.Add(h => h.DownloadId == downloadId);
}
if (seriesIds != null && seriesIds.Any())
{
pagingSpec.FilterExpressions.Add(h => seriesIds.Contains(h.SeriesId));
}
return pagingSpec.ApplyToPage(h => _historyService.Paged(pagingSpec, languages, quality), h => MapToResource(h, includeSeries, includeEpisode));
}
[HttpGet("since")]
[Produces("application/json")]
public List<HistoryResource> GetHistorySince(DateTime date, EpisodeHistoryEventType? eventType = null, bool includeSeries = false, bool includeEpisode = false)
{
return _historyService.Since(date, eventType).Select(h => MapToResource(h, includeSeries, includeEpisode)).ToList();
}
[HttpGet("series")]
[Produces("application/json")]
public List<HistoryResource> GetSeriesHistory(int seriesId, int? seasonNumber, EpisodeHistoryEventType? eventType = null, bool includeSeries = false, bool includeEpisode = false)
{
var series = _seriesService.GetSeries(seriesId);
if (seasonNumber.HasValue)
{
return _historyService.GetBySeason(seriesId, seasonNumber.Value, eventType).Select(h =>
{
h.Series = series;
return MapToResource(h, includeSeries, includeEpisode);
}).ToList();
}
return _historyService.GetBySeries(seriesId, eventType).Select(h =>
{
h.Series = series;
return MapToResource(h, includeSeries, includeEpisode);
}).ToList();
}
[HttpPost("failed/{id}")]
public ActionResult MarkAsFailed([FromRoute] int id)
{
_failedDownloadService.MarkAsFailed(id);
return NoContent();
}
}
@@ -1,53 +0,0 @@
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.History;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Qualities;
using Sonarr.Api.V5.CustomFormats;
using Sonarr.Api.V5.Episodes;
using Sonarr.Api.V5.Series;
using Sonarr.Http.REST;
namespace Sonarr.Api.V5.History;
public class HistoryResource : RestResource
{
public int EpisodeId { get; set; }
public int SeriesId { get; set; }
public required string SourceTitle { get; set; }
public required List<Language> Languages { get; set; }
public required QualityModel Quality { get; set; }
public required List<CustomFormatResource> CustomFormats { get; set; }
public int CustomFormatScore { get; set; }
public bool QualityCutoffNotMet { get; set; }
public DateTime Date { get; set; }
public string? DownloadId { get; set; }
public EpisodeHistoryEventType EventType { get; set; }
public required Dictionary<string, string> Data { get; set; }
public EpisodeResource? Episode { get; set; }
public SeriesResource? Series { get; set; }
}
public static class HistoryResourceMapper
{
public static HistoryResource ToResource(this EpisodeHistory model, ICustomFormatCalculationService formatCalculator)
{
var customFormats = formatCalculator.ParseCustomFormat(model, model.Series);
var customFormatScore = model.Series.QualityProfile.Value.CalculateCustomFormatScore(customFormats);
return new HistoryResource
{
Id = model.Id,
EpisodeId = model.EpisodeId,
SeriesId = model.SeriesId,
SourceTitle = model.SourceTitle,
Languages = model.Languages,
Quality = model.Quality,
CustomFormats = customFormats.ToResource(false),
CustomFormatScore = customFormatScore,
Date = model.Date,
DownloadId = model.DownloadId,
EventType = model.EventType,
Data = model.Data
};
}
}
@@ -1,41 +0,0 @@
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
using Sonarr.Http;
namespace Sonarr.Api.V5.Logs;
[V5ApiController("log/file")]
public class LogFileController : LogFileControllerBase
{
private readonly IAppFolderInfo _appFolderInfo;
private readonly IDiskProvider _diskProvider;
public LogFileController(IAppFolderInfo appFolderInfo,
IDiskProvider diskProvider,
IConfigFileProvider configFileProvider)
: base(diskProvider, configFileProvider, "")
{
_appFolderInfo = appFolderInfo;
_diskProvider = diskProvider;
}
protected override IEnumerable<string> GetLogFiles()
{
return _diskProvider.GetFiles(_appFolderInfo.GetLogFolder(), false);
}
protected override string GetLogFilePath(string filename)
{
return Path.Combine(_appFolderInfo.GetLogFolder(), filename);
}
protected override string DownloadUrlRoot
{
get
{
return "logfile";
}
}
}
@@ -1,71 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Core.Configuration;
namespace Sonarr.Api.V5.Logs;
public abstract class LogFileControllerBase : Controller
{
protected const string LOGFILE_ROUTE = @"/(?<filename>[-.a-zA-Z0-9]+?\.txt)";
protected string _resource;
private readonly IDiskProvider _diskProvider;
private readonly IConfigFileProvider _configFileProvider;
public LogFileControllerBase(IDiskProvider diskProvider,
IConfigFileProvider configFileProvider,
string resource)
{
_diskProvider = diskProvider;
_configFileProvider = configFileProvider;
_resource = resource;
}
[HttpGet]
[Produces("application/json")]
public List<LogFileResource> GetLogFilesResponse()
{
var result = new List<LogFileResource>();
var files = GetLogFiles().ToList();
for (var i = 0; i < files.Count; i++)
{
var file = files[i];
var filename = Path.GetFileName(file);
result.Add(new LogFileResource
{
Id = i + 1,
Filename = filename,
LastWriteTime = _diskProvider.FileGetLastWrite(file),
ContentsUrl = string.Format("{0}/api/v1/{1}/{2}", _configFileProvider.UrlBase, _resource, filename),
DownloadUrl = string.Format("{0}/{1}/{2}", _configFileProvider.UrlBase, DownloadUrlRoot, filename)
});
}
return result.OrderByDescending(l => l.LastWriteTime).ToList();
}
[HttpGet(@"{filename:regex([[-.a-zA-Z0-9]]+?\.txt)}")]
[Produces("text/plain")]
public IActionResult GetLogFileResponse(string filename)
{
LogManager.Flush();
var filePath = GetLogFilePath(filename);
if (!_diskProvider.FileExists(filePath))
{
return NotFound();
}
return PhysicalFile(filePath, "text/plain");
}
protected abstract IEnumerable<string> GetLogFiles();
protected abstract string GetLogFilePath(string filename);
protected abstract string DownloadUrlRoot { get; }
}
-11
View File
@@ -1,11 +0,0 @@
using Sonarr.Http.REST;
namespace Sonarr.Api.V5.Logs;
public class LogFileResource : RestResource
{
public required string Filename { get; set; }
public required DateTime LastWriteTime { get; set; }
public required string ContentsUrl { get; set; }
public required string DownloadUrl { get; set; }
}
@@ -1,49 +0,0 @@
using System.Text.RegularExpressions;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
using Sonarr.Http;
namespace Sonarr.Api.V5.Logs;
[V5ApiController("log/file/update")]
public class UpdateLogFileController : LogFileControllerBase
{
private readonly IAppFolderInfo _appFolderInfo;
private readonly IDiskProvider _diskProvider;
public UpdateLogFileController(IAppFolderInfo appFolderInfo,
IDiskProvider diskProvider,
IConfigFileProvider configFileProvider)
: base(diskProvider, configFileProvider, "update")
{
_appFolderInfo = appFolderInfo;
_diskProvider = diskProvider;
}
protected override IEnumerable<string> GetLogFiles()
{
if (!_diskProvider.FolderExists(_appFolderInfo.GetUpdateLogFolder()))
{
return Enumerable.Empty<string>();
}
return _diskProvider.GetFiles(_appFolderInfo.GetUpdateLogFolder(), false)
.Where(f => Regex.IsMatch(Path.GetFileName(f), LOGFILE_ROUTE.TrimStart('/'), RegexOptions.IgnoreCase))
.ToList();
}
protected override string GetLogFilePath(string filename)
{
return Path.Combine(_appFolderInfo.GetUpdateLogFolder(), filename);
}
protected override string DownloadUrlRoot
{
get
{
return "updatelogfile";
}
}
}
+4 -4
View File
@@ -53,10 +53,10 @@ namespace Sonarr.Api.V5.Queue
{ {
Id = model.Id, Id = model.Id,
SeriesId = model.Series?.Id, SeriesId = model.Series?.Id,
EpisodeIds = model.Episodes?.Select(e => e.Id).ToList() ?? [], EpisodeIds = model.Episodes.Select(e => e.Id).ToList(),
SeasonNumbers = model.SeasonNumber.HasValue ? [model.SeasonNumber.Value] : [], SeasonNumbers = model.SeasonNumber.HasValue ? new List<int> { model.SeasonNumber.Value } : new List<int>(),
Series = includeSeries && model.Series != null ? model.Series.ToResource() : null, Series = includeSeries && model.Series != null ? model.Series.ToResource() : null,
Episodes = includeEpisodes ? model.Episodes?.ToResource() : null, Episodes = includeEpisodes ? model.Episodes.ToResource() : null,
Languages = model.Languages, Languages = model.Languages,
Quality = model.Quality, Quality = model.Quality,
CustomFormats = customFormats?.ToResource(false) ?? [], CustomFormats = customFormats?.ToResource(false) ?? [],
@@ -78,7 +78,7 @@ namespace Sonarr.Api.V5.Queue
DownloadClientHasPostImportCategory = model.DownloadClientHasPostImportCategory, DownloadClientHasPostImportCategory = model.DownloadClientHasPostImportCategory,
Indexer = model.Indexer, Indexer = model.Indexer,
OutputPath = model.OutputPath, OutputPath = model.OutputPath,
EpisodesWithFilesCount = model.Episodes?.Count(e => e.HasFile) ?? 0, EpisodesWithFilesCount = model.Episodes.Count(e => e.HasFile),
IsFullSeason = model.RemoteEpisode?.ParsedEpisodeInfo?.FullSeason ?? false IsFullSeason = model.RemoteEpisode?.ParsedEpisodeInfo?.FullSeason ?? false
}; };
} }
-264
View File
@@ -113,134 +113,6 @@
} }
} }
}, },
"/api/v5/blocklist": {
"get": {
"tags": [
"Blocklist"
],
"parameters": [
{
"name": "page",
"in": "query",
"schema": {
"type": "integer",
"format": "int32",
"default": 1
}
},
{
"name": "pageSize",
"in": "query",
"schema": {
"type": "integer",
"format": "int32",
"default": 10
}
},
{
"name": "sortKey",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "sortDirection",
"in": "query",
"schema": {
"$ref": "#/components/schemas/SortDirection"
}
},
{
"name": "seriesIds",
"in": "query",
"schema": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
}
},
{
"name": "protocols",
"in": "query",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DownloadProtocol"
}
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BlocklistResourcePagingResource"
}
}
}
}
}
}
},
"/api/v5/blocklist/{id}": {
"delete": {
"tags": [
"Blocklist"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/v5/blocklist/bulk": {
"delete": {
"tags": [
"Blocklist"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BlocklistBulkResource"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/BlocklistBulkResource"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/BlocklistBulkResource"
}
}
}
},
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/v5/log": { "/api/v5/log": {
"get": { "get": {
"tags": [ "tags": [
@@ -352,13 +224,6 @@
"format": "int32" "format": "int32"
} }
}, },
{
"name": "message",
"in": "query",
"schema": {
"type": "string"
}
},
{ {
"name": "removeFromClient", "name": "removeFromClient",
"in": "query", "in": "query",
@@ -405,13 +270,6 @@
"Queue" "Queue"
], ],
"parameters": [ "parameters": [
{
"name": "message",
"in": "query",
"schema": {
"type": "string"
}
},
{ {
"name": "removeFromClient", "name": "removeFromClient",
"in": "query", "in": "query",
@@ -1222,128 +1080,6 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"BlocklistBulkResource": {
"required": [
"ids"
],
"type": "object",
"properties": {
"ids": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
},
"nullable": true
}
},
"additionalProperties": false
},
"BlocklistResource": {
"required": [
"customFormats",
"episodeIds",
"languages",
"quality",
"series",
"sourceTitle"
],
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"seriesId": {
"type": "integer",
"format": "int32"
},
"episodeIds": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
},
"nullable": true
},
"sourceTitle": {
"type": "string",
"nullable": true
},
"languages": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Language"
},
"nullable": true
},
"quality": {
"$ref": "#/components/schemas/QualityModel"
},
"customFormats": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CustomFormatResource"
},
"nullable": true
},
"date": {
"type": "string",
"format": "date-time"
},
"protocol": {
"$ref": "#/components/schemas/DownloadProtocol"
},
"indexer": {
"type": "string",
"nullable": true
},
"message": {
"type": "string",
"nullable": true
},
"source": {
"type": "string",
"nullable": true
},
"series": {
"$ref": "#/components/schemas/SeriesResource"
}
},
"additionalProperties": false
},
"BlocklistResourcePagingResource": {
"type": "object",
"properties": {
"page": {
"type": "integer",
"format": "int32"
},
"pageSize": {
"type": "integer",
"format": "int32"
},
"sortKey": {
"type": "string",
"nullable": true
},
"sortDirection": {
"$ref": "#/components/schemas/SortDirection"
},
"totalRecords": {
"type": "integer",
"format": "int32"
},
"records": {
"type": "array",
"items": {
"$ref": "#/components/schemas/BlocklistResource"
},
"nullable": true
}
},
"additionalProperties": false
},
"CustomFormatResource": { "CustomFormatResource": {
"required": [ "required": [
"name" "name"
+1 -15
View File
@@ -1395,13 +1395,6 @@
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e" resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e"
integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag== integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==
"@types/moment-timezone@0.5.30":
version "0.5.30"
resolved "https://registry.yarnpkg.com/@types/moment-timezone/-/moment-timezone-0.5.30.tgz#340ed45fe3e715f4a011f5cfceb7cb52aad46fc7"
integrity sha512-aDVfCsjYnAQaV/E9Qc24C5Njx1CoDjXsEgkxtp9NyXDpYu4CCbmclb6QhWloS9UTU/8YROUEEdEkWI0D7DxnKg==
dependencies:
moment-timezone "*"
"@types/mousetrap@1.6.15": "@types/mousetrap@1.6.15":
version "1.6.15" version "1.6.15"
resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.15.tgz#f144a0c539a4cef553a631824651d48267e53c86" resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.15.tgz#f144a0c539a4cef553a631824651d48267e53c86"
@@ -4758,14 +4751,7 @@ mobile-detect@1.4.5:
resolved "https://registry.yarnpkg.com/mobile-detect/-/mobile-detect-1.4.5.tgz#da393c3c413ca1a9bcdd9ced653c38281c0fb6ad" resolved "https://registry.yarnpkg.com/mobile-detect/-/mobile-detect-1.4.5.tgz#da393c3c413ca1a9bcdd9ced653c38281c0fb6ad"
integrity sha512-yc0LhH6tItlvfLBugVUEtgawwFU2sIe+cSdmRJJCTMZ5GEJyLxNyC/NIOAOGk67Fa8GNpOttO3Xz/1bHpXFD/g== integrity sha512-yc0LhH6tItlvfLBugVUEtgawwFU2sIe+cSdmRJJCTMZ5GEJyLxNyC/NIOAOGk67Fa8GNpOttO3Xz/1bHpXFD/g==
moment-timezone@*, moment-timezone@0.6.0: moment@2.30.1:
version "0.6.0"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.6.0.tgz#c5a6519171f31a64739ea75d33f5c136c08ff608"
integrity sha512-ldA5lRNm3iJCWZcBCab4pnNL3HSZYXVb/3TYr75/1WCTWYuTqYUb5f/S384pncYjJ88lbO8Z4uPDvmoluHJc8Q==
dependencies:
moment "^2.29.4"
moment@2.30.1, moment@^2.29.4:
version "2.30.1" version "2.30.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==