1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-03-05 13:20:20 -05:00

Compare commits

..

2 Commits

Author SHA1 Message Date
Mark McDowall
5f359e975d Use react-query for queue UI
New: Season packs and multi-episode releases will show as a single item in the queue
Closes #6537
2025-08-30 14:09:00 -07:00
Mark McDowall
e213f156af Add v5 queue endpoints 2025-08-30 14:09:00 -07:00
199 changed files with 1430 additions and 5763 deletions

View File

@@ -21,7 +21,7 @@ runs:
using: "composite"
steps:
- name: Setup .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v4
- name: Setup Environment Variables
id: variables

View File

@@ -4,8 +4,6 @@ description: Runs unit/integration tests
inputs:
use_postgres:
description: 'Whether postgres should be used for the database'
postgres-version:
description: 'Which postgres version should be used for the database'
os:
description: 'OS that the tests are running on'
required: true
@@ -29,18 +27,16 @@ runs:
using: 'composite'
steps:
- name: Setup .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v4
- name: Setup Postgres
if: ${{ inputs.use_postgres }}
uses: ikalnytskyi/action-setup-postgres@v7
with:
postgres-version: ${{ inputs.postgres-version }}
uses: ikalnytskyi/action-setup-postgres@v4
- name: Setup Test Variables
shell: bash
run: |
echo "RESULTS_NAME=${{ inputs.integration_tests && 'integation-' || 'unit-' }}${{ inputs.artifact }}${{ inputs.use_postgres && '-postgres' }}${{ inputs.use_postgres && inputs.postgres-version && inputs.postgres-version }}" >> "$GITHUB_ENV"
echo "RESULTS_NAME=${{ inputs.integration_tests && 'integation-' || 'unit-' }}${{ inputs.artifact }}${{ inputs.use_postgres && '-postgres' }}" >> "$GITHUB_ENV"
- name: Setup Postgres Environment Variables
if: ${{ inputs.use_postgres }}
@@ -52,14 +48,14 @@ runs:
echo "Sonarr__Postgres__Password=postgres" >> "$GITHUB_ENV"
- name: Download Artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: ${{ inputs.artifact }}
path: _tests
- name: Download Binary Artifact
if: ${{ inputs.integration_tests }}
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: ${{ inputs.binary_artifact }}
path: _output

View File

@@ -82,7 +82,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Build
uses: ./.github/actions/build
@@ -97,7 +97,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Volta
uses: volta-cli/action@v4
@@ -139,7 +139,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Test
uses: ./.github/actions/test
@@ -152,13 +152,9 @@ jobs:
unit_test_postgres:
needs: backend
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
postgres-version: [16, 17]
steps:
- name: Check out
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Test
uses: ./.github/actions/test
@@ -168,7 +164,6 @@ jobs:
pattern: Sonarr.*.Test.dll
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
use_postgres: true
postgres-version: ${{ matrix.postgres-version }}
integration_test:
needs: [prepare, backend]
@@ -195,7 +190,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Check out
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Test
uses: ./.github/actions/test

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { setQueueOptions } from 'Activity/Queue/queueOptionsStore';
import { SelectProvider } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -16,8 +16,20 @@ 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 useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import usePrevious from 'Helpers/Hooks/usePrevious';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { align, icons, kinds } from 'Helpers/Props';
import {
clearBlocklist,
fetchBlocklist,
gotoBlocklistPage,
removeBlocklistItems,
setBlocklistFilter,
setBlocklistSort,
setBlocklistTableOption,
} from 'Store/Actions/blocklistActions';
import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
@@ -31,35 +43,27 @@ import {
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import BlocklistFilterModal from './BlocklistFilterModal';
import {
setBlocklistOption,
useBlocklistOptions,
} from './blocklistOptionsStore';
import BlocklistRow from './BlocklistRow';
import useBlocklist, {
useFilters,
useRemoveBlocklistItems,
} from './useBlocklist';
function Blocklist() {
const requestCurrentPage = useCurrentPage();
const {
records,
isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
totalPages,
totalRecords,
isFetching,
isFetched,
isLoading,
error,
page,
goToPage,
refetch,
} = useBlocklist();
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useBlocklistOptions();
const filters = useFilters();
const { isRemoving, removeBlocklistItems } = useRemoveBlocklistItems();
isRemoving,
} = useSelector((state: AppState) => state.blocklist);
const customFilters = useSelector(createCustomFiltersSelector('blocklist'));
const isClearingBlocklistExecuting = useSelector(
@@ -78,27 +82,28 @@ function Blocklist() {
return getSelectedIds(selectedState);
}, [selectedState]);
const wasClearingBlocklistExecuting = usePrevious(
isClearingBlocklistExecuting
);
const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => {
setSelectState({
type: value ? 'selectAll' : 'unselectAll',
items: records,
});
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[records, setSelectState]
[items, setSelectState]
);
const handleSelectedChange = useCallback(
({ id, value, shiftKey = false }: SelectStateInputProps) => {
setSelectState({
type: 'toggleSelected',
items: records,
items,
id,
isSelected: value,
shiftKey,
});
},
[records, setSelectState]
[items, setSelectState]
);
const handleRemoveSelectedPress = useCallback(() => {
@@ -106,9 +111,9 @@ function Blocklist() {
}, [setIsConfirmRemoveModalOpen]);
const handleRemoveSelectedConfirmed = useCallback(() => {
removeBlocklistItems({ ids: selectedIds });
dispatch(removeBlocklistItems({ ids: selectedIds }));
setIsConfirmRemoveModalOpen(false);
}, [selectedIds, setIsConfirmRemoveModalOpen, removeBlocklistItems]);
}, [selectedIds, setIsConfirmRemoveModalOpen, dispatch]);
const handleConfirmRemoveModalClose = useCallback(() => {
setIsConfirmRemoveModalOpen(false);
@@ -119,46 +124,66 @@ function Blocklist() {
}, [setIsConfirmClearModalOpen]);
const handleClearBlocklistConfirmed = useCallback(() => {
dispatch(
executeCommand({
name: commandNames.CLEAR_BLOCKLIST,
commandFinished: () => {
goToPage(1);
},
})
);
dispatch(executeCommand({ name: commandNames.CLEAR_BLOCKLIST }));
setIsConfirmClearModalOpen(false);
}, [setIsConfirmClearModalOpen, goToPage, dispatch]);
}, [setIsConfirmClearModalOpen, dispatch]);
const handleConfirmClearModalClose = useCallback(() => {
setIsConfirmClearModalOpen(false);
}, [setIsConfirmClearModalOpen]);
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoBlocklistPage,
});
const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => {
setBlocklistOption('selectedFilterKey', selectedFilterKey);
dispatch(setBlocklistFilter({ selectedFilterKey }));
},
[]
[dispatch]
);
const handleSortPress = useCallback((sortKey: string) => {
setBlocklistOption('sortKey', sortKey);
}, []);
const handleSortPress = useCallback(
(sortKey: string) => {
dispatch(setBlocklistSort({ sortKey }));
},
[dispatch]
);
const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => {
setQueueOptions(payload);
dispatch(setBlocklistTableOption(payload));
if (payload.pageSize) {
goToPage(1);
dispatch(gotoBlocklistPage({ page: 1 }));
}
},
[goToPage]
[dispatch]
);
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchBlocklist());
} else {
dispatch(gotoBlocklistPage({ page: 1 }));
}
return () => {
dispatch(clearBlocklist());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const repopulate = () => {
refetch();
dispatch(fetchBlocklist());
};
registerPagePopulator(repopulate);
@@ -166,10 +191,16 @@ function Blocklist() {
return () => {
unregisterPagePopulator(repopulate);
};
}, [refetch]);
}, [dispatch]);
useEffect(() => {
if (wasClearingBlocklistExecuting && !isClearingBlocklistExecuting) {
dispatch(gotoBlocklistPage({ page: 1 }));
}
}, [isClearingBlocklistExecuting, wasClearingBlocklistExecuting, dispatch]);
return (
<SelectProvider items={records}>
<SelectProvider items={items}>
<PageContent title={translate('Blocklist')}>
<PageToolbar>
<PageToolbarSection>
@@ -184,7 +215,7 @@ function Blocklist() {
<PageToolbarButton
label={translate('Clear')}
iconName={icons.CLEAR}
isDisabled={!records.length}
isDisabled={!items.length}
isSpinning={isClearingBlocklistExecuting}
onPress={handleClearBlocklistPress}
/>
@@ -214,13 +245,13 @@ function Blocklist() {
</PageToolbar>
<PageContentBody>
{isLoading && !isFetched ? <LoadingIndicator /> : null}
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isLoading && !!error ? (
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
) : null}
{isFetched && !error && !records.length ? (
{isPopulated && !error && !items.length ? (
<Alert kind={kinds.INFO}>
{selectedFilterKey === 'all'
? translate('NoBlocklistItems')
@@ -228,7 +259,7 @@ function Blocklist() {
</Alert>
) : null}
{isFetched && !error && !!records.length ? (
{isPopulated && !error && !!items.length ? (
<div>
<Table
selectAll={true}
@@ -243,7 +274,7 @@ function Blocklist() {
onSortPress={handleSortPress}
>
<TableBody>
{records.map((item) => {
{items.map((item) => {
return (
<BlocklistRow
key={item.id}
@@ -261,7 +292,11 @@ function Blocklist() {
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onPageSelect={goToPage}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/>
</div>
) : null}

View File

@@ -16,19 +16,13 @@ interface BlocklistDetailsModalProps {
protocol: DownloadProtocol;
indexer?: string;
message?: string;
source?: string;
onModalClose: () => void;
}
function BlocklistDetailsModal({
isOpen,
sourceTitle,
protocol,
indexer,
message,
source,
onModalClose,
}: BlocklistDetailsModalProps) {
function BlocklistDetailsModal(props: BlocklistDetailsModalProps) {
const { isOpen, sourceTitle, protocol, indexer, message, onModalClose } =
props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ModalContent onModalClose={onModalClose}>
@@ -56,9 +50,6 @@ function BlocklistDetailsModal({
data={message}
/>
) : null}
{source ? (
<DescriptionListItem title={translate('Source')} data={source} />
) : null}
</DescriptionList>
</ModalBody>

View File

@@ -1,26 +1,50 @@
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 { setBlocklistOption } from './blocklistOptionsStore';
import useBlocklist, { FILTER_BUILDER } from './useBlocklist';
import { setBlocklistFilter } from 'Store/Actions/blocklistActions';
function createBlocklistSelector() {
return createSelector(
(state: AppState) => state.blocklist.items,
(blocklistItems) => {
return blocklistItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.blocklist.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
type BlocklistFilterModalProps = FilterModalProps<History>;
export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
const { records } = useBlocklist();
const sectionItems = useSelector(createBlocklistSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'blocklist';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
setBlocklistOption('selectedFilterKey', selectedFilterKey);
(payload: unknown) => {
dispatch(setBlocklistFilter(payload));
},
[]
[dispatch]
);
return (
<FilterModal
{...props}
sectionItems={records}
filterBuilderProps={FILTER_BUILDER}
customFilterType="blocklist"
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>
);

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import IconButton from 'Components/Link/IconButton';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
@@ -11,11 +12,11 @@ import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds } from 'Helpers/Props';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries';
import { removeBlocklistItem } from 'Store/Actions/blocklistActions';
import Blocklist from 'typings/Blocklist';
import { SelectStateInputProps } from 'typings/props';
import translate from 'Utilities/String/translate';
import BlocklistDetailsModal from './BlocklistDetailsModal';
import { useRemoveBlocklistItem } from './useBlocklist';
import styles from './BlocklistRow.css';
interface BlocklistRowProps extends Blocklist {
@@ -24,24 +25,25 @@ interface BlocklistRowProps extends Blocklist {
onSelectedChange: (options: SelectStateInputProps) => void;
}
function BlocklistRow({
id,
seriesId,
sourceTitle,
languages,
quality,
customFormats,
date,
protocol,
indexer,
message,
source,
isSelected,
columns,
onSelectedChange,
}: BlocklistRowProps) {
function BlocklistRow(props: BlocklistRowProps) {
const {
id,
seriesId,
sourceTitle,
languages,
quality,
customFormats,
date,
protocol,
indexer,
message,
isSelected,
columns,
onSelectedChange,
} = props;
const series = useSeries(seriesId);
const { isRemoving, removeBlocklistItem } = useRemoveBlocklistItem(id);
const dispatch = useDispatch();
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const handleDetailsPress = useCallback(() => {
@@ -53,8 +55,8 @@ function BlocklistRow({
}, [setIsDetailsModalOpen]);
const handleRemovePress = useCallback(() => {
removeBlocklistItem();
}, [removeBlocklistItem]);
dispatch(removeBlocklistItem({ id }));
}, [id, dispatch]);
if (!series) {
return null;
@@ -137,7 +139,6 @@ function BlocklistRow({
title={translate('RemoveFromBlocklist')}
name={icons.REMOVE}
kind={kinds.DANGER}
isSpinning={isRemoving}
onPress={handleRemovePress}
/>
</TableRowCell>
@@ -153,7 +154,6 @@ function BlocklistRow({
protocol={protocol}
indexer={indexer}
message={message}
source={source}
onModalClose={handleDetailsModalClose}
/>
</TableRow>

View File

@@ -1,71 +0,0 @@
import {
createOptionsStore,
PageableOptions,
} from 'Helpers/Hooks/useOptionsStore';
import translate from 'Utilities/String/translate';
export type BlocklistOptions = PageableOptions;
const { useOptions, useOption, setOptions, setOption } =
createOptionsStore<BlocklistOptions>('blocklist_options', () => {
return {
pageSize: 20,
selectedFilterKey: 'all',
sortKey: 'time',
sortDirection: 'descending',
columns: [
{
name: 'series.sortTitle',
label: () => translate('SeriesTitle'),
isSortable: true,
isVisible: true,
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isSortable: true,
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: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: false,
},
{
name: 'actions',
label: '',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false,
},
],
};
});
export const useBlocklistOptions = useOptions;
export const setBlocklistOptions = setOptions;
export const useBlocklistOption = useOption;
export const setBlocklistOption = setOption;

View File

@@ -1,116 +0,0 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { useMemo } 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 Blocklist from 'typings/Blocklist';
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
import translate from 'Utilities/String/translate';
import { useBlocklistOptions } from './blocklistOptionsStore';
interface BulkBlocklistData {
ids: number[];
}
export const FILTERS: Filter[] = [
{
key: 'all',
label: () => translate('All'),
filters: [],
},
];
export const FILTER_BUILDER: FilterBuilderProp<Blocklist>[] = [
{
name: 'seriesIds',
label: () => translate('Series'),
type: 'equal',
valueType: filterBuilderValueTypes.SERIES,
},
{
name: 'protocols',
label: () => translate('Protocol'),
type: 'equal',
valueType: filterBuilderValueTypes.PROTOCOL,
},
];
const useBlocklist = () => {
const { page, goToPage } = usePage('blocklist');
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
useBlocklistOptions();
const customFilters = useSelector(
createCustomFiltersSelector('blocklist')
) as CustomFilter[];
const filters = useMemo(() => {
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
}, [selectedFilterKey, customFilters]);
const { refetch, ...query } = usePagedApiQuery<Blocklist>({
path: '/blocklist',
page,
pageSize,
filters,
sortKey,
sortDirection,
queryOptions: {
placeholderData: keepPreviousData,
},
});
return {
...query,
goToPage,
page,
refetch,
};
};
export default useBlocklist;
export const useFilters = () => {
return FILTERS;
};
export const useRemoveBlocklistItem = (id: number) => {
const queryClient = useQueryClient();
const { mutate, isPending } = useApiMutation<unknown, void>({
path: `/blocklist/${id}`,
method: 'DELETE',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/blocklist'] });
},
},
});
return {
removeBlocklistItem: mutate,
isRemoving: isPending,
};
};
export const useRemoveBlocklistItems = () => {
const queryClient = useQueryClient();
const { mutate, isPending } = useApiMutation<unknown, BulkBlocklistData>({
path: `/blocklist/bulk`,
method: 'DELETE',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/blocklist'] });
},
},
});
return {
removeBlocklistItems: mutate,
isRemoving: isPending,
};
};

View File

@@ -174,7 +174,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
}
if (eventType === 'downloadFailed') {
const { indexer, message, source } = data as DownloadFailedHistory;
const { message, indexer } = data as DownloadFailedHistory;
return (
<DescriptionList>
@@ -195,10 +195,6 @@ function HistoryDetails(props: HistoryDetailsProps) {
{message ? (
<DescriptionListItem title={translate('Message')} data={message} />
) : null}
{source ? (
<DescriptionListItem title={translate('Source')} data={source} />
) : null}
</DescriptionList>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React from 'react';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import Modal from 'Components/Modal/Modal';
@@ -9,7 +9,6 @@ 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';
@@ -34,32 +33,26 @@ 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, 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]);
const {
isOpen,
eventType,
sourceTitle,
data,
downloadId,
isMarkingAsFailed = false,
onMarkAsFailedPress,
onModalClose,
} = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
@@ -81,7 +74,7 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
className={styles.markAsFailedButton}
kind={kinds.DANGER}
isSpinning={isMarkingAsFailed}
onPress={handleMarkAsFailedPress}
onPress={onMarkAsFailedPress}
>
{translate('MarkAsFailed')}
</SpinnerButton>

View File

@@ -1,9 +1,6 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
setQueueOption,
setQueueOptions,
} from 'Activity/Queue/queueOptionsStore';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
@@ -16,11 +13,20 @@ 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';
@@ -31,90 +37,100 @@ 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 {
records,
isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
totalPages,
totalRecords,
error,
isFetching,
isFetched,
isLoading,
page,
goToPage,
refetch,
} = useHistory();
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useHistoryOptions();
const filters = useFilters();
const requestCurrentPage = useCurrentPage();
} = useSelector((state: AppState) => state.history);
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
useSelector(createEpisodesFetchingSelector());
const customFilters = useSelector(createCustomFiltersSelector('history'));
const dispatch = useDispatch();
const isFetchingAny = isLoading || isEpisodesFetching;
const isAllPopulated = isFetched && (isEpisodesPopulated || !records.length);
const isFetchingAny = isFetching || isEpisodesFetching;
const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length);
const hasError = error || episodesError;
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoHistoryPage,
});
const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => {
setQueueOption('selectedFilterKey', selectedFilterKey);
dispatch(setHistoryFilter({ selectedFilterKey }));
},
[]
[dispatch]
);
const handleSortPress = useCallback((sortKey: string) => {
setQueueOption('sortKey', sortKey);
}, []);
const handleSortPress = useCallback(
(sortKey: string) => {
dispatch(setHistorySort({ sortKey }));
},
[dispatch]
);
const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => {
setQueueOptions(payload);
dispatch(setHistoryTableOption(payload));
if (payload.pageSize) {
goToPage(1);
dispatch(gotoHistoryPage({ page: 1 }));
}
},
[goToPage]
[dispatch]
);
const handleRefreshPress = useCallback(() => {
goToPage(1);
refetch();
}, [goToPage, refetch]);
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchHistory());
} else {
dispatch(gotoHistoryPage({ page: 1 }));
}
return () => {
dispatch(clearHistory());
dispatch(clearEpisodes());
dispatch(clearEpisodeFiles());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const episodeIds = selectUniqueIds<HistoryItem, number>(
records,
'episodeId'
);
const episodeIds = selectUniqueIds<HistoryItem, number>(items, 'episodeId');
if (episodeIds.length) {
dispatch(fetchEpisodes({ episodeIds }));
} else {
dispatch(clearEpisodes());
}
}, [records, dispatch]);
}, [items, dispatch]);
useEffect(() => {
const repopulate = () => {
refetch();
dispatch(fetchHistory());
};
registerPagePopulator(repopulate);
@@ -122,7 +138,7 @@ function History() {
return () => {
unregisterPagePopulator(repopulate);
};
}, [refetch]);
}, [dispatch]);
return (
<PageContent title={translate('History')}>
@@ -132,7 +148,7 @@ function History() {
label={translate('Refresh')}
iconName={icons.REFRESH}
isSpinning={isFetching}
onPress={handleRefreshPress}
onPress={handleFirstPagePress}
/>
</PageToolbarSection>
@@ -170,12 +186,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.
isFetched && !hasError && !records.length ? (
isPopulated && !hasError && !items.length ? (
<Alert kind={kinds.INFO}>{translate('NoHistoryFound')}</Alert>
) : null
}
{isAllPopulated && !hasError && records.length ? (
{isAllPopulated && !hasError && items.length ? (
<div>
<Table
columns={columns}
@@ -186,7 +202,7 @@ function History() {
onSortPress={handleSortPress}
>
<TableBody>
{records.map((item) => {
{items.map((item) => {
return (
<HistoryRow key={item.id} columns={columns} {...item} />
);
@@ -199,7 +215,11 @@ function History() {
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onPageSelect={goToPage}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/>
</div>
) : null}

View File

@@ -1,25 +1,48 @@
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 { setHistoryOption } from './historyOptionsStore';
import useHistory, { FILTER_BUILDER } from './useHistory';
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;
}
);
}
type HistoryFilterModalProps = FilterModalProps<History>;
export default function HistoryFilterModal(props: HistoryFilterModalProps) {
const { records } = useHistory();
const sectionItems = useSelector(createHistorySelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
setHistoryOption('selectedFilterKey', selectedFilterKey);
(payload: { selectedFilterKey: string | number }) => {
dispatch(setHistoryFilter(payload));
},
[]
[dispatch]
);
return (
<FilterModal
{...props}
sectionItems={records}
filterBuilderProps={FILTER_BUILDER}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType="history"
dispatchSetFilter={dispatchSetFilter}
/>

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import IconButton from 'Components/Link/IconButton';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
@@ -12,11 +13,13 @@ 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';
@@ -58,9 +61,13 @@ 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');
@@ -74,6 +81,23 @@ 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;
}
@@ -230,12 +254,13 @@ function HistoryRow(props: HistoryRowProps) {
})}
<HistoryDetailsModal
id={id}
isOpen={isDetailsModalOpen}
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
downloadId={downloadId}
isMarkingAsFailed={isMarkingAsFailed}
onMarkAsFailedPress={handleMarkAsFailedPress}
onModalClose={handleDetailsModalClose}
/>
</TableRow>

View File

@@ -1,109 +0,0 @@
import React from 'react';
import Icon from 'Components/Icon';
import {
createOptionsStore,
PageableOptions,
} from 'Helpers/Hooks/useOptionsStore';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
export type HistoryOptions = PageableOptions;
const { useOptions, useOption, setOptions, setOption } =
createOptionsStore<HistoryOptions>('history_options', () => {
return {
includeUnknownSeriesItems: true,
pageSize: 20,
selectedFilterKey: 'all',
sortKey: 'time',
sortDirection: 'descending',
columns: [
{
name: 'eventType',
label: '',
columnLabel: () => translate('EventType'),
isVisible: true,
isModifiable: false,
},
{
name: 'series.sortTitle',
label: () => translate('Series'),
isSortable: true,
isVisible: true,
},
{
name: 'episode',
label: () => translate('Episode'),
isVisible: true,
},
{
name: 'episodes.title',
label: () => translate('EpisodeTitle'),
isVisible: true,
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: false,
},
{
name: 'quality',
label: () => translate('Quality'),
isVisible: true,
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true,
},
{
name: 'date',
label: () => translate('Date'),
isSortable: true,
isVisible: true,
},
{
name: 'downloadClient',
label: () => translate('DownloadClient'),
isVisible: false,
},
{
name: 'indexer',
label: () => translate('Indexer'),
isVisible: false,
},
{
name: 'releaseGroup',
label: () => translate('ReleaseGroup'),
isVisible: false,
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isVisible: false,
},
{
name: 'customFormatScore',
columnLabel: () => translate('CustomFormatScore'),
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isVisible: false,
},
{
name: 'details',
label: '',
columnLabel: () => translate('Details'),
isVisible: true,
isModifiable: false,
},
],
};
});
export const useHistoryOptions = useOptions;
export const setHistoryOptions = setOptions;
export const useHistoryOption = useOption;
export const setHistoryOption = setOption;

View File

@@ -1,186 +0,0 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
import { filterBuilderValueTypes } from 'Helpers/Props';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import History from 'typings/History';
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
import translate from 'Utilities/String/translate';
import { useHistoryOptions } from './historyOptionsStore';
export const FILTERS: Filter[] = [
{
key: 'all',
label: () => translate('All'),
filters: [],
},
{
key: 'grabbed',
label: () => translate('Grabbed'),
filters: [
{
key: 'eventType',
value: '1',
type: 'equal',
},
],
},
{
key: 'imported',
label: () => translate('Imported'),
filters: [
{
key: 'eventType',
value: '3',
type: 'equal',
},
],
},
{
key: 'failed',
label: () => translate('Failed'),
filters: [
{
key: 'eventType',
value: '4',
type: 'equal',
},
],
},
{
key: 'deleted',
label: () => translate('Deleted'),
filters: [
{
key: 'eventType',
value: '5',
type: 'equal',
},
],
},
{
key: 'renamed',
label: () => translate('Renamed'),
filters: [
{
key: 'eventType',
value: '6',
type: 'equal',
},
],
},
{
key: 'ignored',
label: () => translate('Ignored'),
filters: [
{
key: 'eventType',
value: '7',
type: 'equal',
},
],
},
];
export const FILTER_BUILDER: FilterBuilderProp<History>[] = [
{
name: 'eventType',
label: () => translate('EventType'),
type: 'equal',
valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE,
},
{
name: 'seriesIds',
label: () => translate('Series'),
type: 'equal',
valueType: filterBuilderValueTypes.SERIES,
},
{
name: 'quality',
label: () => translate('Quality'),
type: 'equal',
valueType: filterBuilderValueTypes.QUALITY,
},
{
name: 'languages',
label: () => translate('Languages'),
type: 'contains',
valueType: filterBuilderValueTypes.LANGUAGE,
},
];
const useHistory = () => {
const { page, goToPage } = usePage('history');
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
useHistoryOptions();
const customFilters = useSelector(
createCustomFiltersSelector('history')
) as CustomFilter[];
const filters = useMemo(() => {
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
}, [selectedFilterKey, customFilters]);
const { refetch, ...query } = usePagedApiQuery<History>({
path: '/history',
page,
pageSize,
filters,
sortKey,
sortDirection,
queryOptions: {
placeholderData: keepPreviousData,
},
});
const handleGoToPage = useCallback(
(page: number) => {
goToPage(page);
},
[goToPage]
);
return {
...query,
goToPage: handleGoToPage,
page,
refetch,
};
};
export default useHistory;
export const useFilters = () => {
return FILTERS;
};
export const useMarkAsFailed = (id: number) => {
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const { mutate, isPending } = useApiMutation<unknown, void>({
path: `/history/failed/${id}`,
method: 'POST',
mutationOptions: {
onMutate: () => {
setError(null);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/history'] });
},
onError: () => {
setError('Error marking history item as failed');
},
},
});
return {
markAsFailed: mutate,
isMarkingAsFailed: isPending,
markAsFailedError: error,
};
};

View File

@@ -54,13 +54,17 @@ import useQueue, {
useRemoveQueueItems,
} from './useQueue';
const DEFAULT_DATA = {
records: [],
totalPages: 0,
totalRecords: 0,
};
function Queue() {
const dispatch = useDispatch();
const {
records,
totalPages,
totalRecords,
data,
error,
isFetching,
isFetched,
@@ -70,6 +74,8 @@ function Queue() {
refetch,
} = useQueue();
const { records, totalPages = 0, totalRecords } = data ?? DEFAULT_DATA;
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useQueueOptions();

View File

@@ -6,7 +6,8 @@ import useQueue, { FILTER_BUILDER } from './useQueue';
type QueueFilterModalProps = FilterModalProps<History>;
export default function QueueFilterModal(props: QueueFilterModalProps) {
const { records } = useQueue();
const { data } = useQueue();
const customFilterType = 'queue';
const dispatchSetFilter = useCallback(
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
@@ -18,9 +19,9 @@ export default function QueueFilterModal(props: QueueFilterModalProps) {
return (
<FilterModal
{...props}
sectionItems={records}
sectionItems={data?.records ?? []}
filterBuilderProps={FILTER_BUILDER}
customFilterType="queue"
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>
);

View File

@@ -90,7 +90,7 @@ function QueueStatus(props: QueueStatusProps) {
if (trackedDownloadState === 'importing') {
title += ` - ${translate('Importing')}`;
iconKind = kinds.PRIMARY;
iconKind = kinds.PURPLE;
}
if (trackedDownloadState === 'failedPending') {

View File

@@ -1,10 +1,9 @@
import React from 'react';
import Icon from 'Components/Icon';
import {
createOptionsStore,
PageableOptions,
} from 'Helpers/Hooks/useOptionsStore';
import Column from 'Components/Table/Column';
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
import { icons } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import translate from 'Utilities/String/translate';
interface QueueRemovalOptions {
@@ -12,8 +11,13 @@ interface QueueRemovalOptions {
blocklistMethod: 'blocklistAndSearch' | 'blocklistOnly' | 'doNotBlocklist';
}
export interface QueueOptions extends PageableOptions {
export interface QueueOptions {
includeUnknownSeriesItems: boolean;
pageSize: number;
selectedFilterKey: string | number;
sortKey: string;
sortDirection: SortDirection;
columns: Column[];
removalOptions: QueueRemovalOptions;
}

View File

@@ -1,5 +1,5 @@
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
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';
@@ -90,9 +90,16 @@ const useQueue = () => {
},
});
const handleGoToPage = useCallback(
(page: number) => {
goToPage(page);
},
[goToPage]
);
return {
...query,
goToPage,
goToPage: handleGoToPage,
page,
refetch,
};

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
@@ -9,6 +9,7 @@ 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';
@@ -63,8 +64,9 @@ interface AppUpdatedModalContentProps {
}
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
const dispatch = useDispatch();
const { version, prevVersion } = useSelector((state: AppState) => state.app);
const { isFetched, error, data, refetch } = useUpdates();
const { isFetched, error, data } = useUpdates();
const previousVersion = usePrevious(version);
const { onModalClose } = props;
@@ -75,11 +77,15 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
window.location.href = `${window.Sonarr.urlBase}/system/updates`;
}, []);
useEffect(() => {
dispatch(fetchUpdates());
}, [dispatch]);
useEffect(() => {
if (version !== previousVersion) {
refetch();
dispatch(fetchUpdates());
}
}, [version, previousVersion, refetch]);
}, [version, previousVersion, dispatch]);
return (
<ModalContent onModalClose={onModalClose}>

View File

@@ -90,6 +90,7 @@ interface AppState {
episodeHistory: HistoryAppState;
episodes: EpisodesAppState;
episodesSelection: EpisodesAppState;
history: HistoryAppState;
importSeries: ImportSeriesAppState;
interactiveImport: InteractiveImportAppState;
oAuth: OAuthAppState;

View File

@@ -17,7 +17,6 @@ export interface TagDetail extends ModelBase {
indexerIds: number[];
notificationIds: number[];
restrictionIds: number[];
excludedReleaseProfileIds: number[];
seriesIds: number[];
}

View File

@@ -1,4 +1,5 @@
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,7 +15,6 @@ 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,8 +58,9 @@ function AgendaEvent(props: AgendaEventProps) {
const series = useSeries(seriesId)!;
const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useQueueItemForEpisode(id);
const { timeFormat, longDateFormat, enableColorImpairedMode, timeZone } =
useSelector(createUISettingsSelector());
const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector()
);
const {
showEpisodeInformation,
@@ -70,11 +71,8 @@ function AgendaEvent(props: AgendaEventProps) {
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const startTime = convertToTimezone(airDateUtc, timeZone);
const endTime = convertToTimezone(airDateUtc, timeZone).add(
series.runtime,
'minutes'
);
const startTime = moment(airDateUtc);
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
const downloading = !!(queueItem || grabbed);
const isMonitored = series.monitored && monitored;
const statusStyle = getStatusStyle(
@@ -112,10 +110,9 @@ function AgendaEvent(props: AgendaEventProps) {
)}
>
<div className={styles.time}>
{formatTime(airDateUtc, timeFormat, { timeZone })} -{' '}
{formatTime(airDateUtc, timeFormat)} -{' '}
{formatTime(endTime.toISOString(), timeFormat, {
includeMinuteZero: true,
timeZone,
})}
</div>

View File

@@ -1,4 +1,5 @@
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';
@@ -13,7 +14,6 @@ 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, timeZone } = useSelector(
const { timeFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector()
);
@@ -88,11 +88,8 @@ function CalendarEvent(props: CalendarEventProps) {
return null;
}
const startTime = convertToTimezone(airDateUtc, timeZone);
const endTime = convertToTimezone(airDateUtc, timeZone).add(
series.runtime,
'minutes'
);
const startTime = moment(airDateUtc);
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
const isDownloading = !!(queueItem || grabbed);
const isMonitored = series.monitored && monitored;
const statusStyle = getStatusStyle(
@@ -220,10 +217,9 @@ function CalendarEvent(props: CalendarEventProps) {
) : null}
<div className={styles.airTime}>
{formatTime(airDateUtc, timeFormat, { timeZone })} -{' '}
{formatTime(airDateUtc, timeFormat)} -{' '}
{formatTime(endTime.toISOString(), timeFormat, {
includeMinuteZero: true,
timeZone,
})}
</div>
</div>

View File

@@ -1,4 +1,5 @@
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';
@@ -11,7 +12,6 @@ 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, timeZone } = useSelector(
const { timeFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector()
);
@@ -46,11 +46,8 @@ function CalendarEventGroup({
const firstEpisode = events[0];
const lastEpisode = events[events.length - 1];
const airDateUtc = firstEpisode.airDateUtc;
const startTime = convertToTimezone(airDateUtc, timeZone);
const endTime = convertToTimezone(lastEpisode.airDateUtc, timeZone).add(
series.runtime,
'minutes'
);
const startTime = moment(airDateUtc);
const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
const seasonNumber = firstEpisode.seasonNumber;
const { allDownloaded, anyGrabbed, anyMonitored, allAbsoluteEpisodeNumbers } =
@@ -197,10 +194,9 @@ function CalendarEventGroup({
<div className={styles.airingInfo}>
<div className={styles.airTime}>
{formatTime(airDateUtc, timeFormat, { timeZone })} -{' '}
{formatTime(airDateUtc, timeFormat)} -{' '}
{formatTime(endTime.toISOString(), timeFormat, {
includeMinuteZero: true,
timeZone,
})}
</div>

View File

@@ -1,19 +0,0 @@
import React from 'react';
import NumberInput, { NumberInputChanged } from './NumberInput';
export interface FloatInputProps {
name: string;
value?: number | null;
min?: number;
max?: number;
step?: number;
placeholder?: string;
className?: string;
onChange: (change: NumberInputChanged) => void;
}
function FloatInput(props: FloatInputProps) {
return <NumberInput {...props} isFloat={true} />;
}
export default FloatInput;

View File

@@ -7,7 +7,6 @@ import translate from 'Utilities/String/translate';
import AutoCompleteInput, { AutoCompleteInputProps } from './AutoCompleteInput';
import CaptchaInput, { CaptchaInputProps } from './CaptchaInput';
import CheckInput, { CheckInputProps } from './CheckInput';
import FloatInput, { FloatInputProps } from './FloatInput';
import { FormInputButtonProps } from './FormInputButton';
import FormInputHelpText from './FormInputHelpText';
import KeyValueListInput, { KeyValueListInputProps } from './KeyValueListInput';
@@ -66,7 +65,7 @@ const componentMap: Record<InputType, ElementType> = {
downloadClientSelect: DownloadClientSelectInput,
dynamicSelect: ProviderDataSelectInput,
file: TextInput,
float: FloatInput,
float: NumberInput,
indexerFlagsSelect: IndexerFlagsSelectInput,
indexerSelect: IndexerSelectInput,
keyValueList: KeyValueListInput,
@@ -111,7 +110,7 @@ type PickProps<V, C extends InputType> = C extends 'text'
: C extends 'file'
? TextInputProps
: C extends 'float'
? FloatInputProps
? TextInputProps
: C extends 'indexerFlagsSelect'
? IndexerFlagsSelectInputProps
: C extends 'indexerSelect'

View File

@@ -24,17 +24,13 @@ function parseValue(
return newValue;
}
export interface NumberInputChanged extends InputChanged<number | null> {
isFloat?: boolean;
}
export interface NumberInputProps
extends Omit<TextInputProps, 'value' | 'onChange'> {
value?: number | null;
min?: number;
max?: number;
isFloat?: boolean;
onChange: (change: NumberInputChanged) => void;
onChange: (input: InputChanged<number | null>) => void;
}
function NumberInput({
@@ -54,14 +50,11 @@ function NumberInput({
const handleChange = useCallback(
({ name, value: newValue }: InputChanged<string>) => {
const parsedValue = parseValue(newValue, isFloat, min, max);
setValue(parsedValue == null ? '' : parsedValue.toString());
setValue(newValue);
onChange({
name,
value: parsedValue,
isFloat,
value: parseValue(newValue, isFloat, min, max),
});
},
[isFloat, min, max, onChange, setValue]
@@ -82,7 +75,6 @@ function NumberInput({
onChange({
name,
value: parsedValue,
isFloat,
});
isFocused.current = false;

View File

@@ -5,18 +5,14 @@ import { addTag } from 'Store/Actions/tagActions';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { InputChanged } from 'typings/inputs';
import sortByProp from 'Utilities/Array/sortByProp';
import TagInput, { TagBase, TagInputProps } from './TagInput';
import TagInput, { TagBase } from './TagInput';
interface SeriesTag extends TagBase {
id: number;
name: string;
}
export interface SeriesTagInputProps<V>
extends Omit<
TagInputProps<SeriesTag>,
'tags' | 'tagList' | 'onTagAdd' | 'onTagDelete' | 'onChange'
> {
export interface SeriesTagInputProps<V> {
name: string;
value: V;
onChange: (change: InputChanged<V>) => void;
@@ -67,7 +63,6 @@ export default function SeriesTagInput<V extends number | number[]>({
name,
value,
onChange,
...otherProps
}: SeriesTagInputProps<V>) {
const dispatch = useDispatch();
const isArray = Array.isArray(value);
@@ -140,7 +135,6 @@ export default function SeriesTagInput<V extends number | number[]>({
return (
<TagInput
{...otherProps}
name={name}
tags={tags}
tagList={tagList}

View File

@@ -26,10 +26,6 @@
color: var(--warningColor);
}
.primary {
color: var(--primaryColor);
}
.purple {
color: var(--purple);
}

View File

@@ -6,7 +6,6 @@ interface CssExports {
'disabled': string;
'info': string;
'pink': string;
'primary': string;
'purple': string;
'success': string;
'warning': string;

View File

@@ -19,7 +19,6 @@
.modal {
position: relative;
display: flex;
max-width: 90%;
max-height: 90%;
border-radius: 6px;
opacity: 1;

View File

@@ -1,4 +1,5 @@
.header {
z-index: 3;
display: flex;
align-items: center;
flex: 0 0 auto;

View File

@@ -7,40 +7,6 @@
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;

View File

@@ -1,13 +1,8 @@
// 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;

View File

@@ -1,3 +1,4 @@
import classNames from 'classnames';
import React, {
useCallback,
useEffect,
@@ -10,8 +11,6 @@ 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';
@@ -231,6 +230,10 @@ 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
@@ -296,6 +299,22 @@ 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;
@@ -377,13 +396,10 @@ 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);
@@ -392,6 +408,7 @@ 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);
@@ -400,6 +417,7 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
}, [
isSmallScreen,
handleWindowClick,
handleWindowScroll,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
@@ -438,37 +456,13 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
return (
<div
ref={sidebarRef}
className={styles.sidebarContainer}
className={classNames(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={{
height: `${window.innerHeight - HEADER_HEIGHT}px`,
}}
style={sidebarStyle}
>
<div>
{LINKS.map((link) => {

View File

@@ -1,22 +1,16 @@
import React from 'react';
import { Tag } from 'App/State/TagsAppState';
import { kinds } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import sortByProp from 'Utilities/Array/sortByProp';
import Label, { LabelProps } from './Label';
import Label from './Label';
import styles from './TagList.css';
interface TagListProps {
tags: number[];
tagList: Tag[];
kind?: Extract<Kind, LabelProps['kind']>;
}
export default function TagList({
tags,
tagList,
kind = kinds.INFO,
}: TagListProps) {
function TagList({ tags, tagList }: TagListProps) {
const sortedTags = tags
.map((tagId) => tagList.find((tag) => tag.id === tagId))
.filter((tag) => !!tag)
@@ -26,7 +20,7 @@ export default function TagList({
<div className={styles.tags}>
{sortedTags.map((tag) => {
return (
<Label key={tag.id} kind={kind}>
<Label key={tag.id} kind={kinds.INFO}>
{tag.label}
</Label>
);
@@ -34,3 +28,5 @@ export default function TagList({
</div>
);
}
export default TagList;

View File

@@ -54,7 +54,7 @@ export default function AuthenticationRequiredModalContent() {
dispatch(fetchGeneralSettings());
return () => {
dispatch(clearPendingChanges({ section: `settings.${SECTION}` }));
dispatch(clearPendingChanges());
};
}, [dispatch]);

View File

@@ -20,7 +20,6 @@ function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
headers: {
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
'X-Sonarr-Client': 'Sonarr',
},
};
}, [options]);

View File

@@ -26,7 +26,6 @@ const useApiQuery = <T>(options: QueryOptions<T>) => {
headers: {
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
'X-Sonarr-Client': 'Sonarr',
},
},
};

View File

@@ -2,7 +2,6 @@ import { StateCreator } from 'zustand';
import { PersistOptions } from 'zustand/middleware';
import Column from 'Components/Table/Column';
import { createPersist } from 'Helpers/createPersist';
import { SortDirection } from 'Helpers/Props/sortDirections';
type TSettingsWithoutColumns = object;
@@ -12,14 +11,6 @@ interface TSettingsWithColumns {
type TSettingd = TSettingsWithoutColumns | TSettingsWithColumns;
export interface PageableOptions {
pageSize: number;
selectedFilterKey: string | number;
sortKey: string;
sortDirection: SortDirection;
columns: Column[];
}
export type OptionChanged<T> = {
name: keyof T;
value: T[keyof T];

View File

@@ -3,16 +3,12 @@ import { useHistory } from 'react-router';
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,
}));

View File

@@ -25,8 +25,6 @@ interface PagedQueryResponse<T> {
records: T[];
}
const DEFAULT_RECORDS: never[] = [];
const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
const { requestOptions, queryKey } = useMemo(() => {
const {
@@ -66,13 +64,12 @@ const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
headers: {
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
'X-Sonarr-Client': 'Sonarr',
},
},
};
}, [options]);
const { data, ...query } = useQuery({
return useQuery({
...options.queryOptions,
queryKey,
queryFn: async ({ signal }) => {
@@ -90,13 +87,6 @@ const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
};
},
});
return {
...query,
records: data?.records ?? DEFAULT_RECORDS,
totalRecords: data?.totalRecords ?? 0,
totalPages: data?.totalPages ?? 0,
};
};
export default usePagedApiQuery;

View File

@@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
@@ -7,42 +6,14 @@ import themes from 'Styles/Themes';
function createThemeSelector() {
return createSelector(
(state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
(theme) => theme
(theme) => {
return theme;
}
);
}
const useTheme = () => {
const selectedTheme = useSelector(createThemeSelector());
const [resolvedTheme, setResolvedTheme] = useState(selectedTheme);
useEffect(() => {
if (selectedTheme !== 'auto') {
setResolvedTheme(selectedTheme);
return;
}
const applySystemTheme = () => {
setResolvedTheme(
window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
);
};
applySystemTheme();
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', applySystemTheme);
return () => {
window
.matchMedia('(prefers-color-scheme: dark)')
.removeEventListener('change', applySystemTheme);
};
}, [selectedTheme]);
return resolvedTheme;
return useSelector(createThemeSelector());
};
export default useTheme;

View File

@@ -116,7 +116,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
onGrabPress,
} = props;
const { longDateFormat, timeFormat, timeZone } = useSelector(
const { longDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
@@ -174,7 +174,6 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
className={styles.age}
title={formatDateTime(publishDate, longDateFormat, timeFormat, {
includeSeconds: true,
timeZone,
})}
>
{formatAge(age, ageHours, ageMinutes)}

View File

@@ -57,12 +57,11 @@
.title {
overflow: auto;
max-height: calc(3 * 60px);
max-height: calc(3 * 50px);
text-wrap: balance;
font-weight: 300;
font-size: 50px;
line-height: 60px;
-webkit-line-clamp: 3;
line-height: 50px;
line-clamp: 3;
}
@@ -83,7 +82,6 @@
.alternateTitlesIconContainer {
align-self: flex-end;
margin-bottom: 10px;
margin-left: 20px;
}

View File

@@ -144,10 +144,7 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
<div className={styles.poster}>
<div className={styles.posterContainer}>
{isSelectMode ? (
<SeriesIndexPosterSelect
seriesId={seriesId}
titleSlug={titleSlug}
/>
<SeriesIndexPosterSelect seriesId={seriesId} />
) : null}
{status === 'ended' ? (

View File

@@ -1,6 +1,7 @@
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
import React, { SyntheticEvent, useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelect } from 'App/SelectContext';
import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
@@ -122,8 +123,31 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
setIsDeleteSeriesModalOpen(false);
}, [setIsDeleteSeriesModalOpen]);
const [selectState, selectDispatch] = useSelect();
const onSelectPress = useCallback(
(event: SyntheticEvent<HTMLElement, MouseEvent>) => {
if (event.nativeEvent.ctrlKey || event.nativeEvent.metaKey) {
window.open(`/series/${titleSlug}`, '_blank');
return;
}
const shiftKey = event.nativeEvent.shiftKey;
selectDispatch({
type: 'toggleSelected',
id: seriesId,
isSelected: !selectState.selectedState[seriesId],
shiftKey,
});
},
[seriesId, selectState.selectedState, selectDispatch, titleSlug]
);
const link = `/series/${titleSlug}`;
const linkProps = isSelectMode ? { onPress: onSelectPress } : { to: link };
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`,
@@ -132,9 +156,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
return (
<div className={styles.content}>
<div className={styles.posterContainer} title={title}>
{isSelectMode ? (
<SeriesIndexPosterSelect seriesId={seriesId} titleSlug={titleSlug} />
) : null}
{isSelectMode ? <SeriesIndexPosterSelect seriesId={seriesId} /> : null}
<Label className={styles.controls}>
<SpinnerIconButton
@@ -177,7 +199,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
/>
) : null}
<Link className={styles.link} style={elementStyle} to={link}>
<Link className={styles.link} style={elementStyle} {...linkProps}>
<SeriesPoster
style={elementStyle}
images={images}

View File

@@ -3,8 +3,8 @@
top: 0;
left: 0;
z-index: 3;
width: 100%;
height: 100%;
width: 36px;
height: 36px;
}
.checkContainer {

View File

@@ -7,23 +7,15 @@ import styles from './SeriesIndexPosterSelect.css';
interface SeriesIndexPosterSelectProps {
seriesId: number;
titleSlug: string;
}
function SeriesIndexPosterSelect({
seriesId,
titleSlug,
}: SeriesIndexPosterSelectProps) {
function SeriesIndexPosterSelect(props: SeriesIndexPosterSelectProps) {
const { seriesId } = props;
const [selectState, selectDispatch] = useSelect();
const isSelected = selectState.selectedState[seriesId];
const onSelectPress = useCallback(
(event: SyntheticEvent<HTMLElement, PointerEvent>) => {
if (event.nativeEvent.ctrlKey || event.nativeEvent.metaKey) {
window.open(`${window.Sonarr.urlBase}/series/${titleSlug}`, '_blank');
return;
}
const shiftKey = event.nativeEvent.shiftKey;
selectDispatch({
@@ -33,7 +25,7 @@ function SeriesIndexPosterSelect({
shiftKey,
});
},
[seriesId, titleSlug, isSelected, selectDispatch]
[seriesId, isSelected, selectDispatch]
);
return (

View File

@@ -156,7 +156,6 @@ function GeneralSettings() {
enableSsl={settings.enableSsl}
sslPort={settings.sslPort}
sslCertPath={settings.sslCertPath}
sslKeyPath={settings.sslKeyPath}
sslCertPassword={settings.sslCertPassword}
launchBrowser={settings.launchBrowser}
onInputChange={handleInputChange}

View File

@@ -19,7 +19,6 @@ interface HostSettingsProps {
applicationUrl: PendingSection<General>['applicationUrl'];
enableSsl: PendingSection<General>['enableSsl'];
sslPort: PendingSection<General>['sslPort'];
sslKeyPath: PendingSection<General>['sslKeyPath'];
sslCertPath: PendingSection<General>['sslCertPath'];
sslCertPassword: PendingSection<General>['sslCertPassword'];
launchBrowser: PendingSection<General>['launchBrowser'];
@@ -35,7 +34,6 @@ function HostSettings({
enableSsl,
sslPort,
sslCertPath,
sslKeyPath,
sslCertPassword,
launchBrowser,
onInputChange,
@@ -144,46 +142,33 @@ function HostSettings({
) : null}
{enableSsl.value ? (
<>
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
<FormLabel>{translate('SslCertPath')}</FormLabel>
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
<FormLabel>{translate('SslCertPath')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="sslCertPath"
helpText={translate('SslCertPathHelpText')}
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
onChange={onInputChange}
{...sslCertPath}
/>
</FormGroup>
<FormInputGroup
type={inputTypes.TEXT}
name="sslCertPath"
helpText={translate('SslCertPathHelpText')}
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
onChange={onInputChange}
{...sslCertPath}
/>
</FormGroup>
) : null}
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
<FormLabel>{translate('SslKeyPath')}</FormLabel>
{enableSsl.value ? (
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
<FormLabel>{translate('SslCertPassword')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="sslKeyPath"
helpText={translate('SslKeyPathHelpText')}
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
onChange={onInputChange}
{...sslKeyPath}
/>
</FormGroup>
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
<FormLabel>{translate('SslCertPassword')}</FormLabel>
<FormInputGroup
type={inputTypes.PASSWORD}
name="sslCertPassword"
helpText={translate('SslCertPasswordHelpText')}
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
onChange={onInputChange}
{...sslCertPassword}
/>
</FormGroup>
</>
<FormInputGroup
type={inputTypes.PASSWORD}
name="sslCertPassword"
helpText={translate('SslCertPasswordHelpText')}
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
onChange={onInputChange}
{...sslCertPassword}
/>
</FormGroup>
) : null}
{isWindowsService ? null : (

View File

@@ -116,27 +116,6 @@ const fileDateOptions: EnhancedSelectInputValue<string>[] = [
},
];
const seasonPackUpgradeOptions: EnhancedSelectInputValue<string>[] = [
{
key: 'all',
get value() {
return translate('All');
},
},
{
key: 'threshold',
get value() {
return translate('Threshold');
},
},
{
key: 'any',
get value() {
return translate('Any');
},
},
];
function MediaManagement() {
const dispatch = useDispatch();
const showAdvancedSettings = useShowAdvancedSettings();
@@ -400,82 +379,6 @@ function MediaManagement() {
{...settings.userRejectedExtensions}
/>
</FormGroup>
{showAdvancedSettings && (
<>
<FormGroup
advancedSettings={showAdvancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>
{translate('SeasonPackUpgradeAllowLabel')}
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="seasonPackUpgrade"
helpText={translate('SeasonPackUpgradeAllowHelpText')}
helpTextWarning={
settings.seasonPackUpgrade.value === 'any'
? translate('SeasonPackUpgradeAllowAnyWarning')
: undefined
}
values={seasonPackUpgradeOptions}
onChange={handleInputChange}
{...settings.seasonPackUpgrade}
/>
</FormGroup>
{settings.seasonPackUpgrade.value === 'threshold' && (
<FormGroup
advancedSettings={showAdvancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>
{translate('SeasonPackUpgradeThresholdLabel')}
</FormLabel>
<FormInputGroup
type={inputTypes.FLOAT}
name="seasonPackUpgradeThreshold"
unit="%"
step={0.01}
min={0}
max={100}
helpTexts={[
translate('SeasonPackUpgradeThresholdHelpText'),
translate(
'SeasonPackUpgradeThresholdHelpTextExample',
{
numberEpisodes: 2,
totalEpisodes: 8,
count: Math.ceil((100 * 2) / 8),
}
),
translate(
'SeasonPackUpgradeThresholdHelpTextExample',
{
numberEpisodes: 3,
totalEpisodes: 12,
count: Math.ceil((100 * 3) / 12),
}
),
translate(
'SeasonPackUpgradeThresholdHelpTextExample',
{
numberEpisodes: 6,
totalEpisodes: 24,
count: Math.ceil((100 * 6) / 24),
}
),
]}
onChange={handleInputChange}
{...settings.seasonPackUpgradeThreshold}
/>
</FormGroup>
)}
</>
)}
</FieldSet>
) : null}

View File

@@ -33,7 +33,6 @@ const newReleaseProfile: ReleaseProfile = {
required: [],
ignored: [],
tags: [],
excludedTags: [],
indexerId: 0,
};
@@ -77,8 +76,7 @@ function EditReleaseProfileModalContent({
const { item, isFetching, isSaving, error, saveError, ...otherProps } =
useSelector(createReleaseProfileSelector(id));
const { name, enabled, required, ignored, tags, excludedTags, indexerId } =
item;
const { name, enabled, required, ignored, tags, indexerId } = item;
const dispatch = useDispatch();
const previousIsSaving = usePrevious(isSaving);
@@ -204,19 +202,6 @@ function EditReleaseProfileModalContent({
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ExcludedTags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="excludedTags"
helpText={translate('ReleaseProfileExcludedTagSeriesHelpText')}
kind={kinds.DANGER}
{...excludedTags}
onChange={handleInputChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>

View File

@@ -28,7 +28,6 @@ function ReleaseProfileItem(props: ReleaseProfileProps) {
required = [],
ignored = [],
tags,
excludedTags,
indexerId = 0,
tagList,
indexerList,
@@ -93,8 +92,6 @@ function ReleaseProfileItem(props: ReleaseProfileProps) {
<TagList tags={tags} tagList={tagList} />
<TagList tags={excludedTags} tagList={tagList} kind={kinds.DANGER} />
<div>
{enabled ? null : (
<Label kind={kinds.DISABLED} outline={true}>

View File

@@ -61,7 +61,7 @@ export interface TagDetailsModalContentProps {
delayProfileIds: number[];
importListIds: number[];
notificationIds: number[];
releaseProfileIds: number[];
restrictionIds: number[];
indexerIds: number[];
downloadClientIds: number[];
autoTagIds: number[];
@@ -76,7 +76,7 @@ function TagDetailsModalContent({
delayProfileIds = [],
importListIds = [],
notificationIds = [],
releaseProfileIds = [],
restrictionIds = [],
indexerIds = [],
downloadClientIds = [],
autoTagIds = [],
@@ -109,7 +109,7 @@ function TagDetailsModalContent({
const releaseProfiles = useSelector(
createMatchingItemSelector(
releaseProfileIds,
restrictionIds,
(state: AppState) => state.settings.releaseProfiles.items
)
);

View File

@@ -22,7 +22,6 @@ function Tag({ id, label }: TagProps) {
importListIds = [],
notificationIds = [],
restrictionIds = [],
excludedReleaseProfileIds = [],
indexerIds = [],
downloadClientIds = [],
autoTagIds = [],
@@ -36,17 +35,12 @@ function Tag({ id, label }: TagProps) {
importListIds.length ||
notificationIds.length ||
restrictionIds.length ||
excludedReleaseProfileIds.length ||
indexerIds.length ||
downloadClientIds.length ||
autoTagIds.length ||
seriesIds.length
);
const mergedReleaseProfileIds = Array.from(
new Set([...restrictionIds, ...excludedReleaseProfileIds]).values()
);
const handleShowDetailsPress = useCallback(() => {
setIsDetailsModalOpen(true);
}, []);
@@ -101,7 +95,7 @@ function Tag({ id, label }: TagProps) {
<TagInUse
label={translate('ReleaseProfile')}
labelPlural={translate('ReleaseProfiles')}
count={mergedReleaseProfileIds.length}
count={restrictionIds.length}
/>
<TagInUse
@@ -132,7 +126,7 @@ function Tag({ id, label }: TagProps) {
delayProfileIds={delayProfileIds}
importListIds={importListIds}
notificationIds={notificationIds}
releaseProfileIds={mergedReleaseProfileIds}
restrictionIds={restrictionIds}
indexerIds={indexerIds}
downloadClientIds={downloadClientIds}
autoTagIds={autoTagIds}

View File

@@ -21,7 +21,6 @@ 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';
@@ -219,18 +218,6 @@ 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

View File

@@ -5,7 +5,7 @@ import updateSectionState from 'Utilities/State/updateSectionState';
function createSetSettingValueReducer(section) {
return (state, { payload }) => {
if (section === payload.section) {
const { name, value, isFloat } = payload;
const { name, value } = payload;
const newState = getSectionState(state, section);
newState.pendingChanges = Object.assign({}, newState.pendingChanges);
@@ -15,12 +15,7 @@ function createSetSettingValueReducer(section) {
let parsedValue = null;
if (_.isNumber(currentValue) && value != null) {
// Use isFloat property to determine parsing method
if (isFloat) {
parsedValue = parseFloat(value);
} else {
parsedValue = parseInt(value);
}
parsedValue = parseInt(value);
} else {
parsedValue = value;
}

View File

@@ -0,0 +1,221 @@
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import { filterBuilderTypes, filterBuilderValueTypes, 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 { set, updateItem } from './baseActions';
import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
import createClearReducer from './Creators/Reducers/createClearReducer';
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
//
// Variables
export const section = 'blocklist';
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
pageSize: 20,
sortKey: 'date',
sortDirection: sortDirections.DESCENDING,
error: null,
items: [],
isRemoving: false,
columns: [
{
name: 'series.sortTitle',
label: () => translate('SeriesTitle'),
isSortable: true,
isVisible: true
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isSortable: true,
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: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: false
},
{
name: 'actions',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
],
selectedFilterKey: 'all',
filters: [
{
key: 'all',
label: () => translate('All'),
filters: []
}
],
filterBuilderProps: [
{
name: 'seriesIds',
label: () => translate('Series'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.SERIES
},
{
name: 'protocols',
label: () => translate('Protocol'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.PROTOCOL
}
]
};
export const persistState = [
'blocklist.pageSize',
'blocklist.sortKey',
'blocklist.sortDirection',
'blocklist.selectedFilterKey',
'blocklist.columns'
];
//
// Action Types
export const FETCH_BLOCKLIST = 'blocklist/fetchBlocklist';
export const GOTO_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistPage';
export const SET_BLOCKLIST_SORT = 'blocklist/setBlocklistSort';
export const SET_BLOCKLIST_FILTER = 'blocklist/setBlocklistFilter';
export const SET_BLOCKLIST_TABLE_OPTION = 'blocklist/setBlocklistTableOption';
export const REMOVE_BLOCKLIST_ITEM = 'blocklist/removeBlocklistItem';
export const REMOVE_BLOCKLIST_ITEMS = 'blocklist/removeBlocklistItems';
export const CLEAR_BLOCKLIST = 'blocklist/clearBlocklist';
//
// Action Creators
export const fetchBlocklist = createThunk(FETCH_BLOCKLIST);
export const gotoBlocklistPage = createThunk(GOTO_BLOCKLIST_PAGE);
export const setBlocklistSort = createThunk(SET_BLOCKLIST_SORT);
export const setBlocklistFilter = createThunk(SET_BLOCKLIST_FILTER);
export const setBlocklistTableOption = createAction(SET_BLOCKLIST_TABLE_OPTION);
export const removeBlocklistItem = createThunk(REMOVE_BLOCKLIST_ITEM);
export const removeBlocklistItems = createThunk(REMOVE_BLOCKLIST_ITEMS);
export const clearBlocklist = createAction(CLEAR_BLOCKLIST);
//
// Action Handlers
export const actionHandlers = handleThunks({
...createServerSideCollectionHandlers(
section,
'/blocklist',
fetchBlocklist,
{
[serverSideCollectionHandlers.FETCH]: FETCH_BLOCKLIST,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_BLOCKLIST_PAGE,
[serverSideCollectionHandlers.SORT]: SET_BLOCKLIST_SORT,
[serverSideCollectionHandlers.FILTER]: SET_BLOCKLIST_FILTER
}),
[REMOVE_BLOCKLIST_ITEM]: createRemoveItemHandler(section, '/blocklist'),
[REMOVE_BLOCKLIST_ITEMS]: function(getState, payload, dispatch) {
const {
ids
} = payload;
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section,
id,
isRemoving: true
});
}),
set({ section, isRemoving: true })
]));
const promise = createAjaxRequest({
url: '/blocklist/bulk',
method: 'DELETE',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify({ ids })
}).request;
promise.done((data) => {
// Don't use batchActions with thunks
dispatch(fetchBlocklist());
dispatch(set({ section, isRemoving: false }));
});
promise.fail((xhr) => {
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section,
id,
isRemoving: false
});
}),
set({ section, isRemoving: false })
]));
});
}
});
//
// Reducers
export const reducers = createHandleActions({
[SET_BLOCKLIST_TABLE_OPTION]: createSetTableOptionReducer(section),
[CLEAR_BLOCKLIST]: createClearReducer(section, {
isFetching: false,
isPopulated: false,
error: null,
items: [],
totalPages: 0,
totalRecords: 0
})
}, defaultState, section);

View File

@@ -0,0 +1,327 @@
import React from 'react';
import { createAction } from 'redux-actions';
import Icon from 'Components/Icon';
import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, icons, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/State/serverSideCollectionHandlers';
import translate from 'Utilities/String/translate';
import { updateItem } from './baseActions';
import createHandleActions from './Creators/createHandleActions';
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
import createClearReducer from './Creators/Reducers/createClearReducer';
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
//
// Variables
export const section = 'history';
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
pageSize: 20,
sortKey: 'date',
sortDirection: sortDirections.DESCENDING,
items: [],
columns: [
{
name: 'eventType',
columnLabel: () => translate('EventType'),
isVisible: true,
isModifiable: false
},
{
name: 'series.sortTitle',
label: () => translate('Series'),
isSortable: true,
isVisible: true
},
{
name: 'episode',
label: () => translate('Episode'),
isVisible: true
},
{
name: 'episodes.title',
label: () => translate('EpisodeTitle'),
isVisible: true
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: false
},
{
name: 'quality',
label: () => translate('Quality'),
isVisible: true
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true
},
{
name: 'date',
label: () => translate('Date'),
isSortable: true,
isVisible: true
},
{
name: 'downloadClient',
label: () => translate('DownloadClient'),
isVisible: false
},
{
name: 'indexer',
label: () => translate('Indexer'),
isVisible: false
},
{
name: 'releaseGroup',
label: () => translate('ReleaseGroup'),
isVisible: false
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isVisible: false
},
{
name: 'customFormatScore',
columnLabel: () => translate('CustomFormatScore'),
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
}),
isVisible: false
},
{
name: 'details',
columnLabel: () => translate('Details'),
isVisible: true,
isModifiable: false
}
],
selectedFilterKey: 'all',
filters: [
{
key: 'all',
label: () => translate('All'),
filters: []
},
{
key: 'grabbed',
label: () => translate('Grabbed'),
filters: [
{
key: 'eventType',
value: '1',
type: filterTypes.EQUAL
}
]
},
{
key: 'imported',
label: () => translate('Imported'),
filters: [
{
key: 'eventType',
value: '3',
type: filterTypes.EQUAL
}
]
},
{
key: 'failed',
label: () => translate('Failed'),
filters: [
{
key: 'eventType',
value: '4',
type: filterTypes.EQUAL
}
]
},
{
key: 'deleted',
label: () => translate('Deleted'),
filters: [
{
key: 'eventType',
value: '5',
type: filterTypes.EQUAL
}
]
},
{
key: 'renamed',
label: () => translate('Renamed'),
filters: [
{
key: 'eventType',
value: '6',
type: filterTypes.EQUAL
}
]
},
{
key: 'ignored',
label: () => translate('Ignored'),
filters: [
{
key: 'eventType',
value: '7',
type: filterTypes.EQUAL
}
]
}
],
filterBuilderProps: [
{
name: 'eventType',
label: () => translate('EventType'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE
},
{
name: 'seriesIds',
label: () => translate('Series'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.SERIES
},
{
name: 'quality',
label: () => translate('Quality'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.QUALITY
},
{
name: 'languages',
label: () => translate('Languages'),
type: filterBuilderTypes.CONTAINS,
valueType: filterBuilderValueTypes.LANGUAGE
}
]
};
export const persistState = [
'history.pageSize',
'history.sortKey',
'history.sortDirection',
'history.selectedFilterKey',
'history.columns'
];
//
// Actions Types
export const FETCH_HISTORY = 'history/fetchHistory';
export const GOTO_FIRST_HISTORY_PAGE = 'history/gotoHistoryFirstPage';
export const GOTO_PREVIOUS_HISTORY_PAGE = 'history/gotoHistoryPreviousPage';
export const GOTO_NEXT_HISTORY_PAGE = 'history/gotoHistoryNextPage';
export const GOTO_LAST_HISTORY_PAGE = 'history/gotoHistoryLastPage';
export const GOTO_HISTORY_PAGE = 'history/gotoHistoryPage';
export const SET_HISTORY_SORT = 'history/setHistorySort';
export const SET_HISTORY_FILTER = 'history/setHistoryFilter';
export const SET_HISTORY_TABLE_OPTION = 'history/setHistoryTableOption';
export const CLEAR_HISTORY = 'history/clearHistory';
export const MARK_AS_FAILED = 'history/markAsFailed';
//
// Action Creators
export const fetchHistory = createThunk(FETCH_HISTORY);
export const gotoHistoryFirstPage = createThunk(GOTO_FIRST_HISTORY_PAGE);
export const gotoHistoryPreviousPage = createThunk(GOTO_PREVIOUS_HISTORY_PAGE);
export const gotoHistoryNextPage = createThunk(GOTO_NEXT_HISTORY_PAGE);
export const gotoHistoryLastPage = createThunk(GOTO_LAST_HISTORY_PAGE);
export const gotoHistoryPage = createThunk(GOTO_HISTORY_PAGE);
export const setHistorySort = createThunk(SET_HISTORY_SORT);
export const setHistoryFilter = createThunk(SET_HISTORY_FILTER);
export const setHistoryTableOption = createAction(SET_HISTORY_TABLE_OPTION);
export const clearHistory = createAction(CLEAR_HISTORY);
export const markAsFailed = createThunk(MARK_AS_FAILED);
//
// Action Handlers
export const actionHandlers = handleThunks({
...createServerSideCollectionHandlers(
section,
'/history',
fetchHistory,
{
[serverSideCollectionHandlers.FETCH]: FETCH_HISTORY,
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_HISTORY_PAGE,
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_HISTORY_PAGE,
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_HISTORY_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_HISTORY_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_HISTORY_PAGE,
[serverSideCollectionHandlers.SORT]: SET_HISTORY_SORT,
[serverSideCollectionHandlers.FILTER]: SET_HISTORY_FILTER
}),
[MARK_AS_FAILED]: function(getState, payload, dispatch) {
const id = payload.id;
dispatch(updateItem({
section,
id,
isMarkingAsFailed: true
}));
const promise = createAjaxRequest({
url: `/history/failed/${id}`,
method: 'POST',
dataType: 'json'
}).request;
promise.done(() => {
dispatch(updateItem({
section,
id,
isMarkingAsFailed: false,
markAsFailedError: null
}));
});
promise.fail((xhr) => {
dispatch(updateItem({
section,
id,
isMarkingAsFailed: false,
markAsFailedError: xhr
}));
});
}
});
//
// Reducers
export const reducers = createHandleActions({
[SET_HISTORY_TABLE_OPTION]: createSetTableOptionReducer(section),
[CLEAR_HISTORY]: createClearReducer(section, {
isFetching: false,
isPopulated: false,
error: null,
items: [],
totalPages: 0,
totalRecords: 0
})
}, defaultState, section);

View File

@@ -1,4 +1,5 @@
import * as app from './appActions';
import * as blocklist from './blocklistActions';
import * as calendar from './calendarActions';
import * as captcha from './captchaActions';
import * as commands from './commandActions';
@@ -7,6 +8,7 @@ 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';
@@ -26,6 +28,7 @@ import * as wanted from './wantedActions';
export default [
app,
blocklist,
calendar,
captcha,
commands,
@@ -34,6 +37,7 @@ export default [
episodeFiles,
episodeHistory,
episodeSelection,
history,
importSeries,
interactiveImportActions,
oAuth,

View File

@@ -62,6 +62,20 @@ export const defaultState = {
isPopulated: false,
error: null,
items: []
},
logFiles: {
isFetching: false,
isPopulated: false,
error: null,
items: []
},
updateLogFiles: {
isFetching: false,
isPopulated: false,
error: null,
items: []
}
};
@@ -80,6 +94,11 @@ export const RESTORE_BACKUP = 'system/backups/restoreBackup';
export const CLEAR_RESTORE_BACKUP = 'system/backups/clearRestoreBackup';
export const 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';
@@ -98,6 +117,11 @@ 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);
@@ -176,6 +200,10 @@ 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',

View File

@@ -28,17 +28,10 @@ import useEvents, { useFilters } from './useEvents';
function LogsTable() {
const dispatch = useDispatch();
const {
records,
totalPages,
totalRecords,
error,
isFetching,
isFetched,
isLoading,
page,
goToPage,
} = useEvents();
const { data, error, isFetching, isFetched, isLoading, page, goToPage } =
useEvents();
const { records = [], totalPages = 0, totalRecords } = data ?? {};
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useEventOptions();

View File

@@ -1,10 +1,15 @@
import {
createOptionsStore,
PageableOptions,
} from 'Helpers/Hooks/useOptionsStore';
import Column from 'Components/Table/Column';
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
import { SortDirection } from 'Helpers/Props/sortDirections';
import translate from 'Utilities/String/translate';
export type EventOptions = PageableOptions;
export interface EventOptions {
pageSize: number;
selectedFilterKey: string | number;
sortKey: string;
sortDirection: SortDirection;
columns: Column[];
}
const { useOptions, setOptions, setOption } = createOptionsStore<EventOptions>(
'event_options',

View File

@@ -1,5 +1,5 @@
import { keepPreviousData } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { Filter } from 'App/State/AppState';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
@@ -69,9 +69,17 @@ const useEvents = () => {
},
});
const handleGoToPage = useCallback(
(page: number) => {
goToPage(page);
refetch();
},
[goToPage, refetch]
);
return {
...query,
goToPage,
goToPage: handleGoToPage,
page,
refetch,
};

View File

@@ -1,39 +1,46 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } 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 { data = [], isFetching, refetch } = useLogFiles();
const { isFetching, items } = useSelector(
(state: AppState) => state.system.logFiles
);
const isDeleteFilesExecuting = useSelector(
createCommandExecutingSelector(commandNames.DELETE_LOG_FILES)
);
const handleRefreshPress = useCallback(() => {
refetch();
}, [refetch]);
dispatch(fetchLogFiles());
}, [dispatch]);
const handleDeleteFilesPress = useCallback(() => {
dispatch(
executeCommand({
name: commandNames.DELETE_LOG_FILES,
commandFinished: () => {
refetch();
dispatch(fetchLogFiles());
},
})
);
}, [dispatch, refetch]);
}, [dispatch]);
useEffect(() => {
dispatch(fetchLogFiles());
}, [dispatch]);
return (
<LogFiles
isDeleteFilesExecuting={isDeleteFilesExecuting}
isFetching={isFetching}
items={data}
items={items}
type="app"
onRefreshPress={handleRefreshPress}
onDeleteFilesPress={handleDeleteFilesPress}

View File

@@ -1,39 +1,46 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } 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 { data = [], isFetching, refetch } = useUpdateLogFiles();
const { isFetching, items } = useSelector(
(state: AppState) => state.system.updateLogFiles
);
const isDeleteFilesExecuting = useSelector(
createCommandExecutingSelector(commandNames.DELETE_UPDATE_LOG_FILES)
);
const handleRefreshPress = useCallback(() => {
refetch();
}, [refetch]);
dispatch(fetchUpdateLogFiles());
}, [dispatch]);
const handleDeleteFilesPress = useCallback(() => {
dispatch(
executeCommand({
name: commandNames.DELETE_UPDATE_LOG_FILES,
commandFinished: () => {
refetch();
dispatch(fetchUpdateLogFiles());
},
})
);
}, [dispatch, refetch]);
}, [dispatch]);
useEffect(() => {
dispatch(fetchUpdateLogFiles());
}, [dispatch]);
return (
<LogFiles
isDeleteFilesExecuting={isDeleteFilesExecuting}
isFetching={isFetching}
items={data}
items={items}
type="update"
onRefreshPress={handleRefreshPress}
onDeleteFilesPress={handleDeleteFilesPress}

View File

@@ -1,14 +0,0 @@
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import LogFile from 'typings/LogFile';
export default function useLogFiles() {
return useApiQuery<LogFile[]>({
path: '/log/file',
});
}
export function useUpdateLogFiles() {
return useApiQuery<LogFile[]>({
path: '/log/file/update',
});
}

View File

@@ -15,6 +15,7 @@ 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';
@@ -113,6 +114,7 @@ function Updates() {
}, [setIsMajorUpdateModalOpen]);
useEffect(() => {
dispatch(fetchUpdates());
dispatch(fetchGeneralSettings());
}, [dispatch]);

View File

@@ -1,26 +0,0 @@
import moment from 'moment-timezone';
export const convertToTimezone = (
date: moment.MomentInput,
timeZone: string
) => {
if (!date) {
return moment();
}
if (!timeZone) {
return moment(date);
}
try {
return moment.tz(date, timeZone);
} catch (error) {
console.warn(
`Error converting to timezone ${timeZone}. Using system timezone.`,
error
);
return moment(date);
}
};
export default convertToTimezone;

View File

@@ -1,15 +1,11 @@
import moment from 'moment-timezone';
import moment, { MomentInput } from 'moment';
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: moment.MomentInput,
includeRelativeDate: boolean
) {
function getRelativeDay(date: MomentInput, includeRelativeDate: boolean) {
if (!includeRelativeDate) {
return '';
}
@@ -30,23 +26,20 @@ function getRelativeDay(
}
function formatDateTime(
date: moment.MomentInput,
date: MomentInput,
dateFormat: string,
timeFormat: string,
{ includeSeconds = false, includeRelativeDay = false, timeZone = '' } = {}
{ includeSeconds = false, includeRelativeDay = false } = {}
) {
if (!date) {
return '';
}
const dateTime = convertToTimezone(date, timeZone);
const relativeDay = getRelativeDay(dateTime, includeRelativeDay);
const formattedDate = dateTime.format(dateFormat);
const formattedTime = formatTime(dateTime, timeFormat, {
const relativeDay = getRelativeDay(date, includeRelativeDay);
const formattedDate = moment(date).format(dateFormat);
const formattedTime = formatTime(date, timeFormat, {
includeMinuteZero: true,
includeSeconds,
timeZone,
});
if (relativeDay) {

View File

@@ -1,16 +1,15 @@
import moment from 'moment-timezone';
import { convertToTimezone } from './convertToTimezone';
import moment, { MomentInput } from 'moment';
function formatTime(
date: moment.MomentInput,
date: MomentInput,
timeFormat: string,
{ includeMinuteZero = false, includeSeconds = false, timeZone = '' } = {}
{ includeMinuteZero = false, includeSeconds = false } = {}
) {
if (!date) {
return '';
}
const time = convertToTimezone(date, timeZone);
const time = moment(date);
if (includeSeconds) {
timeFormat = timeFormat.replace(/\(?:mm\)?/, ':mm:ss');

View File

@@ -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,7 +12,6 @@ interface GetRelativeDateOptions {
shortDateFormat: string;
showRelativeDates: boolean;
timeFormat?: string;
timeZone?: string;
includeSeconds?: boolean;
timeForToday?: boolean;
includeTime?: boolean;
@@ -23,7 +22,6 @@ function getRelativeDate({
shortDateFormat,
showRelativeDates,
timeFormat,
timeZone = '',
includeSeconds = false,
timeForToday = false,
includeTime = false,
@@ -43,7 +41,6 @@ function getRelativeDate({
? formatTime(date, timeFormat, {
includeMinuteZero: true,
includeSeconds,
timeZone,
})
: '';
@@ -52,8 +49,7 @@ function getRelativeDate({
}
if (!showRelativeDates) {
const dateTime = convertToTimezone(date, timeZone);
return dateTime.format(shortDateFormat);
return moment(date).format(shortDateFormat);
}
if (isYesterday(date)) {
@@ -73,18 +69,14 @@ function getRelativeDate({
}
if (isInNextWeek(date)) {
const dateTime = convertToTimezone(date, timeZone);
const day = dateTime.format('dddd');
const day = moment(date).format('dddd');
return includeTime ? translate('DayOfWeekAt', { day, time }) : day;
}
return includeTime && timeFormat
? formatDateTime(date, shortDateFormat, timeFormat, {
includeSeconds,
timeZone,
})
: convertToTimezone(date, timeZone).format(shortDateFormat);
? formatDateTime(date, shortDateFormat, timeFormat, { includeSeconds })
: moment(date).format(shortDateFormat);
}
export default getRelativeDate;

View File

@@ -1,192 +0,0 @@
import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput';
import translate from 'Utilities/String/translate';
export const timeZoneOptions: EnhancedSelectInputValue<string>[] = [
{
key: '',
get value() {
return translate('SystemDefault');
},
},
// UTC
{ key: 'UTC', value: 'UTC' },
// Africa (Major cities and unique timezones)
{ key: 'Africa/Abidjan', value: 'Africa/Abidjan' },
{ key: 'Africa/Algiers', value: 'Africa/Algiers' },
{ key: 'Africa/Cairo', value: 'Africa/Cairo' },
{ key: 'Africa/Casablanca', value: 'Africa/Casablanca' },
{ key: 'Africa/Johannesburg', value: 'Africa/Johannesburg' },
{ key: 'Africa/Lagos', value: 'Africa/Lagos' },
{ key: 'Africa/Nairobi', value: 'Africa/Nairobi' },
{ key: 'Africa/Tripoli', value: 'Africa/Tripoli' },
// America - North America (Major US/Canada zones)
{ key: 'America/New_York', value: 'America/New_York (Eastern)' },
{ key: 'America/Chicago', value: 'America/Chicago (Central)' },
{ key: 'America/Denver', value: 'America/Denver (Mountain)' },
{ key: 'America/Los_Angeles', value: 'America/Los_Angeles (Pacific)' },
{ key: 'America/Anchorage', value: 'America/Anchorage (Alaska)' },
{ key: 'America/Adak', value: 'America/Adak (Hawaii-Aleutian)' },
{ key: 'America/Phoenix', value: 'America/Phoenix (Arizona)' },
{ key: 'America/Toronto', value: 'America/Toronto' },
{ key: 'America/Vancouver', value: 'America/Vancouver' },
{ key: 'America/Halifax', value: 'America/Halifax' },
{ key: 'America/St_Johns', value: 'America/St_Johns (Newfoundland)' },
// America - Mexico
{ key: 'America/Mexico_City', value: 'America/Mexico_City' },
{ key: 'America/Cancun', value: 'America/Cancun' },
{ key: 'America/Tijuana', value: 'America/Tijuana' },
// America - Central America
{ key: 'America/Guatemala', value: 'America/Guatemala' },
{ key: 'America/Costa_Rica', value: 'America/Costa_Rica' },
{ key: 'America/Panama', value: 'America/Panama' },
// America - Caribbean
{ key: 'America/Havana', value: 'America/Havana' },
{ key: 'America/Jamaica', value: 'America/Jamaica' },
{ key: 'America/Puerto_Rico', value: 'America/Puerto_Rico' },
// America - South America
{ key: 'America/Bogota', value: 'America/Bogota' },
{ key: 'America/Caracas', value: 'America/Caracas' },
{ key: 'America/Guyana', value: 'America/Guyana' },
{ key: 'America/La_Paz', value: 'America/La_Paz' },
{ key: 'America/Lima', value: 'America/Lima' },
{ key: 'America/Santiago', value: 'America/Santiago' },
{ key: 'America/Asuncion', value: 'America/Asuncion' },
{ key: 'America/Montevideo', value: 'America/Montevideo' },
{
key: 'America/Argentina/Buenos_Aires',
value: 'America/Argentina/Buenos_Aires',
},
{ key: 'America/Sao_Paulo', value: 'America/Sao_Paulo' },
{ key: 'America/Manaus', value: 'America/Manaus' },
{ key: 'America/Fortaleza', value: 'America/Fortaleza' },
{ key: 'America/Noronha', value: 'America/Noronha' },
// Antarctica (Research stations)
{ key: 'Antarctica/McMurdo', value: 'Antarctica/McMurdo' },
{ key: 'Antarctica/Palmer', value: 'Antarctica/Palmer' },
// Arctic
{ key: 'Arctic/Longyearbyen', value: 'Arctic/Longyearbyen' },
// Asia - East Asia
{ key: 'Asia/Tokyo', value: 'Asia/Tokyo' },
{ key: 'Asia/Seoul', value: 'Asia/Seoul' },
{ key: 'Asia/Shanghai', value: 'Asia/Shanghai' },
{ key: 'Asia/Hong_Kong', value: 'Asia/Hong_Kong' },
{ key: 'Asia/Taipei', value: 'Asia/Taipei' },
{ key: 'Asia/Macau', value: 'Asia/Macau' },
// Asia - Southeast Asia
{ key: 'Asia/Singapore', value: 'Asia/Singapore' },
{ key: 'Asia/Kuala_Lumpur', value: 'Asia/Kuala_Lumpur' },
{ key: 'Asia/Jakarta', value: 'Asia/Jakarta' },
{ key: 'Asia/Manila', value: 'Asia/Manila' },
{ key: 'Asia/Bangkok', value: 'Asia/Bangkok' },
{ key: 'Asia/Ho_Chi_Minh', value: 'Asia/Ho_Chi_Minh' },
// Asia - South Asia
{ key: 'Asia/Kolkata', value: 'Asia/Kolkata' },
{ key: 'Asia/Dhaka', value: 'Asia/Dhaka' },
{ key: 'Asia/Karachi', value: 'Asia/Karachi' },
{ key: 'Asia/Kathmandu', value: 'Asia/Kathmandu' },
{ key: 'Asia/Colombo', value: 'Asia/Colombo' },
// Asia - Central Asia
{ key: 'Asia/Almaty', value: 'Asia/Almaty' },
{ key: 'Asia/Tashkent', value: 'Asia/Tashkent' },
{ key: 'Asia/Bishkek', value: 'Asia/Bishkek' },
{ key: 'Asia/Dushanbe', value: 'Asia/Dushanbe' },
// Asia - Western Asia/Middle East
{ key: 'Asia/Dubai', value: 'Asia/Dubai' },
{ key: 'Asia/Riyadh', value: 'Asia/Riyadh' },
{ key: 'Asia/Kuwait', value: 'Asia/Kuwait' },
{ key: 'Asia/Qatar', value: 'Asia/Qatar' },
{ key: 'Asia/Bahrain', value: 'Asia/Bahrain' },
{ key: 'Asia/Jerusalem', value: 'Asia/Jerusalem' },
{ key: 'Asia/Beirut', value: 'Asia/Beirut' },
{ key: 'Asia/Damascus', value: 'Asia/Damascus' },
{ key: 'Asia/Baghdad', value: 'Asia/Baghdad' },
{ key: 'Asia/Tehran', value: 'Asia/Tehran' },
// Asia - Russia
{ key: 'Europe/Moscow', value: 'Europe/Moscow' },
{ key: 'Asia/Yekaterinburg', value: 'Asia/Yekaterinburg' },
{ key: 'Asia/Novosibirsk', value: 'Asia/Novosibirsk' },
{ key: 'Asia/Krasnoyarsk', value: 'Asia/Krasnoyarsk' },
{ key: 'Asia/Irkutsk', value: 'Asia/Irkutsk' },
{ key: 'Asia/Yakutsk', value: 'Asia/Yakutsk' },
{ key: 'Asia/Vladivostok', value: 'Asia/Vladivostok' },
{ key: 'Asia/Sakhalin', value: 'Asia/Sakhalin' },
{ key: 'Asia/Kamchatka', value: 'Asia/Kamchatka' },
// Atlantic
{ key: 'Atlantic/Azores', value: 'Atlantic/Azores' },
{ key: 'Atlantic/Canary', value: 'Atlantic/Canary' },
{ key: 'Atlantic/Cape_Verde', value: 'Atlantic/Cape_Verde' },
{ key: 'Atlantic/Reykjavik', value: 'Atlantic/Reykjavik' },
// Australia & New Zealand
{ key: 'Australia/Sydney', value: 'Australia/Sydney' },
{ key: 'Australia/Melbourne', value: 'Australia/Melbourne' },
{ key: 'Australia/Brisbane', value: 'Australia/Brisbane' },
{ key: 'Australia/Perth', value: 'Australia/Perth' },
{ key: 'Australia/Adelaide', value: 'Australia/Adelaide' },
{ key: 'Australia/Darwin', value: 'Australia/Darwin' },
{ key: 'Australia/Hobart', value: 'Australia/Hobart' },
{ key: 'Pacific/Auckland', value: 'Pacific/Auckland' },
{ key: 'Pacific/Chatham', value: 'Pacific/Chatham' },
// Europe - Western Europe
{ key: 'Europe/London', value: 'Europe/London' },
{ key: 'Europe/Dublin', value: 'Europe/Dublin' },
{ key: 'Europe/Paris', value: 'Europe/Paris' },
{ key: 'Europe/Berlin', value: 'Europe/Berlin' },
{ key: 'Europe/Amsterdam', value: 'Europe/Amsterdam' },
{ key: 'Europe/Brussels', value: 'Europe/Brussels' },
{ key: 'Europe/Zurich', value: 'Europe/Zurich' },
{ key: 'Europe/Vienna', value: 'Europe/Vienna' },
{ key: 'Europe/Rome', value: 'Europe/Rome' },
{ key: 'Europe/Madrid', value: 'Europe/Madrid' },
{ key: 'Europe/Lisbon', value: 'Europe/Lisbon' },
// Europe - Northern Europe
{ key: 'Europe/Stockholm', value: 'Europe/Stockholm' },
{ key: 'Europe/Oslo', value: 'Europe/Oslo' },
{ key: 'Europe/Copenhagen', value: 'Europe/Copenhagen' },
{ key: 'Europe/Helsinki', value: 'Europe/Helsinki' },
// Europe - Eastern Europe
{ key: 'Europe/Warsaw', value: 'Europe/Warsaw' },
{ key: 'Europe/Prague', value: 'Europe/Prague' },
{ key: 'Europe/Budapest', value: 'Europe/Budapest' },
{ key: 'Europe/Bucharest', value: 'Europe/Bucharest' },
{ key: 'Europe/Sofia', value: 'Europe/Sofia' },
{ key: 'Europe/Athens', value: 'Europe/Athens' },
{ key: 'Europe/Istanbul', value: 'Europe/Istanbul' },
{ key: 'Europe/Kiev', value: 'Europe/Kiev' },
{ key: 'Europe/Minsk', value: 'Europe/Minsk' },
// Indian Ocean
{ key: 'Indian/Mauritius', value: 'Indian/Mauritius' },
{ key: 'Indian/Maldives', value: 'Indian/Maldives' },
// Pacific - Major Island Nations
{ key: 'Pacific/Honolulu', value: 'Pacific/Honolulu' },
{ key: 'Pacific/Fiji', value: 'Pacific/Fiji' },
{ key: 'Pacific/Guam', value: 'Pacific/Guam' },
{ key: 'Pacific/Tahiti', value: 'Pacific/Tahiti' },
{ key: 'Pacific/Apia', value: 'Pacific/Apia' },
{ key: 'Pacific/Tongatapu', value: 'Pacific/Tongatapu' },
{ key: 'Pacific/Port_Moresby', value: 'Pacific/Port_Moresby' },
{ key: 'Pacific/Noumea', value: 'Pacific/Noumea' },
];
export default timeZoneOptions;

View File

@@ -15,7 +15,6 @@ interface Blocklist extends ModelBase {
seriesId?: number;
indexer?: string;
message?: string;
source?: string;
}
export default Blocklist;

View File

@@ -37,7 +37,6 @@ export interface GrabbedHistoryData {
export interface DownloadFailedHistory {
message: string;
indexer?: string;
source?: string;
}
export interface DownloadFolderImportedHistory {

View File

@@ -23,7 +23,6 @@ export default interface General {
branch: string;
apiKey: string;
sslCertPath: string;
sslKeyPath: string;
sslCertPassword: string;
urlBase: string;
instanceName: string;

View File

@@ -20,6 +20,4 @@ export default interface MediaManagement {
extraFileExtensions: string;
userRejectedExtensions: string;
enableMediaInfo: boolean;
seasonPackUpgrade: string;
seasonPackUpgradeThreshold: number;
}

View File

@@ -7,7 +7,6 @@ interface ReleaseProfile extends ModelBase {
ignored: string[];
indexerId: number;
tags: number[];
excludedTags: number[];
}
export default ReleaseProfile;

View File

@@ -4,7 +4,6 @@ export default interface UiSettings {
shortDateFormat: string;
longDateFormat: string;
timeFormat: string;
timeZone: string;
firstDayOfWeek: number;
enableColorImpairedMode: boolean;
calendarWeekColumnHeader: string;

View File

@@ -46,7 +46,6 @@
"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",
@@ -95,7 +94,6 @@
"@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",

View File

@@ -84,9 +84,8 @@
<Deterministic Condition="$(AssemblyVersion.EndsWith('*'))">False</Deterministic>
<PathMap>$(MSBuildThisFileDirectory)=./</PathMap>
<PathMap>$(MSBuildProjectDirectory)=./$(MSBuildProjectName)/</PathMap>
</PropertyGroup>
<!-- Set the AssemblyConfiguration attribute for projects -->
<ItemGroup Condition="'$(SonarrProject)'=='true'">
<AssemblyAttribute Include="System.Reflection.AssemblyConfigurationAttribute">
@@ -123,11 +122,14 @@
<!-- Standard testing packages -->
<ItemGroup Condition="'$(TestProject)'=='true'">
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.1.0" />
<PackageReference Include="NunitXml.TestLogger" Version="3.1.20" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Include="NunitXml.TestLogger" Version="3.0.131" />
</ItemGroup>
<ItemGroup Condition="'$(TestProject)'=='true' and '$(TargetFramework)'=='net8.0'">
<PackageReference Include="coverlet.collector" Version="3.0.4-preview.27.ge7cb7c3b40" />
</ItemGroup>
<PropertyGroup Condition="'$(SonarrProject)'=='true' and '$(EnableAnalyzers)'=='false'">

View File

@@ -5,6 +5,9 @@
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="dotnet-bsd-crossbuild" value="https://pkgs.dev.azure.com/Servarr/Servarr/_packaging/dotnet-bsd-crossbuild/nuget/v3/index.json" />
<add key="Mono.Posix.NETStandard" value="https://pkgs.dev.azure.com/Servarr/Servarr/_packaging/Mono.Posix.NETStandard/nuget/v3/index.json" />
<add key="SQLite" value="https://pkgs.dev.azure.com/Servarr/Servarr/_packaging/SQLite/nuget/v3/index.json" />
<add key="coverlet-nightly" value="https://pkgs.dev.azure.com/Servarr/coverlet/_packaging/coverlet-nightly/nuget/v3/index.json" />
<add key="FFMpegCore" value="https://pkgs.dev.azure.com/Servarr/Servarr/_packaging/FFMpegCore/nuget/v3/index.json" />
<add key="FluentMigrator" value="https://pkgs.dev.azure.com/Servarr/Servarr/_packaging/FluentMigrator/nuget/v3/index.json" />
</packageSources>
</configuration>
</configuration>

View File

@@ -1,23 +0,0 @@
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.Http;
using NzbDrone.Test.Common;
namespace NzbDrone.Common.Test.Http;
[TestFixture]
public class UserAgentParserFixture : TestBase
{
// Ref *Arr `_userAgent = $"{BuildInfo.AppName}/{BuildInfo.Version} ({osName} {osVersion})";`
// Ref Mylar `Mylar3/' +str(hash) +'(' +vers +') +http://www.github.com/mylar3/mylar3/`
[TestCase("Mylar3/ 3ee23rh23irqfq (13123123) http://www.github.com/mylar3/mylar3/", "Mylar3")]
[TestCase("Lidarr/1.0.0.2300 (ubuntu 20.04)", "Lidarr")]
[TestCase("Radarr/1.0.0.2300 (ubuntu 20.04)", "Radarr")]
[TestCase("Readarr/1.0.0.2300 (ubuntu 20.04)", "Readarr")]
[TestCase("Sonarr/3.0.6.9999 (ubuntu 20.04)", "Sonarr")]
[TestCase("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36", "Other")]
public void should_parse_user_agent(string userAgent, string parsedAgent)
{
UserAgentParser.ParseSource(userAgent).Should().Be(parsedAgent);
}
}

View File

@@ -222,7 +222,7 @@ namespace NzbDrone.Common.EnvironmentInfo
private void RemovePidFile()
{
if (OsInfo.IsNotWindows && _diskProvider.FolderExists(_appFolderInfo.AppDataFolder))
if (OsInfo.IsNotWindows)
{
_diskProvider.DeleteFile(Path.Combine(_appFolderInfo.AppDataFolder, "sonarr.pid"));
}

View File

@@ -27,17 +27,15 @@ namespace NzbDrone.Common.EnvironmentInfo
_dataSpecialFolder = Environment.SpecialFolder.ApplicationData;
}
if (startupContext.Args.TryGetValue(StartupContext.APPDATA, out var argsAppDataFolder))
if (startupContext.Args.ContainsKey(StartupContext.APPDATA))
{
AppDataFolder = argsAppDataFolder;
AppDataFolder = startupContext.Args[StartupContext.APPDATA];
Logger.Info("Data directory is being overridden to [{0}]", AppDataFolder);
}
else
{
AppDataFolder = Path.Combine(Environment.GetFolderPath(_dataSpecialFolder, Environment.SpecialFolderOption.DoNotVerify), "Sonarr");
LegacyAppDataFolder = OsInfo.IsOsx
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.DoNotVerify), ".config", "NzbDrone")
: Path.Combine(Environment.GetFolderPath(_dataSpecialFolder, Environment.SpecialFolderOption.DoNotVerify), "NzbDrone");
LegacyAppDataFolder = Path.Combine(Environment.GetFolderPath(_dataSpecialFolder, Environment.SpecialFolderOption.DoNotVerify), "NzbDrone");
}
StartUpFolder = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory.FullName;

View File

@@ -390,12 +390,5 @@ namespace NzbDrone.Common.Http
return this;
}
public virtual HttpRequestBuilder AllowRedirect(bool allowAutoRedirect = true)
{
AllowAutoRedirect = allowAutoRedirect;
return this;
}
}
}

View File

@@ -1,12 +1,7 @@
using System.Text.RegularExpressions;
namespace NzbDrone.Common.Http
{
public static class UserAgentParser
{
private static readonly Regex AppSourceRegex = new(@"(?<agent>[a-z0-9]*)\/.*(?:\(.*\))?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static string SimplifyUserAgent(string userAgent)
{
if (userAgent == null || userAgent.StartsWith("Mozilla/5.0"))
@@ -16,17 +11,5 @@ namespace NzbDrone.Common.Http
return userAgent;
}
public static string ParseSource(string userAgent)
{
var match = AppSourceRegex.Match(SimplifyUserAgent(userAgent) ?? string.Empty);
if (match.Groups["agent"].Success)
{
return match.Groups["agent"].Value;
}
return "Other";
}
}
}

View File

@@ -8,6 +8,5 @@ public class ServerOptions
public bool? EnableSsl { get; set; }
public int? SslPort { get; set; }
public string SslCertPath { get; set; }
public string SslKeyPath { get; set; }
public string SslCertPassword { get; set; }
}

View File

@@ -15,14 +15,13 @@
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.15" />
<PackageReference Include="Sentry" Version="4.0.2" />
<PackageReference Include="SharpZipLib" Version="1.4.2" />
<PackageReference Include="SourceGear.sqlite3" Version="3.50.4.2" />
<PackageReference Include="StyleCop.Analyzers.Unstable" Version="1.2.0.556">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="System.Data.SQLite" Version="2.0.2" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="System.Data.SQLite.Core.Servarr" Version="1.0.115.5-18" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="8.0.1" />
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="6.0.0-preview.5.21301.5" />
<PackageReference Include="System.Runtime.Loader" Version="4.3.0" />

View File

@@ -85,10 +85,6 @@ namespace NzbDrone.Core.Test.Configuration
{
value = DateTime.Now.Millisecond;
}
else if (propertyInfo.PropertyType == typeof(double))
{
value = (double)DateTime.Now.Millisecond;
}
else if (propertyInfo.PropertyType == typeof(bool))
{
value = true;

View File

@@ -7,7 +7,6 @@ using NUnit.Framework;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Languages;
using NzbDrone.Core.MediaFiles;
@@ -438,102 +437,5 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Subject.IsSatisfiedBy(_parseResultSingle, new()).Accepted.Should().BeFalse();
}
[Test]
public void should_reject_season_pack_when_mode_is_all_and_not_all_are_upgradable()
{
GivenProfile(new QualityProfile
{
Cutoff = Quality.Bluray1080p.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true
});
Mocker.GetMock<IConfigService>()
.SetupGet(s => s.SeasonPackUpgrade)
.Returns(SeasonPackUpgradeType.All);
_parseResultMulti.ParsedEpisodeInfo.FullSeason = true;
_parseResultMulti.Episodes = new List<Episode>
{
new Episode { EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.SDTV) }, EpisodeFileId = 1 },
new Episode { EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.Bluray1080p) }, EpisodeFileId = 2 }
};
_parseResultMulti.ParsedEpisodeInfo.Quality = new QualityModel(Quality.Bluray1080p);
var result = Subject.IsSatisfiedBy(_parseResultMulti, new());
result.Accepted.Should().BeFalse();
}
[Test]
public void should_reject_for_season_pack_not_meeting_threshold()
{
GivenProfile(new QualityProfile
{
Cutoff = Quality.Bluray1080p.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true
});
Mocker.GetMock<IConfigService>()
.SetupGet(s => s.SeasonPackUpgrade)
.Returns(SeasonPackUpgradeType.Threshold);
Mocker.GetMock<IConfigService>()
.SetupGet(s => s.SeasonPackUpgradeThreshold)
.Returns(90);
_parseResultMulti.ParsedEpisodeInfo.FullSeason = true;
_parseResultMulti.Episodes = new List<Episode>
{
new Episode { EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.SDTV) }, EpisodeFileId = 1 },
new Episode { EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.SDTV) }, EpisodeFileId = 2 },
new Episode { EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.SDTV) }, EpisodeFileId = 3 },
new Episode { EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.SDTV) }, EpisodeFileId = 4 },
new Episode { EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.SDTV) }, EpisodeFileId = 5 },
new Episode { EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.SDTV) }, EpisodeFileId = 6 },
new Episode { EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.SDTV) }, EpisodeFileId = 7 },
new Episode { EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.Bluray1080p) }, EpisodeFileId = 8 },
new Episode { EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.Bluray1080p) }, EpisodeFileId = 9 },
new Episode { EpisodeFile = null, EpisodeFileId = 0 }
};
_parseResultMulti.ParsedEpisodeInfo.Quality = new QualityModel(Quality.Bluray1080p);
var result = Subject.IsSatisfiedBy(_parseResultMulti, new());
result.Accepted.Should().BeFalse();
result.Reason.Should().Be(DownloadRejectionReason.DiskNotUpgrade);
}
[Test]
public void should_accept_season_pack_when_mode_is_any_and_at_least_one_upgradable()
{
GivenProfile(new QualityProfile
{
Cutoff = Quality.Bluray1080p.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true
});
Mocker.GetMock<IConfigService>()
.SetupGet(s => s.SeasonPackUpgrade)
.Returns(SeasonPackUpgradeType.Any);
_parseResultMulti.ParsedEpisodeInfo.FullSeason = true;
_parseResultMulti.Episodes = new List<Episode>
{
new Episode { EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.SDTV) }, EpisodeFileId = 1 },
new Episode { EpisodeFile = new EpisodeFile { Quality = new QualityModel(Quality.Bluray1080p) }, EpisodeFileId = 2 }
};
_parseResultMulti.ParsedEpisodeInfo.Quality = new QualityModel(Quality.Bluray1080p);
var result = Subject.IsSatisfiedBy(_parseResultMulti, new());
result.Accepted.Should().BeTrue();
}
}
}

View File

@@ -65,12 +65,10 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Subject.Aggregate(_remoteEpisode).Languages.Should().Equal(_remoteEpisode.ParsedEpisodeInfo.Languages);
}
[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)
[Test]
public void should_return_multi_languages_when_indexer_id_has_multi_languages_configuration()
{
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
var indexerDefinition = new IndexerDefinition
{
Id = 1,
@@ -89,12 +87,10 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
}
[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)
[Test]
public void should_return_multi_languages_from_indexer_with_id_when_indexer_id_and_name_are_set()
{
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
var indexerDefinition1 = new IndexerDefinition
{
Id = 1,
@@ -126,12 +122,10 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
}
[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)
[Test]
public void should_return_multi_languages_when_indexer_name_has_multi_languages_configuration()
{
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
var indexerDefinition = new IndexerDefinition
{
Id = 1,
@@ -152,12 +146,10 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
}
[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)
[Test]
public void should_return_multi_languages_when_release_as_unknown_as_default_language_and_indexer_has_multi_languages_configuration()
{
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
var indexerDefinition = new IndexerDefinition
{
Id = 1,
@@ -176,12 +168,10 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
}
[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)
[Test]
public void should_return_multi_languages_when_release_as_specified_language_and_indexer_has_multi_languages_configuration()
{
var releaseTitle = "Series.Title.S01E01.MULTi.VFF.VFQ.1080p.BluRay.DTS.HDMA.x264-RlsGroup";
var indexerDefinition = new IndexerDefinition
{
Id = 1,
@@ -200,12 +190,10 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
}
[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)
[Test]
public void should_return_multi_languages_when_release_as_other_language_and_indexer_has_multi_languages_configuration()
{
var releaseTitle = "Series.Title.S01E01.MULTi.GERMAN.1080p.BluRay.DTS.HDMA.x264-RlsGroup";
var indexerDefinition = new IndexerDefinition
{
Id = 1,
@@ -224,12 +212,10 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
}
[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)
[Test]
public void should_return_original_when_indexer_has_no_multi_languages_configuration()
{
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
var indexerDefinition = new IndexerDefinition
{
Id = 1,
@@ -248,12 +234,11 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
}
[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)
[Test]
public void should_return_original_when_no_indexer_value()
{
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
_remoteEpisode.ParsedEpisodeInfo = GetParsedEpisodeInfo(new List<Language> { }, releaseTitle);
_remoteEpisode.Release.Title = releaseTitle;

View File

@@ -1,234 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.Download;
using NzbDrone.Core.Download.Clients.RQBit;
using NzbDrone.Core.MediaFiles.TorrentInfo;
namespace NzbDrone.Core.Test.Download.DownloadClientTests.RQBitTests
{
[TestFixture]
public class RQBitFixture : DownloadClientFixtureBase<RQBit>
{
protected RQBitTorrent _queued;
protected RQBitTorrent _downloading;
protected RQBitTorrent _failed;
protected RQBitTorrent _completed;
[SetUp]
public void Setup()
{
Subject.Definition = new DownloadClientDefinition();
Subject.Definition.Settings = new RQbitSettings
{
Host = "127.0.0.1",
Port = 3030,
UseSsl = false
};
_queued = new RQBitTorrent
{
Hash = "HASH",
IsFinished = false,
IsActive = false,
Name = _title,
TotalSize = 1000,
RemainingSize = 1000,
Path = "somepath"
};
_downloading = new RQBitTorrent
{
Hash = "HASH",
IsFinished = false,
IsActive = true,
Name = _title,
TotalSize = 1000,
RemainingSize = 100,
Path = "somepath"
};
_failed = new RQBitTorrent
{
Hash = "HASH",
IsFinished = false,
IsActive = false,
Name = _title,
TotalSize = 1000,
RemainingSize = 1000,
Path = "somepath"
};
_completed = new RQBitTorrent
{
Hash = "HASH",
IsFinished = true,
IsActive = false,
Name = _title,
TotalSize = 1000,
RemainingSize = 0,
Path = "somepath"
};
Mocker.GetMock<ITorrentFileInfoReader>()
.Setup(s => s.GetHashFromTorrentFile(It.IsAny<byte[]>()))
.Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951");
}
protected void GivenSuccessfulDownload()
{
Mocker.GetMock<IRQbitProxy>()
.Setup(s => s.AddTorrentFromUrl(It.IsAny<string>(), It.IsAny<RQbitSettings>()))
.Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951");
Mocker.GetMock<IRQbitProxy>()
.Setup(s => s.AddTorrentFromFile(It.IsAny<string>(), It.IsAny<byte[]>(), It.IsAny<RQbitSettings>()))
.Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951");
}
protected virtual void GivenTorrents(List<RQBitTorrent> torrents)
{
if (torrents == null)
{
torrents = new List<RQBitTorrent>();
}
Mocker.GetMock<IRQbitProxy>()
.Setup(s => s.GetTorrents(It.IsAny<RQbitSettings>()))
.Returns(torrents);
}
protected void PrepareClientToReturnQueuedItem()
{
GivenTorrents(new List<RQBitTorrent>
{
_queued
});
}
protected void PrepareClientToReturnDownloadingItem()
{
GivenTorrents(new List<RQBitTorrent>
{
_downloading
});
}
protected void PrepareClientToReturnFailedItem()
{
GivenTorrents(new List<RQBitTorrent>
{
_failed
});
}
protected void PrepareClientToReturnCompletedItem()
{
GivenTorrents(new List<RQBitTorrent>
{
_completed
});
}
[Test]
public void queued_item_should_have_required_properties()
{
PrepareClientToReturnQueuedItem();
var item = Subject.GetItems().Single();
VerifyPaused(item);
}
[Test]
public void downloading_item_should_have_required_properties()
{
PrepareClientToReturnDownloadingItem();
var item = Subject.GetItems().Single();
VerifyDownloading(item);
}
[Test]
public void failed_item_should_have_required_properties()
{
PrepareClientToReturnFailedItem();
var item = Subject.GetItems().Single();
VerifyPaused(item);
}
[Test]
public void completed_download_should_have_required_properties()
{
PrepareClientToReturnCompletedItem();
var item = Subject.GetItems().Single();
VerifyCompleted(item);
}
[Test]
public async Task Download_should_return_unique_id()
{
GivenSuccessfulDownload();
var remoteEpisode = CreateRemoteEpisode();
var id = await Subject.Download(remoteEpisode, CreateIndexer());
id.Should().NotBeNullOrEmpty();
}
[TestCase("magnet:?xt=urn:btih:ZPBPA2P6ROZPKRHK44D5OW6NHXU5Z6KR&tr=udp", "CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951")]
public async Task Download_should_get_hash_from_magnet_url(string magnetUrl, string expectedHash)
{
GivenSuccessfulDownload();
var remoteEpisode = CreateRemoteEpisode();
remoteEpisode.Release.DownloadUrl = magnetUrl;
var id = await Subject.Download(remoteEpisode, CreateIndexer());
id.Should().Be(expectedHash);
}
[Test]
public void should_return_status_with_outputdirs()
{
var result = Subject.GetStatus();
result.IsLocalhost.Should().BeTrue();
}
[Test]
public void GetItems_should_ignore_torrents_with_empty_path()
{
var torrents = new List<RQBitTorrent>
{
new RQBitTorrent { Name = "Test1", Hash = "Hash1", Path = "" },
new RQBitTorrent { Name = "Test2", Hash = "Hash2", Path = "/valid/path" }
};
GivenTorrents(torrents);
var items = Subject.GetItems();
items.Should().HaveCount(1);
items.First().Title.Should().Be("Test2");
}
[Test]
public void GetItems_should_ignore_torrents_with_relative_path()
{
var torrents = new List<RQBitTorrent>
{
new RQBitTorrent { Name = "Test1", Hash = "Hash1", Path = "./relative/path" },
new RQBitTorrent { Name = "Test2", Hash = "Hash2", Path = "/absolute/path" }
};
GivenTorrents(torrents);
var items = Subject.GetItems();
items.Should().HaveCount(1);
items.First().Title.Should().Be("Test2");
}
}
}

View File

@@ -27,7 +27,6 @@
"TvrageID":"4055",
"ImdbID":"0320037",
"InfoHash":"123",
"Tags": ["Subtitles"],
"DownloadURL":"https:\/\/broadcasthe.net\/torrents.php?action=download&id=123&authkey=123&torrent_pass=123"
},
"1234":{
@@ -55,9 +54,8 @@
"TvrageID":"38472",
"ImdbID":"2377081",
"InfoHash":"1234",
"Tags": [],
"DownloadURL":"https:\/\/broadcasthe.net\/torrents.php?action=download&id=1234&authkey=1234&torrent_pass=1234"
}},
"results":"117927"
}
}
}

View File

@@ -124,34 +124,5 @@
<newznab:attr name="nuked" value="0"/>
</item>
<item>
<title>title</title>
<guid isPermaLink="true">subs=eng</guid>
<link>link</link>
<comments>comments</comments>
<pubDate>Sat, 31 Aug 2024 12:28:40 +0300</pubDate>
<category>category</category>
<description>description</description>
<enclosure url="url" length="500" type="application/x-nzb"/>
<newznab:attr name="haspretime" value="0"/>
<newznab:attr name="nuked" value="0"/>
<newznab:attr name="subs" value="Eng"/>
</item>
<item>
<title>title</title>
<guid isPermaLink="true">subs=''</guid>
<link>link</link>
<comments>comments</comments>
<pubDate>Sat, 31 Aug 2024 12:28:40 +0300</pubDate>
<category>category</category>
<description>description</description>
<enclosure url="url" length="500" type="application/x-nzb"/>
<newznab:attr name="haspretime" value="0"/>
<newznab:attr name="nuked" value="0"/>
<newznab:attr name="subs" value=""/>
</item>
</channel>
</rss>

View File

@@ -15,24 +15,24 @@ namespace NzbDrone.Core.Test.Http
return new HttpProxySettings(ProxyType.Socks5, "localhost", 8080, "*.httpbin.org,google.com,172.16.0.0/12", true, null, null);
}
[TestCase("http://eu.httpbin.org/get")]
[TestCase("http://google.com/get")]
[TestCase("http://localhost:8654/get")]
[TestCase("http://172.21.0.1:8989/api/v3/indexer/schema")]
public void should_bypass_proxy(string url)
[Test]
public void should_bypass_proxy()
{
var settings = GetProxySettings();
Subject.ShouldProxyBeBypassed(settings, new HttpUri(url)).Should().BeTrue();
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://eu.httpbin.org/get")).Should().BeTrue();
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://google.com/get")).Should().BeTrue();
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://localhost:8654/get")).Should().BeTrue();
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://172.21.0.1:8989/api/v3/indexer/schema")).Should().BeTrue();
}
[TestCase("http://bing.com/get")]
[TestCase("http://172.3.0.1:8989/api/v3/indexer/schema")]
public void should_not_bypass_proxy(string url)
[Test]
public void should_not_bypass_proxy()
{
var settings = GetProxySettings();
Subject.ShouldProxyBeBypassed(settings, new HttpUri(url)).Should().BeFalse();
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://bing.com/get")).Should().BeFalse();
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://172.3.0.1:8989/api/v3/indexer/schema")).Should().BeFalse();
}
}
}

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