mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-03-26 17:44:10 -04:00
Compare commits
16 Commits
sidebar-cl
...
giant-disk
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31a5a32356 | ||
|
|
550cf8d399 | ||
|
|
37dfad11f2 | ||
|
|
ff5e73273b | ||
|
|
fce8e780cb | ||
|
|
4fb89252b5 | ||
|
|
5a3f41263a | ||
|
|
52d7f67627 | ||
|
|
a1db23353c | ||
|
|
79b56c3ff6 | ||
|
|
5568746ef8 | ||
|
|
813d7df643 | ||
|
|
b04b9f900f | ||
|
|
a45b077625 | ||
|
|
74ce132556 | ||
|
|
ca364724cf |
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
@@ -9,6 +9,7 @@ import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { HistoryData, HistoryEventType } from 'typings/History';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { useMarkAsFailed } from '../useHistory';
|
||||
import HistoryDetails from './HistoryDetails';
|
||||
import styles from './HistoryDetailsModal.css';
|
||||
|
||||
@@ -33,26 +34,32 @@ function getHeaderTitle(eventType: HistoryEventType) {
|
||||
|
||||
interface HistoryDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
id: number;
|
||||
eventType: HistoryEventType;
|
||||
sourceTitle: string;
|
||||
data: HistoryData;
|
||||
downloadId?: string;
|
||||
isMarkingAsFailed: boolean;
|
||||
onMarkAsFailedPress: () => void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
||||
const {
|
||||
isOpen,
|
||||
eventType,
|
||||
sourceTitle,
|
||||
data,
|
||||
downloadId,
|
||||
isMarkingAsFailed = false,
|
||||
onMarkAsFailedPress,
|
||||
onModalClose,
|
||||
} = props;
|
||||
const { isOpen, id, eventType, sourceTitle, data, downloadId, onModalClose } =
|
||||
props;
|
||||
|
||||
const { markAsFailed, isMarkingAsFailed, markAsFailedError } =
|
||||
useMarkAsFailed(id);
|
||||
|
||||
const wasMarkingAsFailed = useRef(isMarkingAsFailed);
|
||||
|
||||
const handleMarkAsFailedPress = useCallback(() => {
|
||||
markAsFailed();
|
||||
}, [markAsFailed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasMarkingAsFailed && !isMarkingAsFailed && !markAsFailedError) {
|
||||
onModalClose();
|
||||
}
|
||||
}, [wasMarkingAsFailed, isMarkingAsFailed, markAsFailedError, onModalClose]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
@@ -74,7 +81,7 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
||||
className={styles.markAsFailedButton}
|
||||
kind={kinds.DANGER}
|
||||
isSpinning={isMarkingAsFailed}
|
||||
onPress={onMarkAsFailedPress}
|
||||
onPress={handleMarkAsFailedPress}
|
||||
>
|
||||
{translate('MarkAsFailed')}
|
||||
</SpinnerButton>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import {
|
||||
setQueueOption,
|
||||
setQueueOptions,
|
||||
} from 'Activity/Queue/queueOptionsStore';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
@@ -13,20 +16,11 @@ import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
import TablePager from 'Components/Table/TablePager';
|
||||
import usePaging from 'Components/Table/usePaging';
|
||||
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
|
||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
|
||||
import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
|
||||
import {
|
||||
clearHistory,
|
||||
fetchHistory,
|
||||
gotoHistoryPage,
|
||||
setHistoryFilter,
|
||||
setHistorySort,
|
||||
setHistoryTableOption,
|
||||
} from 'Store/Actions/historyActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import HistoryItem from 'typings/History';
|
||||
import { TableOptionsChangePayload } from 'typings/Table';
|
||||
@@ -37,100 +31,90 @@ import {
|
||||
} from 'Utilities/pagePopulator';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import HistoryFilterModal from './HistoryFilterModal';
|
||||
import { useHistoryOptions } from './historyOptionsStore';
|
||||
import HistoryRow from './HistoryRow';
|
||||
import useHistory, { useFilters } from './useHistory';
|
||||
|
||||
function History() {
|
||||
const requestCurrentPage = useCurrentPage();
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
columns,
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
page,
|
||||
pageSize,
|
||||
records,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
} = useSelector((state: AppState) => state.history);
|
||||
error,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isLoading,
|
||||
page,
|
||||
goToPage,
|
||||
refetch,
|
||||
} = useHistory();
|
||||
|
||||
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||
useHistoryOptions();
|
||||
|
||||
const filters = useFilters();
|
||||
|
||||
const requestCurrentPage = useCurrentPage();
|
||||
|
||||
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
|
||||
useSelector(createEpisodesFetchingSelector());
|
||||
const customFilters = useSelector(createCustomFiltersSelector('history'));
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const isFetchingAny = isFetching || isEpisodesFetching;
|
||||
const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length);
|
||||
const isFetchingAny = isLoading || isEpisodesFetching;
|
||||
const isAllPopulated = isFetched && (isEpisodesPopulated || !records.length);
|
||||
const hasError = error || episodesError;
|
||||
|
||||
const {
|
||||
handleFirstPagePress,
|
||||
handlePreviousPagePress,
|
||||
handleNextPagePress,
|
||||
handleLastPagePress,
|
||||
handlePageSelect,
|
||||
} = usePaging({
|
||||
page,
|
||||
totalPages,
|
||||
gotoPage: gotoHistoryPage,
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string | number) => {
|
||||
dispatch(setHistoryFilter({ selectedFilterKey }));
|
||||
setQueueOption('selectedFilterKey', selectedFilterKey);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSortPress = useCallback(
|
||||
(sortKey: string) => {
|
||||
dispatch(setHistorySort({ sortKey }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
const handleSortPress = useCallback((sortKey: string) => {
|
||||
setQueueOption('sortKey', sortKey);
|
||||
}, []);
|
||||
|
||||
const handleTableOptionChange = useCallback(
|
||||
(payload: TableOptionsChangePayload) => {
|
||||
dispatch(setHistoryTableOption(payload));
|
||||
setQueueOptions(payload);
|
||||
|
||||
if (payload.pageSize) {
|
||||
dispatch(gotoHistoryPage({ page: 1 }));
|
||||
goToPage(1);
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
[goToPage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestCurrentPage) {
|
||||
dispatch(fetchHistory());
|
||||
} else {
|
||||
dispatch(gotoHistoryPage({ page: 1 }));
|
||||
}
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
goToPage(1);
|
||||
refetch();
|
||||
}, [goToPage, refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dispatch(clearHistory());
|
||||
dispatch(clearEpisodes());
|
||||
dispatch(clearEpisodeFiles());
|
||||
};
|
||||
}, [requestCurrentPage, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const episodeIds = selectUniqueIds<HistoryItem, number>(items, 'episodeId');
|
||||
const episodeIds = selectUniqueIds<HistoryItem, number>(
|
||||
records,
|
||||
'episodeId'
|
||||
);
|
||||
|
||||
if (episodeIds.length) {
|
||||
dispatch(fetchEpisodes({ episodeIds }));
|
||||
} else {
|
||||
dispatch(clearEpisodes());
|
||||
}
|
||||
}, [items, dispatch]);
|
||||
}, [records, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const repopulate = () => {
|
||||
dispatch(fetchHistory());
|
||||
refetch();
|
||||
};
|
||||
|
||||
registerPagePopulator(repopulate);
|
||||
@@ -138,7 +122,7 @@ function History() {
|
||||
return () => {
|
||||
unregisterPagePopulator(repopulate);
|
||||
};
|
||||
}, [dispatch]);
|
||||
}, [refetch]);
|
||||
|
||||
return (
|
||||
<PageContent title={translate('History')}>
|
||||
@@ -148,7 +132,7 @@ function History() {
|
||||
label={translate('Refresh')}
|
||||
iconName={icons.REFRESH}
|
||||
isSpinning={isFetching}
|
||||
onPress={handleFirstPagePress}
|
||||
onPress={handleRefreshPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
@@ -186,12 +170,12 @@ function History() {
|
||||
// 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.
|
||||
|
||||
isPopulated && !hasError && !items.length ? (
|
||||
isFetched && !hasError && !records.length ? (
|
||||
<Alert kind={kinds.INFO}>{translate('NoHistoryFound')}</Alert>
|
||||
) : null
|
||||
}
|
||||
|
||||
{isAllPopulated && !hasError && items.length ? (
|
||||
{isAllPopulated && !hasError && records.length ? (
|
||||
<div>
|
||||
<Table
|
||||
columns={columns}
|
||||
@@ -202,7 +186,7 @@ function History() {
|
||||
onSortPress={handleSortPress}
|
||||
>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
{records.map((item) => {
|
||||
return (
|
||||
<HistoryRow key={item.id} columns={columns} {...item} />
|
||||
);
|
||||
@@ -215,11 +199,7 @@ function History() {
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
isFetching={isFetching}
|
||||
onFirstPagePress={handleFirstPagePress}
|
||||
onPreviousPagePress={handlePreviousPagePress}
|
||||
onNextPagePress={handleNextPagePress}
|
||||
onLastPagePress={handleLastPagePress}
|
||||
onPageSelect={handlePageSelect}
|
||||
onPageSelect={goToPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,48 +1,25 @@
|
||||
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 { setHistoryFilter } from 'Store/Actions/historyActions';
|
||||
|
||||
function createHistorySelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.history.items,
|
||||
(queueItems) => {
|
||||
return queueItems;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createFilterBuilderPropsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.history.filterBuilderProps,
|
||||
(filterBuilderProps) => {
|
||||
return filterBuilderProps;
|
||||
}
|
||||
);
|
||||
}
|
||||
import { setHistoryOption } from './historyOptionsStore';
|
||||
import useHistory, { FILTER_BUILDER } from './useHistory';
|
||||
|
||||
type HistoryFilterModalProps = FilterModalProps<History>;
|
||||
|
||||
export default function HistoryFilterModal(props: HistoryFilterModalProps) {
|
||||
const sectionItems = useSelector(createHistorySelector());
|
||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { records } = useHistory();
|
||||
|
||||
const dispatchSetFilter = useCallback(
|
||||
(payload: { selectedFilterKey: string | number }) => {
|
||||
dispatch(setHistoryFilter(payload));
|
||||
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
|
||||
setHistoryOption('selectedFilterKey', selectedFilterKey);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
{...props}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
sectionItems={records}
|
||||
filterBuilderProps={FILTER_BUILDER}
|
||||
customFilterType="history"
|
||||
dispatchSetFilter={dispatchSetFilter}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
@@ -13,13 +12,11 @@ import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
||||
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
|
||||
import useEpisode from 'Episode/useEpisode';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { icons, tooltipPositions } from 'Helpers/Props';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import { HistoryData, HistoryEventType } from 'typings/History';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
@@ -61,13 +58,9 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
date,
|
||||
data,
|
||||
downloadId,
|
||||
isMarkingAsFailed = false,
|
||||
markAsFailedError,
|
||||
columns,
|
||||
} = props;
|
||||
|
||||
const wasMarkingAsFailed = usePrevious(isMarkingAsFailed);
|
||||
const dispatch = useDispatch();
|
||||
const series = useSeries(seriesId);
|
||||
const episode = useEpisode(episodeId, 'episodes');
|
||||
|
||||
@@ -81,23 +74,6 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
setIsDetailsModalOpen(false);
|
||||
}, [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) {
|
||||
return null;
|
||||
}
|
||||
@@ -254,13 +230,12 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
})}
|
||||
|
||||
<HistoryDetailsModal
|
||||
id={id}
|
||||
isOpen={isDetailsModalOpen}
|
||||
eventType={eventType}
|
||||
sourceTitle={sourceTitle}
|
||||
data={data}
|
||||
downloadId={downloadId}
|
||||
isMarkingAsFailed={isMarkingAsFailed}
|
||||
onMarkAsFailedPress={handleMarkAsFailedPress}
|
||||
onModalClose={handleDetailsModalClose}
|
||||
/>
|
||||
</TableRow>
|
||||
|
||||
109
frontend/src/Activity/History/historyOptionsStore.ts
Normal file
109
frontend/src/Activity/History/historyOptionsStore.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
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
frontend/src/Activity/History/useHistory.ts
Normal file
186
frontend/src/Activity/History/useHistory.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
@@ -9,7 +9,6 @@ import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { fetchUpdates } from 'Store/Actions/systemActions';
|
||||
import UpdateChanges from 'System/Updates/UpdateChanges';
|
||||
import useUpdates from 'System/Updates/useUpdates';
|
||||
import Update from 'typings/Update';
|
||||
@@ -64,9 +63,8 @@ interface AppUpdatedModalContentProps {
|
||||
}
|
||||
|
||||
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { version, prevVersion } = useSelector((state: AppState) => state.app);
|
||||
const { isFetched, error, data } = useUpdates();
|
||||
const { isFetched, error, data, refetch } = useUpdates();
|
||||
const previousVersion = usePrevious(version);
|
||||
|
||||
const { onModalClose } = props;
|
||||
@@ -77,15 +75,11 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
||||
window.location.href = `${window.Sonarr.urlBase}/system/updates`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchUpdates());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (version !== previousVersion) {
|
||||
dispatch(fetchUpdates());
|
||||
refetch();
|
||||
}
|
||||
}, [version, previousVersion, dispatch]);
|
||||
}, [version, previousVersion, refetch]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
|
||||
@@ -90,7 +90,6 @@ interface AppState {
|
||||
episodeHistory: HistoryAppState;
|
||||
episodes: EpisodesAppState;
|
||||
episodesSelection: EpisodesAppState;
|
||||
history: HistoryAppState;
|
||||
importSeries: ImportSeriesAppState;
|
||||
interactiveImport: InteractiveImportAppState;
|
||||
oAuth: OAuthAppState;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
@@ -15,6 +14,7 @@ import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
import translate from 'Utilities/String/translate';
|
||||
@@ -58,9 +58,8 @@ function AgendaEvent(props: AgendaEventProps) {
|
||||
const series = useSeries(seriesId)!;
|
||||
const episodeFile = useEpisodeFile(episodeFileId);
|
||||
const queueItem = useQueueItemForEpisode(id);
|
||||
const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
const { timeFormat, longDateFormat, enableColorImpairedMode, timeZone } =
|
||||
useSelector(createUISettingsSelector());
|
||||
|
||||
const {
|
||||
showEpisodeInformation,
|
||||
@@ -71,8 +70,11 @@ function AgendaEvent(props: AgendaEventProps) {
|
||||
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
|
||||
const startTime = moment(airDateUtc);
|
||||
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
|
||||
const startTime = convertToTimezone(airDateUtc, timeZone);
|
||||
const endTime = convertToTimezone(airDateUtc, timeZone).add(
|
||||
series.runtime,
|
||||
'minutes'
|
||||
);
|
||||
const downloading = !!(queueItem || grabbed);
|
||||
const isMonitored = series.monitored && monitored;
|
||||
const statusStyle = getStatusStyle(
|
||||
@@ -110,9 +112,10 @@ function AgendaEvent(props: AgendaEventProps) {
|
||||
)}
|
||||
>
|
||||
<div className={styles.time}>
|
||||
{formatTime(airDateUtc, timeFormat)} -{' '}
|
||||
{formatTime(airDateUtc, timeFormat, { timeZone })} -{' '}
|
||||
{formatTime(endTime.toISOString(), timeFormat, {
|
||||
includeMinuteZero: true,
|
||||
timeZone,
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
@@ -14,6 +13,7 @@ import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
import translate from 'Utilities/String/translate';
|
||||
@@ -60,7 +60,7 @@ function CalendarEvent(props: CalendarEventProps) {
|
||||
const episodeFile = useEpisodeFile(episodeFileId);
|
||||
const queueItem = useQueueItemForEpisode(id);
|
||||
|
||||
const { timeFormat, enableColorImpairedMode } = useSelector(
|
||||
const { timeFormat, enableColorImpairedMode, timeZone } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
|
||||
@@ -88,8 +88,11 @@ function CalendarEvent(props: CalendarEventProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startTime = moment(airDateUtc);
|
||||
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
|
||||
const startTime = convertToTimezone(airDateUtc, timeZone);
|
||||
const endTime = convertToTimezone(airDateUtc, timeZone).add(
|
||||
series.runtime,
|
||||
'minutes'
|
||||
);
|
||||
const isDownloading = !!(queueItem || grabbed);
|
||||
const isMonitored = series.monitored && monitored;
|
||||
const statusStyle = getStatusStyle(
|
||||
@@ -217,9 +220,10 @@ function CalendarEvent(props: CalendarEventProps) {
|
||||
) : null}
|
||||
|
||||
<div className={styles.airTime}>
|
||||
{formatTime(airDateUtc, timeFormat)} -{' '}
|
||||
{formatTime(airDateUtc, timeFormat, { timeZone })} -{' '}
|
||||
{formatTime(endTime.toISOString(), timeFormat, {
|
||||
includeMinuteZero: true,
|
||||
timeZone,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useIsDownloadingEpisodes } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
@@ -12,6 +11,7 @@ import { icons, kinds } from 'Helpers/Props';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import { CalendarItem } from 'typings/Calendar';
|
||||
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
import translate from 'Utilities/String/translate';
|
||||
@@ -34,7 +34,7 @@ function CalendarEventGroup({
|
||||
const isDownloading = useIsDownloadingEpisodes(episodeIds);
|
||||
const series = useSeries(seriesId)!;
|
||||
|
||||
const { timeFormat, enableColorImpairedMode } = useSelector(
|
||||
const { timeFormat, enableColorImpairedMode, timeZone } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
|
||||
@@ -46,8 +46,11 @@ function CalendarEventGroup({
|
||||
const firstEpisode = events[0];
|
||||
const lastEpisode = events[events.length - 1];
|
||||
const airDateUtc = firstEpisode.airDateUtc;
|
||||
const startTime = moment(airDateUtc);
|
||||
const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
|
||||
const startTime = convertToTimezone(airDateUtc, timeZone);
|
||||
const endTime = convertToTimezone(lastEpisode.airDateUtc, timeZone).add(
|
||||
series.runtime,
|
||||
'minutes'
|
||||
);
|
||||
const seasonNumber = firstEpisode.seasonNumber;
|
||||
|
||||
const { allDownloaded, anyGrabbed, anyMonitored, allAbsoluteEpisodeNumbers } =
|
||||
@@ -194,9 +197,10 @@ function CalendarEventGroup({
|
||||
|
||||
<div className={styles.airingInfo}>
|
||||
<div className={styles.airTime}>
|
||||
{formatTime(airDateUtc, timeFormat)} -{' '}
|
||||
{formatTime(airDateUtc, timeFormat, { timeZone })} -{' '}
|
||||
{formatTime(endTime.toISOString(), timeFormat, {
|
||||
includeMinuteZero: true,
|
||||
timeZone,
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
.header {
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -7,6 +7,40 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebarHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: $headerHeight;
|
||||
}
|
||||
|
||||
.logoContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.logoLink {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.sidebarCloseButton {
|
||||
composes: button from '~Components/Link/IconButton.css';
|
||||
|
||||
margin-right: 15px;
|
||||
color: #e1e2e3;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--sonarrBlue);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'logo': string;
|
||||
'logoContainer': string;
|
||||
'logoLink': string;
|
||||
'sidebar': string;
|
||||
'sidebarCloseButton': string;
|
||||
'sidebarContainer': string;
|
||||
'sidebarHeader': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import classNames from 'classnames';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -11,6 +10,8 @@ import { useDispatch } from 'react-redux';
|
||||
import { useLocation } from 'react-router';
|
||||
import QueueStatus from 'Activity/Queue/Status/QueueStatus';
|
||||
import { IconName } from 'Components/Icon';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Link from 'Components/Link/Link';
|
||||
import OverlayScroller from 'Components/Scroller/OverlayScroller';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
@@ -230,10 +231,6 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
||||
transition: 'none',
|
||||
transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1,
|
||||
});
|
||||
const [sidebarStyle, setSidebarStyle] = useState({
|
||||
top: dimensions.headerHeight,
|
||||
height: `${window.innerHeight - HEADER_HEIGHT}px`,
|
||||
});
|
||||
|
||||
const urlBase = window.Sonarr.urlBase;
|
||||
const pathname = urlBase
|
||||
@@ -299,22 +296,6 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
||||
dispatch(setIsSidebarVisible({ isSidebarVisible: false }));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleWindowScroll = useCallback(() => {
|
||||
const windowScroll =
|
||||
window.scrollY == null
|
||||
? document.documentElement.scrollTop
|
||||
: window.scrollY;
|
||||
const sidebarTop = Math.max(HEADER_HEIGHT - windowScroll, 0);
|
||||
const sidebarHeight = window.innerHeight - sidebarTop;
|
||||
|
||||
if (isSmallScreen) {
|
||||
setSidebarStyle({
|
||||
top: `${sidebarTop}px`,
|
||||
height: `${sidebarHeight}px`,
|
||||
});
|
||||
}
|
||||
}, [isSmallScreen]);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(event: TouchEvent) => {
|
||||
const touches = event.touches;
|
||||
@@ -396,10 +377,13 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
||||
touchStartY.current = null;
|
||||
}, []);
|
||||
|
||||
const handleSidebarClosePress = useCallback(() => {
|
||||
dispatch(setIsSidebarVisible({ isSidebarVisible: false }));
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSmallScreen) {
|
||||
window.addEventListener('click', handleWindowClick, { capture: true });
|
||||
window.addEventListener('scroll', handleWindowScroll);
|
||||
window.addEventListener('touchstart', handleTouchStart);
|
||||
window.addEventListener('touchmove', handleTouchMove);
|
||||
window.addEventListener('touchend', handleTouchEnd);
|
||||
@@ -408,7 +392,6 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', handleWindowClick, { capture: true });
|
||||
window.removeEventListener('scroll', handleWindowScroll);
|
||||
window.removeEventListener('touchstart', handleTouchStart);
|
||||
window.removeEventListener('touchmove', handleTouchMove);
|
||||
window.removeEventListener('touchend', handleTouchEnd);
|
||||
@@ -417,7 +400,6 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
||||
}, [
|
||||
isSmallScreen,
|
||||
handleWindowClick,
|
||||
handleWindowScroll,
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
@@ -456,13 +438,37 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
||||
return (
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={classNames(styles.sidebarContainer)}
|
||||
className={styles.sidebarContainer}
|
||||
style={containerStyle}
|
||||
>
|
||||
{isSmallScreen ? (
|
||||
<div className={styles.sidebarHeader}>
|
||||
<div className={styles.logoContainer}>
|
||||
<Link className={styles.logoLink} to="/">
|
||||
<img
|
||||
className={styles.logo}
|
||||
src={`${window.Sonarr.urlBase}/Content/Images/logo.svg`}
|
||||
alt="Sonarr Logo"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
className={styles.sidebarCloseButton}
|
||||
name={icons.CLOSE}
|
||||
aria-label={translate('Close')}
|
||||
size={20}
|
||||
onPress={handleSidebarClosePress}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ScrollerComponent
|
||||
className={styles.sidebar}
|
||||
scrollDirection="vertical"
|
||||
style={sidebarStyle}
|
||||
style={{
|
||||
height: `${window.innerHeight - HEADER_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{LINKS.map((link) => {
|
||||
|
||||
@@ -5,12 +5,14 @@ import { create } from 'zustand';
|
||||
interface PageStore {
|
||||
blocklist: number;
|
||||
events: number;
|
||||
history: number;
|
||||
queue: number;
|
||||
}
|
||||
|
||||
const pageStore = create<PageStore>(() => ({
|
||||
blocklist: 1,
|
||||
events: 1,
|
||||
history: 1,
|
||||
queue: 1,
|
||||
}));
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
|
||||
onGrabPress,
|
||||
} = props;
|
||||
|
||||
const { longDateFormat, timeFormat } = useSelector(
|
||||
const { longDateFormat, timeFormat, timeZone } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
|
||||
@@ -174,6 +174,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
|
||||
className={styles.age}
|
||||
title={formatDateTime(publishDate, longDateFormat, timeFormat, {
|
||||
includeSeconds: true,
|
||||
timeZone,
|
||||
})}
|
||||
>
|
||||
{formatAge(age, ageHours, ageMinutes)}
|
||||
|
||||
@@ -21,6 +21,7 @@ import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
|
||||
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
|
||||
import themes from 'Styles/Themes';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import timeZoneOptions from 'Utilities/Date/timeZoneOptions';
|
||||
import titleCase from 'Utilities/String/titleCase';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
@@ -218,6 +219,18 @@ function UISettings() {
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('TimeZone')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="timeZone"
|
||||
values={timeZoneOptions}
|
||||
onChange={handleInputChange}
|
||||
{...settings.timeZone}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ShowRelativeDates')}</FormLabel>
|
||||
<FormInputGroup
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
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);
|
||||
@@ -7,7 +7,6 @@ import * as episodes from './episodeActions';
|
||||
import * as episodeFiles from './episodeFileActions';
|
||||
import * as episodeHistory from './episodeHistoryActions';
|
||||
import * as episodeSelection from './episodeSelectionActions';
|
||||
import * as history from './historyActions';
|
||||
import * as importSeries from './importSeriesActions';
|
||||
import * as interactiveImportActions from './interactiveImportActions';
|
||||
import * as oAuth from './oAuthActions';
|
||||
@@ -35,7 +34,6 @@ export default [
|
||||
episodeFiles,
|
||||
episodeHistory,
|
||||
episodeSelection,
|
||||
history,
|
||||
importSeries,
|
||||
interactiveImportActions,
|
||||
oAuth,
|
||||
|
||||
@@ -62,20 +62,6 @@ export const defaultState = {
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
items: []
|
||||
},
|
||||
|
||||
logFiles: {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
items: []
|
||||
},
|
||||
|
||||
updateLogFiles: {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
items: []
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,11 +80,6 @@ export const RESTORE_BACKUP = 'system/backups/restoreBackup';
|
||||
export const CLEAR_RESTORE_BACKUP = 'system/backups/clearRestoreBackup';
|
||||
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 SHUTDOWN = 'system/shutdown';
|
||||
|
||||
@@ -117,11 +98,6 @@ export const restoreBackup = createThunk(RESTORE_BACKUP);
|
||||
export const clearRestoreBackup = createAction(CLEAR_RESTORE_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 shutdown = createThunk(SHUTDOWN);
|
||||
|
||||
@@ -200,10 +176,6 @@ export const actionHandlers = handleThunks({
|
||||
|
||||
[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) {
|
||||
const promise = createAjaxRequest({
|
||||
url: '/system/restart',
|
||||
|
||||
@@ -1,46 +1,39 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { fetchLogFiles } from 'Store/Actions/systemActions';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import LogFiles from '../LogFiles';
|
||||
import useLogFiles from '../useLogFiles';
|
||||
|
||||
function AppLogFiles() {
|
||||
const dispatch = useDispatch();
|
||||
const { isFetching, items } = useSelector(
|
||||
(state: AppState) => state.system.logFiles
|
||||
);
|
||||
const { data = [], isFetching, refetch } = useLogFiles();
|
||||
|
||||
const isDeleteFilesExecuting = useSelector(
|
||||
createCommandExecutingSelector(commandNames.DELETE_LOG_FILES)
|
||||
);
|
||||
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
dispatch(fetchLogFiles());
|
||||
}, [dispatch]);
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const handleDeleteFilesPress = useCallback(() => {
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: commandNames.DELETE_LOG_FILES,
|
||||
commandFinished: () => {
|
||||
dispatch(fetchLogFiles());
|
||||
refetch();
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchLogFiles());
|
||||
}, [dispatch]);
|
||||
}, [dispatch, refetch]);
|
||||
|
||||
return (
|
||||
<LogFiles
|
||||
isDeleteFilesExecuting={isDeleteFilesExecuting}
|
||||
isFetching={isFetching}
|
||||
items={items}
|
||||
items={data}
|
||||
type="app"
|
||||
onRefreshPress={handleRefreshPress}
|
||||
onDeleteFilesPress={handleDeleteFilesPress}
|
||||
|
||||
@@ -1,46 +1,39 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { fetchUpdateLogFiles } from 'Store/Actions/systemActions';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import LogFiles from '../LogFiles';
|
||||
import { useUpdateLogFiles } from '../useLogFiles';
|
||||
|
||||
function UpdateLogFiles() {
|
||||
const dispatch = useDispatch();
|
||||
const { isFetching, items } = useSelector(
|
||||
(state: AppState) => state.system.updateLogFiles
|
||||
);
|
||||
const { data = [], isFetching, refetch } = useUpdateLogFiles();
|
||||
|
||||
const isDeleteFilesExecuting = useSelector(
|
||||
createCommandExecutingSelector(commandNames.DELETE_UPDATE_LOG_FILES)
|
||||
);
|
||||
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
dispatch(fetchUpdateLogFiles());
|
||||
}, [dispatch]);
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const handleDeleteFilesPress = useCallback(() => {
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: commandNames.DELETE_UPDATE_LOG_FILES,
|
||||
commandFinished: () => {
|
||||
dispatch(fetchUpdateLogFiles());
|
||||
refetch();
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchUpdateLogFiles());
|
||||
}, [dispatch]);
|
||||
}, [dispatch, refetch]);
|
||||
|
||||
return (
|
||||
<LogFiles
|
||||
isDeleteFilesExecuting={isDeleteFilesExecuting}
|
||||
isFetching={isFetching}
|
||||
items={items}
|
||||
items={data}
|
||||
type="update"
|
||||
onRefreshPress={handleRefreshPress}
|
||||
onDeleteFilesPress={handleDeleteFilesPress}
|
||||
|
||||
14
frontend/src/System/Logs/useLogFiles.ts
Normal file
14
frontend/src/System/Logs/useLogFiles.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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',
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import { icons, kinds } from 'Helpers/Props';
|
||||
import useUpdateSettings from 'Settings/General/useUpdateSettings';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { fetchGeneralSettings } from 'Store/Actions/settingsActions';
|
||||
import { fetchUpdates } from 'Store/Actions/systemActions';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
@@ -114,7 +113,6 @@ function Updates() {
|
||||
}, [setIsMajorUpdateModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchUpdates());
|
||||
dispatch(fetchGeneralSettings());
|
||||
}, [dispatch]);
|
||||
|
||||
|
||||
26
frontend/src/Utilities/Date/convertToTimezone.ts
Normal file
26
frontend/src/Utilities/Date/convertToTimezone.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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;
|
||||
@@ -1,11 +1,15 @@
|
||||
import moment, { MomentInput } from 'moment';
|
||||
import moment from 'moment-timezone';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { convertToTimezone } from './convertToTimezone';
|
||||
import formatTime from './formatTime';
|
||||
import isToday from './isToday';
|
||||
import isTomorrow from './isTomorrow';
|
||||
import isYesterday from './isYesterday';
|
||||
|
||||
function getRelativeDay(date: MomentInput, includeRelativeDate: boolean) {
|
||||
function getRelativeDay(
|
||||
date: moment.MomentInput,
|
||||
includeRelativeDate: boolean
|
||||
) {
|
||||
if (!includeRelativeDate) {
|
||||
return '';
|
||||
}
|
||||
@@ -26,20 +30,23 @@ function getRelativeDay(date: MomentInput, includeRelativeDate: boolean) {
|
||||
}
|
||||
|
||||
function formatDateTime(
|
||||
date: MomentInput,
|
||||
date: moment.MomentInput,
|
||||
dateFormat: string,
|
||||
timeFormat: string,
|
||||
{ includeSeconds = false, includeRelativeDay = false } = {}
|
||||
{ includeSeconds = false, includeRelativeDay = false, timeZone = '' } = {}
|
||||
) {
|
||||
if (!date) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const relativeDay = getRelativeDay(date, includeRelativeDay);
|
||||
const formattedDate = moment(date).format(dateFormat);
|
||||
const formattedTime = formatTime(date, timeFormat, {
|
||||
const dateTime = convertToTimezone(date, timeZone);
|
||||
|
||||
const relativeDay = getRelativeDay(dateTime, includeRelativeDay);
|
||||
const formattedDate = dateTime.format(dateFormat);
|
||||
const formattedTime = formatTime(dateTime, timeFormat, {
|
||||
includeMinuteZero: true,
|
||||
includeSeconds,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
if (relativeDay) {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import moment, { MomentInput } from 'moment';
|
||||
import moment from 'moment-timezone';
|
||||
import { convertToTimezone } from './convertToTimezone';
|
||||
|
||||
function formatTime(
|
||||
date: MomentInput,
|
||||
date: moment.MomentInput,
|
||||
timeFormat: string,
|
||||
{ includeMinuteZero = false, includeSeconds = false } = {}
|
||||
{ includeMinuteZero = false, includeSeconds = false, timeZone = '' } = {}
|
||||
) {
|
||||
if (!date) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const time = moment(date);
|
||||
const time = convertToTimezone(date, timeZone);
|
||||
|
||||
if (includeSeconds) {
|
||||
timeFormat = timeFormat.replace(/\(?:mm\)?/, ':mm:ss');
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import moment from 'moment';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import isInNextWeek from 'Utilities/Date/isInNextWeek';
|
||||
import isToday from 'Utilities/Date/isToday';
|
||||
import isTomorrow from 'Utilities/Date/isTomorrow';
|
||||
import isYesterday from 'Utilities/Date/isYesterday';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { convertToTimezone } from './convertToTimezone';
|
||||
import formatDateTime from './formatDateTime';
|
||||
|
||||
interface GetRelativeDateOptions {
|
||||
@@ -12,6 +12,7 @@ interface GetRelativeDateOptions {
|
||||
shortDateFormat: string;
|
||||
showRelativeDates: boolean;
|
||||
timeFormat?: string;
|
||||
timeZone?: string;
|
||||
includeSeconds?: boolean;
|
||||
timeForToday?: boolean;
|
||||
includeTime?: boolean;
|
||||
@@ -22,6 +23,7 @@ function getRelativeDate({
|
||||
shortDateFormat,
|
||||
showRelativeDates,
|
||||
timeFormat,
|
||||
timeZone = '',
|
||||
includeSeconds = false,
|
||||
timeForToday = false,
|
||||
includeTime = false,
|
||||
@@ -41,6 +43,7 @@ function getRelativeDate({
|
||||
? formatTime(date, timeFormat, {
|
||||
includeMinuteZero: true,
|
||||
includeSeconds,
|
||||
timeZone,
|
||||
})
|
||||
: '';
|
||||
|
||||
@@ -49,7 +52,8 @@ function getRelativeDate({
|
||||
}
|
||||
|
||||
if (!showRelativeDates) {
|
||||
return moment(date).format(shortDateFormat);
|
||||
const dateTime = convertToTimezone(date, timeZone);
|
||||
return dateTime.format(shortDateFormat);
|
||||
}
|
||||
|
||||
if (isYesterday(date)) {
|
||||
@@ -69,14 +73,18 @@ function getRelativeDate({
|
||||
}
|
||||
|
||||
if (isInNextWeek(date)) {
|
||||
const day = moment(date).format('dddd');
|
||||
const dateTime = convertToTimezone(date, timeZone);
|
||||
const day = dateTime.format('dddd');
|
||||
|
||||
return includeTime ? translate('DayOfWeekAt', { day, time }) : day;
|
||||
}
|
||||
|
||||
return includeTime && timeFormat
|
||||
? formatDateTime(date, shortDateFormat, timeFormat, { includeSeconds })
|
||||
: moment(date).format(shortDateFormat);
|
||||
? formatDateTime(date, shortDateFormat, timeFormat, {
|
||||
includeSeconds,
|
||||
timeZone,
|
||||
})
|
||||
: convertToTimezone(date, timeZone).format(shortDateFormat);
|
||||
}
|
||||
|
||||
export default getRelativeDate;
|
||||
|
||||
192
frontend/src/Utilities/Date/timeZoneOptions.ts
Normal file
192
frontend/src/Utilities/Date/timeZoneOptions.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
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,6 +4,7 @@ export default interface UiSettings {
|
||||
shortDateFormat: string;
|
||||
longDateFormat: string;
|
||||
timeFormat: string;
|
||||
timeZone: string;
|
||||
firstDayOfWeek: number;
|
||||
enableColorImpairedMode: boolean;
|
||||
calendarWeekColumnHeader: string;
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"lodash": "4.17.21",
|
||||
"mobile-detect": "1.4.5",
|
||||
"moment": "2.30.1",
|
||||
"moment-timezone": "0.6.0",
|
||||
"mousetrap": "1.6.5",
|
||||
"normalize.css": "8.0.1",
|
||||
"prop-types": "15.8.1",
|
||||
@@ -94,6 +95,7 @@
|
||||
"@babel/preset-react": "7.27.1",
|
||||
"@babel/preset-typescript": "7.27.1",
|
||||
"@types/lodash": "4.14.195",
|
||||
"@types/moment-timezone": "0.5.30",
|
||||
"@types/mousetrap": "1.6.15",
|
||||
"@types/qs": "6.9.16",
|
||||
"@types/react-autosuggest": "10.1.11",
|
||||
|
||||
@@ -65,10 +65,12 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
|
||||
Subject.Aggregate(_remoteEpisode).Languages.Should().Equal(_remoteEpisode.ParsedEpisodeInfo.Languages);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_multi_languages_when_indexer_id_has_multi_languages_configuration()
|
||||
[TestCase("Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup")]
|
||||
[TestCase("Series Title (2025) [COMPLETA] [1080p H265 EAC3 MultiLang MultiSub][RlsGroup]")]
|
||||
[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
|
||||
{
|
||||
Id = 1,
|
||||
@@ -87,10 +89,12 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
|
||||
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_multi_languages_from_indexer_with_id_when_indexer_id_and_name_are_set()
|
||||
[TestCase("Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup")]
|
||||
[TestCase("Series Title (2025) [COMPLETA] [1080p H265 EAC3 MultiLang MultiSub][RlsGroup]")]
|
||||
[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
|
||||
{
|
||||
Id = 1,
|
||||
@@ -122,10 +126,12 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
|
||||
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_multi_languages_when_indexer_name_has_multi_languages_configuration()
|
||||
[TestCase("Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup")]
|
||||
[TestCase("Series Title (2025) [COMPLETA] [1080p H265 EAC3 MultiLang MultiSub][RlsGroup]")]
|
||||
[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
|
||||
{
|
||||
Id = 1,
|
||||
@@ -146,10 +152,12 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
|
||||
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_multi_languages_when_release_as_unknown_as_default_language_and_indexer_has_multi_languages_configuration()
|
||||
[TestCase("Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup")]
|
||||
[TestCase("Series Title (2025) [COMPLETA] [1080p H265 EAC3 MultiLang MultiSub][RlsGroup]")]
|
||||
[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
|
||||
{
|
||||
Id = 1,
|
||||
@@ -168,10 +176,12 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
|
||||
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_multi_languages_when_release_as_specified_language_and_indexer_has_multi_languages_configuration()
|
||||
[TestCase("Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup")]
|
||||
[TestCase("Series Title (2025) [COMPLETA] [1080p H265 EAC3 MultiLang MultiSub][RlsGroup]")]
|
||||
[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
|
||||
{
|
||||
Id = 1,
|
||||
@@ -190,10 +200,12 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
|
||||
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_multi_languages_when_release_as_other_language_and_indexer_has_multi_languages_configuration()
|
||||
[TestCase("Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup")]
|
||||
[TestCase("Series Title (2025) [COMPLETA] [1080p H265 EAC3 MultiLang MultiSub][RlsGroup]")]
|
||||
[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
|
||||
{
|
||||
Id = 1,
|
||||
@@ -212,10 +224,12 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
|
||||
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_original_when_indexer_has_no_multi_languages_configuration()
|
||||
[TestCase("Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup")]
|
||||
[TestCase("Series Title (2025) [COMPLETA] [1080p H265 EAC3 MultiLang MultiSub][RlsGroup]")]
|
||||
[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
|
||||
{
|
||||
Id = 1,
|
||||
@@ -234,11 +248,12 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
|
||||
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_original_when_no_indexer_value()
|
||||
[TestCase("Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup")]
|
||||
[TestCase("Series Title (2025) [COMPLETA] [1080p H265 EAC3 MultiLang MultiSub][RlsGroup]")]
|
||||
[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.Release.Title = releaseTitle;
|
||||
|
||||
|
||||
@@ -343,6 +343,13 @@ namespace NzbDrone.Core.Configuration
|
||||
set { SetValue("TimeFormat", value); }
|
||||
}
|
||||
|
||||
public string TimeZone
|
||||
{
|
||||
get { return GetValue("TimeZone", ""); }
|
||||
|
||||
set { SetValue("TimeZone", value); }
|
||||
}
|
||||
|
||||
public bool ShowRelativeDates
|
||||
{
|
||||
get { return GetValueBoolean("ShowRelativeDates", true); }
|
||||
|
||||
@@ -68,6 +68,7 @@ namespace NzbDrone.Core.Configuration
|
||||
string ShortDateFormat { get; set; }
|
||||
string LongDateFormat { get; set; }
|
||||
string TimeFormat { get; set; }
|
||||
string TimeZone { get; set; }
|
||||
bool ShowRelativeDates { get; set; }
|
||||
bool EnableColorImpairedMode { get; set; }
|
||||
int UILanguage { get; set; }
|
||||
|
||||
@@ -434,8 +434,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
|
||||
}
|
||||
catch (HttpException ex)
|
||||
{
|
||||
_logger.Debug("qbitTorrent authentication failed.");
|
||||
if (ex.Response.StatusCode == HttpStatusCode.Forbidden)
|
||||
_logger.Debug(ex, "qbitTorrent authentication failed.");
|
||||
if (ex.Response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
if (response.Content != "Ok.")
|
||||
if (response.Content.IsNotNullOrWhiteSpace() && response.Content != "Ok.")
|
||||
{
|
||||
// returns "Fails." on bad login
|
||||
_logger.Debug("qbitTorrent authentication failed.");
|
||||
|
||||
@@ -715,6 +715,11 @@ namespace NzbDrone.Core.Notifications.Discord
|
||||
return null;
|
||||
}
|
||||
|
||||
if (episodes.Empty())
|
||||
{
|
||||
return series.Title.Replace("`", "\\`");
|
||||
}
|
||||
|
||||
if (series.SeriesType == SeriesTypes.Daily)
|
||||
{
|
||||
var episode = episodes.First();
|
||||
@@ -722,8 +727,7 @@ namespace NzbDrone.Core.Notifications.Discord
|
||||
return $"{series.Title} - {episode.AirDate} - {episode.Title}".Replace("`", "\\`");
|
||||
}
|
||||
|
||||
var episodeNumbers = string.Concat(episodes.Select(e => e.EpisodeNumber)
|
||||
.Select(i => string.Format("x{0:00}", i)));
|
||||
var episodeNumbers = string.Concat(episodes.Select(e => $"x{e.EpisodeNumber:00}"));
|
||||
|
||||
var episodeTitles = string.Join(" + ", episodes.Select(e => e.Title));
|
||||
|
||||
|
||||
@@ -49,38 +49,30 @@ namespace NzbDrone.Core.Notifications
|
||||
{
|
||||
var qualityString = GetQualityString(series, quality);
|
||||
|
||||
if (episodes.Empty())
|
||||
{
|
||||
return $"{series.Title} - [{qualityString}]";
|
||||
}
|
||||
|
||||
if (series.SeriesType == SeriesTypes.Daily)
|
||||
{
|
||||
var episode = episodes.First();
|
||||
|
||||
return string.Format("{0} - {1} - {2} [{3}]",
|
||||
series.Title,
|
||||
episode.AirDate,
|
||||
episode.Title,
|
||||
qualityString);
|
||||
return $"{series.Title} - {episode.AirDate} - {episode.Title} [{qualityString}]";
|
||||
}
|
||||
|
||||
var episodeNumbers = string.Concat(episodes.Select(e => e.EpisodeNumber)
|
||||
.Select(i => string.Format("x{0:00}", i)));
|
||||
var episodeNumbers = string.Concat(episodes.Select(e => $"x{e.EpisodeNumber:00}"));
|
||||
|
||||
var episodeTitles = string.Join(" + ", episodes.Select(e => e.Title));
|
||||
|
||||
return string.Format("{0} - {1}{2} - {3} [{4}]",
|
||||
series.Title,
|
||||
episodes.First().SeasonNumber,
|
||||
episodeNumbers,
|
||||
episodeTitles,
|
||||
qualityString);
|
||||
return $"{series.Title} - {episodes.First().SeasonNumber}{episodeNumbers} - {episodeTitles} [{qualityString}]";
|
||||
}
|
||||
|
||||
private string GetFullSeasonMessage(Series series, int seasonNumber, QualityModel quality)
|
||||
{
|
||||
var qualityString = GetQualityString(series, quality);
|
||||
|
||||
return string.Format("{0} - Season {1} [{2}]",
|
||||
series.Title,
|
||||
seasonNumber,
|
||||
qualityString);
|
||||
return $"{series.Title} - Season {seasonNumber} [{qualityString}]";
|
||||
}
|
||||
|
||||
private string GetQualityString(Series series, QualityModel quality)
|
||||
|
||||
@@ -266,6 +266,16 @@ namespace NzbDrone.Core.Notifications.Slack
|
||||
|
||||
private string GetTitle(Series series, List<Episode> episodes)
|
||||
{
|
||||
if (series == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (episodes.Empty())
|
||||
{
|
||||
return series.Title;
|
||||
}
|
||||
|
||||
if (series.SeriesType == SeriesTypes.Daily)
|
||||
{
|
||||
var episode = episodes.First();
|
||||
@@ -273,8 +283,7 @@ namespace NzbDrone.Core.Notifications.Slack
|
||||
return $"{series.Title} - {episode.AirDate} - {episode.Title}";
|
||||
}
|
||||
|
||||
var episodeNumbers = string.Concat(episodes.Select(e => e.EpisodeNumber)
|
||||
.Select(i => string.Format("x{0:00}", i)));
|
||||
var episodeNumbers = string.Concat(episodes.Select(e => $"x{e.EpisodeNumber:00}"));
|
||||
|
||||
var episodeTitles = string.Join(" + ", episodes.Select(e => e.Title));
|
||||
|
||||
|
||||
@@ -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 Regex MultiRegex = new(@"[_. ](?<multi>multi)[_. ]", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex MultiRegex = new(@"[-_. \[](?<multi>multi|multilang|multilanguage)[-_. \]]", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private static readonly Dictionary<string, int> ShortMonths = new()
|
||||
{
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.20" />
|
||||
<PackageReference Include="System.Memory" Version="4.6.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="FluentMigrator.Runner" Version="6.2.0" />
|
||||
<PackageReference Include="FluentMigrator.Runner.Core" Version="6.2.0" />
|
||||
<PackageReference Include="FluentMigrator.Runner.SQLite" Version="6.2.0" />
|
||||
<PackageReference Include="FluentMigrator.Runner.Postgres" Version="6.2.0" />
|
||||
<PackageReference Include="FluentValidation" Version="9.5.4" />
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
using System.IO;
|
||||
using System;
|
||||
using System.IO;
|
||||
using Mono.Unix;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Instrumentation;
|
||||
|
||||
namespace NzbDrone.Mono.Disk
|
||||
{
|
||||
public class ProcMount : IMount
|
||||
{
|
||||
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(ProcMount));
|
||||
private readonly UnixDriveInfo _unixDriveInfo;
|
||||
|
||||
public ProcMount(DriveType driveType, string name, string mount, string type, MountOptions mountOptions)
|
||||
@@ -34,9 +38,37 @@ namespace NzbDrone.Mono.Disk
|
||||
|
||||
public string RootDirectory { get; private set; }
|
||||
|
||||
public long TotalFreeSpace => _unixDriveInfo.TotalFreeSpace;
|
||||
public long TotalFreeSpace
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
return _unixDriveInfo.TotalFreeSpace;
|
||||
}
|
||||
catch (OverflowException ex)
|
||||
{
|
||||
Logger.Warn(ex, "Failed to get total free space");
|
||||
return long.MaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long TotalSize => _unixDriveInfo.TotalSize;
|
||||
public long 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;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Linq;
|
||||
using System.Reflection;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Authentication;
|
||||
using NzbDrone.Core.Configuration;
|
||||
@@ -25,7 +26,7 @@ namespace Sonarr.Api.V3.Config
|
||||
public HostConfigController(IConfigFileProvider configFileProvider,
|
||||
IConfigService configService,
|
||||
IUserService userService,
|
||||
FileExistsValidator fileExistsValidator)
|
||||
IDiskProvider diskProvider)
|
||||
{
|
||||
_configFileProvider = configFileProvider;
|
||||
_configService = configService;
|
||||
@@ -59,14 +60,14 @@ namespace Sonarr.Api.V3.Config
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.NotEmpty()
|
||||
.IsValidPath()
|
||||
.SetValidator(fileExistsValidator)
|
||||
.SetValidator(new FileExistsValidator(diskProvider))
|
||||
.IsValidCertificate()
|
||||
.When(c => c.EnableSsl);
|
||||
|
||||
SharedValidator.RuleFor(c => c.SslKeyPath)
|
||||
.NotEmpty()
|
||||
.IsValidPath()
|
||||
.SetValidator(fileExistsValidator)
|
||||
.SetValidator(new FileExistsValidator(diskProvider))
|
||||
.When(c => c.SslKeyPath.IsNotNullOrWhiteSpace());
|
||||
|
||||
SharedValidator.RuleFor(c => c.LogSizeLimit).InclusiveBetween(1, 10);
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace Sonarr.Api.V3.Config
|
||||
public string ShortDateFormat { get; set; }
|
||||
public string LongDateFormat { get; set; }
|
||||
public string TimeFormat { get; set; }
|
||||
public string TimeZone { get; set; }
|
||||
public bool ShowRelativeDates { get; set; }
|
||||
|
||||
public bool EnableColorImpairedMode { get; set; }
|
||||
@@ -32,6 +33,7 @@ namespace Sonarr.Api.V3.Config
|
||||
ShortDateFormat = model.ShortDateFormat,
|
||||
LongDateFormat = model.LongDateFormat,
|
||||
TimeFormat = model.TimeFormat,
|
||||
TimeZone = model.TimeZone,
|
||||
ShowRelativeDates = model.ShowRelativeDates,
|
||||
|
||||
EnableColorImpairedMode = model.EnableColorImpairedMode,
|
||||
|
||||
134
src/Sonarr.Api.V5/History/HistoryController.cs
Normal file
134
src/Sonarr.Api.V5/History/HistoryController.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
53
src/Sonarr.Api.V5/History/HistoryResource.cs
Normal file
53
src/Sonarr.Api.V5/History/HistoryResource.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
41
src/Sonarr.Api.V5/Logs/LogFileController.cs
Normal file
41
src/Sonarr.Api.V5/Logs/LogFileController.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/Sonarr.Api.V5/Logs/LogFileControllerBase.cs
Normal file
71
src/Sonarr.Api.V5/Logs/LogFileControllerBase.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
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
src/Sonarr.Api.V5/Logs/LogFileResource.cs
Normal file
11
src/Sonarr.Api.V5/Logs/LogFileResource.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
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; }
|
||||
}
|
||||
49
src/Sonarr.Api.V5/Logs/UpdateLogFileController.cs
Normal file
49
src/Sonarr.Api.V5/Logs/UpdateLogFileController.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,10 +53,10 @@ namespace Sonarr.Api.V5.Queue
|
||||
{
|
||||
Id = model.Id,
|
||||
SeriesId = model.Series?.Id,
|
||||
EpisodeIds = model.Episodes.Select(e => e.Id).ToList(),
|
||||
SeasonNumbers = model.SeasonNumber.HasValue ? new List<int> { model.SeasonNumber.Value } : new List<int>(),
|
||||
EpisodeIds = model.Episodes?.Select(e => e.Id).ToList() ?? [],
|
||||
SeasonNumbers = model.SeasonNumber.HasValue ? [model.SeasonNumber.Value] : [],
|
||||
Series = includeSeries && model.Series != null ? model.Series.ToResource() : null,
|
||||
Episodes = includeEpisodes ? model.Episodes.ToResource() : null,
|
||||
Episodes = includeEpisodes ? model.Episodes?.ToResource() : null,
|
||||
Languages = model.Languages,
|
||||
Quality = model.Quality,
|
||||
CustomFormats = customFormats?.ToResource(false) ?? [],
|
||||
@@ -78,7 +78,7 @@ namespace Sonarr.Api.V5.Queue
|
||||
DownloadClientHasPostImportCategory = model.DownloadClientHasPostImportCategory,
|
||||
Indexer = model.Indexer,
|
||||
OutputPath = model.OutputPath,
|
||||
EpisodesWithFilesCount = model.Episodes.Count(e => e.HasFile),
|
||||
EpisodesWithFilesCount = model.Episodes?.Count(e => e.HasFile) ?? 0,
|
||||
IsFullSeason = model.RemoteEpisode?.ParsedEpisodeInfo?.FullSeason ?? false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +113,134 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -224,6 +352,13 @@
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "removeFromClient",
|
||||
"in": "query",
|
||||
@@ -270,6 +405,13 @@
|
||||
"Queue"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "removeFromClient",
|
||||
"in": "query",
|
||||
@@ -1080,6 +1222,128 @@
|
||||
},
|
||||
"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": {
|
||||
"required": [
|
||||
"name"
|
||||
|
||||
16
yarn.lock
16
yarn.lock
@@ -1395,6 +1395,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e"
|
||||
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":
|
||||
version "1.6.15"
|
||||
resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.15.tgz#f144a0c539a4cef553a631824651d48267e53c86"
|
||||
@@ -4751,7 +4758,14 @@ mobile-detect@1.4.5:
|
||||
resolved "https://registry.yarnpkg.com/mobile-detect/-/mobile-detect-1.4.5.tgz#da393c3c413ca1a9bcdd9ced653c38281c0fb6ad"
|
||||
integrity sha512-yc0LhH6tItlvfLBugVUEtgawwFU2sIe+cSdmRJJCTMZ5GEJyLxNyC/NIOAOGk67Fa8GNpOttO3Xz/1bHpXFD/g==
|
||||
|
||||
moment@2.30.1:
|
||||
moment-timezone@*, moment-timezone@0.6.0:
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
|
||||
integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==
|
||||
|
||||
Reference in New Issue
Block a user