mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-18 21:35:27 -04:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cb9a8b1b7 | |||
| 4fd5e9610a | |||
| a4f210855e | |||
| bc4ad574fc | |||
| a5ea19ddfb | |||
| 858c690543 | |||
| 8e169561f2 | |||
| 994faa60c6 | |||
| a4a18d6121 | |||
| 0407564784 | |||
| 0a61e66ef1 | |||
| 051451eb2a | |||
| a4e3be721d | |||
| 224e74605b | |||
| 6c581b7e3c | |||
| 6588ba8435 | |||
| ce4c2e4fcc | |||
| b1f77007dc | |||
| 8bab0a06dd | |||
| 5b135addaa | |||
| 4904e85887 | |||
| c4978022eb | |||
| 15e9350601 | |||
| 2e1289b924 | |||
| 7dac00d5aa | |||
| 3796c9e30f | |||
| f2f4edad0c | |||
| b0b15c78ff | |||
| 64c421c187 | |||
| 6440151053 | |||
| cf6b21aef6 |
@@ -21,7 +21,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
|
||||
- name: Setup Environment Variables
|
||||
id: variables
|
||||
|
||||
@@ -4,6 +4,8 @@ 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
|
||||
@@ -27,16 +29,18 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
|
||||
- name: Setup Postgres
|
||||
if: ${{ inputs.use_postgres }}
|
||||
uses: ikalnytskyi/action-setup-postgres@v4
|
||||
uses: ikalnytskyi/action-setup-postgres@v7
|
||||
with:
|
||||
postgres-version: ${{ inputs.postgres-version }}
|
||||
|
||||
- name: Setup Test Variables
|
||||
shell: bash
|
||||
run: |
|
||||
echo "RESULTS_NAME=${{ inputs.integration_tests && 'integation-' || 'unit-' }}${{ inputs.artifact }}${{ inputs.use_postgres && '-postgres' }}" >> "$GITHUB_ENV"
|
||||
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"
|
||||
|
||||
- name: Setup Postgres Environment Variables
|
||||
if: ${{ inputs.use_postgres }}
|
||||
@@ -48,14 +52,14 @@ runs:
|
||||
echo "Sonarr__Postgres__Password=postgres" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: ${{ inputs.artifact }}
|
||||
path: _tests
|
||||
|
||||
- name: Download Binary Artifact
|
||||
if: ${{ inputs.integration_tests }}
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: ${{ inputs.binary_artifact }}
|
||||
path: _output
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Build
|
||||
uses: ./.github/actions/build
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Volta
|
||||
uses: volta-cli/action@v4
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
@@ -152,9 +152,13 @@ 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@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
@@ -164,6 +168,7 @@ 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]
|
||||
@@ -190,7 +195,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
|
||||
@@ -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,20 +16,8 @@ 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';
|
||||
@@ -43,27 +31,35 @@ 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 {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
columns,
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
page,
|
||||
pageSize,
|
||||
records,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
isRemoving,
|
||||
} = useSelector((state: AppState) => state.blocklist);
|
||||
isFetching,
|
||||
isFetched,
|
||||
isLoading,
|
||||
error,
|
||||
page,
|
||||
goToPage,
|
||||
refetch,
|
||||
} = useBlocklist();
|
||||
|
||||
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||
useBlocklistOptions();
|
||||
|
||||
const filters = useFilters();
|
||||
const { isRemoving, removeBlocklistItems } = useRemoveBlocklistItems();
|
||||
|
||||
const customFilters = useSelector(createCustomFiltersSelector('blocklist'));
|
||||
const isClearingBlocklistExecuting = useSelector(
|
||||
@@ -82,28 +78,27 @@ function Blocklist() {
|
||||
return getSelectedIds(selectedState);
|
||||
}, [selectedState]);
|
||||
|
||||
const wasClearingBlocklistExecuting = usePrevious(
|
||||
isClearingBlocklistExecuting
|
||||
);
|
||||
|
||||
const handleSelectAllChange = useCallback(
|
||||
({ value }: CheckInputChanged) => {
|
||||
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
|
||||
setSelectState({
|
||||
type: value ? 'selectAll' : 'unselectAll',
|
||||
items: records,
|
||||
});
|
||||
},
|
||||
[items, setSelectState]
|
||||
[records, setSelectState]
|
||||
);
|
||||
|
||||
const handleSelectedChange = useCallback(
|
||||
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
||||
setSelectState({
|
||||
type: 'toggleSelected',
|
||||
items,
|
||||
items: records,
|
||||
id,
|
||||
isSelected: value,
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[items, setSelectState]
|
||||
[records, setSelectState]
|
||||
);
|
||||
|
||||
const handleRemoveSelectedPress = useCallback(() => {
|
||||
@@ -111,9 +106,9 @@ function Blocklist() {
|
||||
}, [setIsConfirmRemoveModalOpen]);
|
||||
|
||||
const handleRemoveSelectedConfirmed = useCallback(() => {
|
||||
dispatch(removeBlocklistItems({ ids: selectedIds }));
|
||||
removeBlocklistItems({ ids: selectedIds });
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
}, [selectedIds, setIsConfirmRemoveModalOpen, dispatch]);
|
||||
}, [selectedIds, setIsConfirmRemoveModalOpen, removeBlocklistItems]);
|
||||
|
||||
const handleConfirmRemoveModalClose = useCallback(() => {
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
@@ -124,66 +119,46 @@ function Blocklist() {
|
||||
}, [setIsConfirmClearModalOpen]);
|
||||
|
||||
const handleClearBlocklistConfirmed = useCallback(() => {
|
||||
dispatch(executeCommand({ name: commandNames.CLEAR_BLOCKLIST }));
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: commandNames.CLEAR_BLOCKLIST,
|
||||
commandFinished: () => {
|
||||
goToPage(1);
|
||||
},
|
||||
})
|
||||
);
|
||||
setIsConfirmClearModalOpen(false);
|
||||
}, [setIsConfirmClearModalOpen, dispatch]);
|
||||
}, [setIsConfirmClearModalOpen, goToPage, dispatch]);
|
||||
|
||||
const handleConfirmClearModalClose = useCallback(() => {
|
||||
setIsConfirmClearModalOpen(false);
|
||||
}, [setIsConfirmClearModalOpen]);
|
||||
|
||||
const {
|
||||
handleFirstPagePress,
|
||||
handlePreviousPagePress,
|
||||
handleNextPagePress,
|
||||
handleLastPagePress,
|
||||
handlePageSelect,
|
||||
} = usePaging({
|
||||
page,
|
||||
totalPages,
|
||||
gotoPage: gotoBlocklistPage,
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string | number) => {
|
||||
dispatch(setBlocklistFilter({ selectedFilterKey }));
|
||||
setBlocklistOption('selectedFilterKey', selectedFilterKey);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSortPress = useCallback(
|
||||
(sortKey: string) => {
|
||||
dispatch(setBlocklistSort({ sortKey }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
const handleSortPress = useCallback((sortKey: string) => {
|
||||
setBlocklistOption('sortKey', sortKey);
|
||||
}, []);
|
||||
|
||||
const handleTableOptionChange = useCallback(
|
||||
(payload: TableOptionsChangePayload) => {
|
||||
dispatch(setBlocklistTableOption(payload));
|
||||
setQueueOptions(payload);
|
||||
|
||||
if (payload.pageSize) {
|
||||
dispatch(gotoBlocklistPage({ page: 1 }));
|
||||
goToPage(1);
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
[goToPage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestCurrentPage) {
|
||||
dispatch(fetchBlocklist());
|
||||
} else {
|
||||
dispatch(gotoBlocklistPage({ page: 1 }));
|
||||
}
|
||||
|
||||
return () => {
|
||||
dispatch(clearBlocklist());
|
||||
};
|
||||
}, [requestCurrentPage, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const repopulate = () => {
|
||||
dispatch(fetchBlocklist());
|
||||
refetch();
|
||||
};
|
||||
|
||||
registerPagePopulator(repopulate);
|
||||
@@ -191,16 +166,10 @@ function Blocklist() {
|
||||
return () => {
|
||||
unregisterPagePopulator(repopulate);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasClearingBlocklistExecuting && !isClearingBlocklistExecuting) {
|
||||
dispatch(gotoBlocklistPage({ page: 1 }));
|
||||
}
|
||||
}, [isClearingBlocklistExecuting, wasClearingBlocklistExecuting, dispatch]);
|
||||
}, [refetch]);
|
||||
|
||||
return (
|
||||
<SelectProvider items={items}>
|
||||
<SelectProvider items={records}>
|
||||
<PageContent title={translate('Blocklist')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
@@ -215,7 +184,7 @@ function Blocklist() {
|
||||
<PageToolbarButton
|
||||
label={translate('Clear')}
|
||||
iconName={icons.CLEAR}
|
||||
isDisabled={!items.length}
|
||||
isDisabled={!records.length}
|
||||
isSpinning={isClearingBlocklistExecuting}
|
||||
onPress={handleClearBlocklistPress}
|
||||
/>
|
||||
@@ -245,13 +214,13 @@ function Blocklist() {
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody>
|
||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
||||
{isLoading && !isFetched ? <LoadingIndicator /> : null}
|
||||
|
||||
{!isFetching && !!error ? (
|
||||
{!isLoading && !!error ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{isPopulated && !error && !items.length ? (
|
||||
{isFetched && !error && !records.length ? (
|
||||
<Alert kind={kinds.INFO}>
|
||||
{selectedFilterKey === 'all'
|
||||
? translate('NoBlocklistItems')
|
||||
@@ -259,7 +228,7 @@ function Blocklist() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isPopulated && !error && !!items.length ? (
|
||||
{isFetched && !error && !!records.length ? (
|
||||
<div>
|
||||
<Table
|
||||
selectAll={true}
|
||||
@@ -274,7 +243,7 @@ function Blocklist() {
|
||||
onSortPress={handleSortPress}
|
||||
>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
{records.map((item) => {
|
||||
return (
|
||||
<BlocklistRow
|
||||
key={item.id}
|
||||
@@ -292,11 +261,7 @@ function Blocklist() {
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
isFetching={isFetching}
|
||||
onFirstPagePress={handleFirstPagePress}
|
||||
onPreviousPagePress={handlePreviousPagePress}
|
||||
onNextPagePress={handleNextPagePress}
|
||||
onLastPagePress={handleLastPagePress}
|
||||
onPageSelect={handlePageSelect}
|
||||
onPageSelect={goToPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -16,13 +16,19 @@ interface BlocklistDetailsModalProps {
|
||||
protocol: DownloadProtocol;
|
||||
indexer?: string;
|
||||
message?: string;
|
||||
source?: string;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function BlocklistDetailsModal(props: BlocklistDetailsModalProps) {
|
||||
const { isOpen, sourceTitle, protocol, indexer, message, onModalClose } =
|
||||
props;
|
||||
|
||||
function BlocklistDetailsModal({
|
||||
isOpen,
|
||||
sourceTitle,
|
||||
protocol,
|
||||
indexer,
|
||||
message,
|
||||
source,
|
||||
onModalClose,
|
||||
}: BlocklistDetailsModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
@@ -50,6 +56,9 @@ function BlocklistDetailsModal(props: BlocklistDetailsModalProps) {
|
||||
data={message}
|
||||
/>
|
||||
) : null}
|
||||
{source ? (
|
||||
<DescriptionListItem title={translate('Source')} data={source} />
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
</ModalBody>
|
||||
|
||||
|
||||
@@ -1,50 +1,26 @@
|
||||
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 { 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;
|
||||
}
|
||||
);
|
||||
}
|
||||
import { setBlocklistOption } from './blocklistOptionsStore';
|
||||
import useBlocklist, { FILTER_BUILDER } from './useBlocklist';
|
||||
|
||||
type BlocklistFilterModalProps = FilterModalProps<History>;
|
||||
|
||||
export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
|
||||
const sectionItems = useSelector(createBlocklistSelector());
|
||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
||||
const customFilterType = 'blocklist';
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { records } = useBlocklist();
|
||||
|
||||
const dispatchSetFilter = useCallback(
|
||||
(payload: unknown) => {
|
||||
dispatch(setBlocklistFilter(payload));
|
||||
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
|
||||
setBlocklistOption('selectedFilterKey', selectedFilterKey);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
{...props}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
customFilterType={customFilterType}
|
||||
sectionItems={records}
|
||||
filterBuilderProps={FILTER_BUILDER}
|
||||
customFilterType="blocklist"
|
||||
dispatchSetFilter={dispatchSetFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -12,11 +11,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 {
|
||||
@@ -25,25 +24,24 @@ interface BlocklistRowProps extends Blocklist {
|
||||
onSelectedChange: (options: SelectStateInputProps) => void;
|
||||
}
|
||||
|
||||
function BlocklistRow(props: BlocklistRowProps) {
|
||||
const {
|
||||
id,
|
||||
seriesId,
|
||||
sourceTitle,
|
||||
languages,
|
||||
quality,
|
||||
customFormats,
|
||||
date,
|
||||
protocol,
|
||||
indexer,
|
||||
message,
|
||||
isSelected,
|
||||
columns,
|
||||
onSelectedChange,
|
||||
} = props;
|
||||
|
||||
function BlocklistRow({
|
||||
id,
|
||||
seriesId,
|
||||
sourceTitle,
|
||||
languages,
|
||||
quality,
|
||||
customFormats,
|
||||
date,
|
||||
protocol,
|
||||
indexer,
|
||||
message,
|
||||
source,
|
||||
isSelected,
|
||||
columns,
|
||||
onSelectedChange,
|
||||
}: BlocklistRowProps) {
|
||||
const series = useSeries(seriesId);
|
||||
const dispatch = useDispatch();
|
||||
const { isRemoving, removeBlocklistItem } = useRemoveBlocklistItem(id);
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
|
||||
const handleDetailsPress = useCallback(() => {
|
||||
@@ -55,8 +53,8 @@ function BlocklistRow(props: BlocklistRowProps) {
|
||||
}, [setIsDetailsModalOpen]);
|
||||
|
||||
const handleRemovePress = useCallback(() => {
|
||||
dispatch(removeBlocklistItem({ id }));
|
||||
}, [id, dispatch]);
|
||||
removeBlocklistItem();
|
||||
}, [removeBlocklistItem]);
|
||||
|
||||
if (!series) {
|
||||
return null;
|
||||
@@ -139,6 +137,7 @@ function BlocklistRow(props: BlocklistRowProps) {
|
||||
title={translate('RemoveFromBlocklist')}
|
||||
name={icons.REMOVE}
|
||||
kind={kinds.DANGER}
|
||||
isSpinning={isRemoving}
|
||||
onPress={handleRemovePress}
|
||||
/>
|
||||
</TableRowCell>
|
||||
@@ -154,6 +153,7 @@ function BlocklistRow(props: BlocklistRowProps) {
|
||||
protocol={protocol}
|
||||
indexer={indexer}
|
||||
message={message}
|
||||
source={source}
|
||||
onModalClose={handleDetailsModalClose}
|
||||
/>
|
||||
</TableRow>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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;
|
||||
@@ -0,0 +1,116 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -174,7 +174,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
}
|
||||
|
||||
if (eventType === 'downloadFailed') {
|
||||
const { message, indexer } = data as DownloadFailedHistory;
|
||||
const { indexer, message, source } = data as DownloadFailedHistory;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
@@ -195,6 +195,10 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
{message ? (
|
||||
<DescriptionListItem title={translate('Message')} data={message} />
|
||||
) : null}
|
||||
|
||||
{source ? (
|
||||
<DescriptionListItem title={translate('Source')} data={source} />
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
@@ -9,6 +9,7 @@ import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { HistoryData, HistoryEventType } from 'typings/History';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { useMarkAsFailed } from '../useHistory';
|
||||
import HistoryDetails from './HistoryDetails';
|
||||
import styles from './HistoryDetailsModal.css';
|
||||
|
||||
@@ -33,26 +34,32 @@ function getHeaderTitle(eventType: HistoryEventType) {
|
||||
|
||||
interface HistoryDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
id: number;
|
||||
eventType: HistoryEventType;
|
||||
sourceTitle: string;
|
||||
data: HistoryData;
|
||||
downloadId?: string;
|
||||
isMarkingAsFailed: boolean;
|
||||
onMarkAsFailedPress: () => void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
||||
const {
|
||||
isOpen,
|
||||
eventType,
|
||||
sourceTitle,
|
||||
data,
|
||||
downloadId,
|
||||
isMarkingAsFailed = false,
|
||||
onMarkAsFailedPress,
|
||||
onModalClose,
|
||||
} = props;
|
||||
const { isOpen, id, eventType, sourceTitle, data, downloadId, onModalClose } =
|
||||
props;
|
||||
|
||||
const { markAsFailed, isMarkingAsFailed, markAsFailedError } =
|
||||
useMarkAsFailed(id);
|
||||
|
||||
const wasMarkingAsFailed = useRef(isMarkingAsFailed);
|
||||
|
||||
const handleMarkAsFailedPress = useCallback(() => {
|
||||
markAsFailed();
|
||||
}, [markAsFailed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasMarkingAsFailed && !isMarkingAsFailed && !markAsFailedError) {
|
||||
onModalClose();
|
||||
}
|
||||
}, [wasMarkingAsFailed, isMarkingAsFailed, markAsFailedError, onModalClose]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
@@ -74,7 +81,7 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
||||
className={styles.markAsFailedButton}
|
||||
kind={kinds.DANGER}
|
||||
isSpinning={isMarkingAsFailed}
|
||||
onPress={onMarkAsFailedPress}
|
||||
onPress={handleMarkAsFailedPress}
|
||||
>
|
||||
{translate('MarkAsFailed')}
|
||||
</SpinnerButton>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import {
|
||||
setQueueOption,
|
||||
setQueueOptions,
|
||||
} from 'Activity/Queue/queueOptionsStore';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
@@ -13,20 +16,11 @@ import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
import TablePager from 'Components/Table/TablePager';
|
||||
import usePaging from 'Components/Table/usePaging';
|
||||
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
|
||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
|
||||
import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
|
||||
import {
|
||||
clearHistory,
|
||||
fetchHistory,
|
||||
gotoHistoryPage,
|
||||
setHistoryFilter,
|
||||
setHistorySort,
|
||||
setHistoryTableOption,
|
||||
} from 'Store/Actions/historyActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import HistoryItem from 'typings/History';
|
||||
import { TableOptionsChangePayload } from 'typings/Table';
|
||||
@@ -37,100 +31,90 @@ import {
|
||||
} from 'Utilities/pagePopulator';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import HistoryFilterModal from './HistoryFilterModal';
|
||||
import { useHistoryOptions } from './historyOptionsStore';
|
||||
import HistoryRow from './HistoryRow';
|
||||
import useHistory, { useFilters } from './useHistory';
|
||||
|
||||
function History() {
|
||||
const requestCurrentPage = useCurrentPage();
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
columns,
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
page,
|
||||
pageSize,
|
||||
records,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
} = useSelector((state: AppState) => state.history);
|
||||
error,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isLoading,
|
||||
page,
|
||||
goToPage,
|
||||
refetch,
|
||||
} = useHistory();
|
||||
|
||||
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||
useHistoryOptions();
|
||||
|
||||
const filters = useFilters();
|
||||
|
||||
const requestCurrentPage = useCurrentPage();
|
||||
|
||||
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
|
||||
useSelector(createEpisodesFetchingSelector());
|
||||
const customFilters = useSelector(createCustomFiltersSelector('history'));
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const isFetchingAny = isFetching || isEpisodesFetching;
|
||||
const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length);
|
||||
const isFetchingAny = isLoading || isEpisodesFetching;
|
||||
const isAllPopulated = isFetched && (isEpisodesPopulated || !records.length);
|
||||
const hasError = error || episodesError;
|
||||
|
||||
const {
|
||||
handleFirstPagePress,
|
||||
handlePreviousPagePress,
|
||||
handleNextPagePress,
|
||||
handleLastPagePress,
|
||||
handlePageSelect,
|
||||
} = usePaging({
|
||||
page,
|
||||
totalPages,
|
||||
gotoPage: gotoHistoryPage,
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string | number) => {
|
||||
dispatch(setHistoryFilter({ selectedFilterKey }));
|
||||
setQueueOption('selectedFilterKey', selectedFilterKey);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSortPress = useCallback(
|
||||
(sortKey: string) => {
|
||||
dispatch(setHistorySort({ sortKey }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
const handleSortPress = useCallback((sortKey: string) => {
|
||||
setQueueOption('sortKey', sortKey);
|
||||
}, []);
|
||||
|
||||
const handleTableOptionChange = useCallback(
|
||||
(payload: TableOptionsChangePayload) => {
|
||||
dispatch(setHistoryTableOption(payload));
|
||||
setQueueOptions(payload);
|
||||
|
||||
if (payload.pageSize) {
|
||||
dispatch(gotoHistoryPage({ page: 1 }));
|
||||
goToPage(1);
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
[goToPage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestCurrentPage) {
|
||||
dispatch(fetchHistory());
|
||||
} else {
|
||||
dispatch(gotoHistoryPage({ page: 1 }));
|
||||
}
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
goToPage(1);
|
||||
refetch();
|
||||
}, [goToPage, refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dispatch(clearHistory());
|
||||
dispatch(clearEpisodes());
|
||||
dispatch(clearEpisodeFiles());
|
||||
};
|
||||
}, [requestCurrentPage, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const episodeIds = selectUniqueIds<HistoryItem, number>(items, 'episodeId');
|
||||
const episodeIds = selectUniqueIds<HistoryItem, number>(
|
||||
records,
|
||||
'episodeId'
|
||||
);
|
||||
|
||||
if (episodeIds.length) {
|
||||
dispatch(fetchEpisodes({ episodeIds }));
|
||||
} else {
|
||||
dispatch(clearEpisodes());
|
||||
}
|
||||
}, [items, dispatch]);
|
||||
}, [records, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const repopulate = () => {
|
||||
dispatch(fetchHistory());
|
||||
refetch();
|
||||
};
|
||||
|
||||
registerPagePopulator(repopulate);
|
||||
@@ -138,7 +122,7 @@ function History() {
|
||||
return () => {
|
||||
unregisterPagePopulator(repopulate);
|
||||
};
|
||||
}, [dispatch]);
|
||||
}, [refetch]);
|
||||
|
||||
return (
|
||||
<PageContent title={translate('History')}>
|
||||
@@ -148,7 +132,7 @@ function History() {
|
||||
label={translate('Refresh')}
|
||||
iconName={icons.REFRESH}
|
||||
isSpinning={isFetching}
|
||||
onPress={handleFirstPagePress}
|
||||
onPress={handleRefreshPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
@@ -186,12 +170,12 @@ function History() {
|
||||
// If history isPopulated and it's empty show no history found and don't
|
||||
// wait for the episodes to populate because they are never coming.
|
||||
|
||||
isPopulated && !hasError && !items.length ? (
|
||||
isFetched && !hasError && !records.length ? (
|
||||
<Alert kind={kinds.INFO}>{translate('NoHistoryFound')}</Alert>
|
||||
) : null
|
||||
}
|
||||
|
||||
{isAllPopulated && !hasError && items.length ? (
|
||||
{isAllPopulated && !hasError && records.length ? (
|
||||
<div>
|
||||
<Table
|
||||
columns={columns}
|
||||
@@ -202,7 +186,7 @@ function History() {
|
||||
onSortPress={handleSortPress}
|
||||
>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
{records.map((item) => {
|
||||
return (
|
||||
<HistoryRow key={item.id} columns={columns} {...item} />
|
||||
);
|
||||
@@ -215,11 +199,7 @@ function History() {
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
isFetching={isFetching}
|
||||
onFirstPagePress={handleFirstPagePress}
|
||||
onPreviousPagePress={handlePreviousPagePress}
|
||||
onNextPagePress={handleNextPagePress}
|
||||
onLastPagePress={handleLastPagePress}
|
||||
onPageSelect={handlePageSelect}
|
||||
onPageSelect={goToPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,48 +1,25 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
||||
import { setHistoryFilter } from 'Store/Actions/historyActions';
|
||||
|
||||
function createHistorySelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.history.items,
|
||||
(queueItems) => {
|
||||
return queueItems;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createFilterBuilderPropsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.history.filterBuilderProps,
|
||||
(filterBuilderProps) => {
|
||||
return filterBuilderProps;
|
||||
}
|
||||
);
|
||||
}
|
||||
import { setHistoryOption } from './historyOptionsStore';
|
||||
import useHistory, { FILTER_BUILDER } from './useHistory';
|
||||
|
||||
type HistoryFilterModalProps = FilterModalProps<History>;
|
||||
|
||||
export default function HistoryFilterModal(props: HistoryFilterModalProps) {
|
||||
const sectionItems = useSelector(createHistorySelector());
|
||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { records } = useHistory();
|
||||
|
||||
const dispatchSetFilter = useCallback(
|
||||
(payload: { selectedFilterKey: string | number }) => {
|
||||
dispatch(setHistoryFilter(payload));
|
||||
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
|
||||
setHistoryOption('selectedFilterKey', selectedFilterKey);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
{...props}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
sectionItems={records}
|
||||
filterBuilderProps={FILTER_BUILDER}
|
||||
customFilterType="history"
|
||||
dispatchSetFilter={dispatchSetFilter}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
@@ -13,13 +12,11 @@ import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
||||
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
|
||||
import useEpisode from 'Episode/useEpisode';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { icons, tooltipPositions } from 'Helpers/Props';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import { HistoryData, HistoryEventType } from 'typings/History';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
@@ -61,13 +58,9 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
date,
|
||||
data,
|
||||
downloadId,
|
||||
isMarkingAsFailed = false,
|
||||
markAsFailedError,
|
||||
columns,
|
||||
} = props;
|
||||
|
||||
const wasMarkingAsFailed = usePrevious(isMarkingAsFailed);
|
||||
const dispatch = useDispatch();
|
||||
const series = useSeries(seriesId);
|
||||
const episode = useEpisode(episodeId, 'episodes');
|
||||
|
||||
@@ -81,23 +74,6 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
setIsDetailsModalOpen(false);
|
||||
}, [setIsDetailsModalOpen]);
|
||||
|
||||
const handleMarkAsFailedPress = useCallback(() => {
|
||||
dispatch(markAsFailed({ id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasMarkingAsFailed && !isMarkingAsFailed && !markAsFailedError) {
|
||||
setIsDetailsModalOpen(false);
|
||||
dispatch(fetchHistory());
|
||||
}
|
||||
}, [
|
||||
wasMarkingAsFailed,
|
||||
isMarkingAsFailed,
|
||||
markAsFailedError,
|
||||
setIsDetailsModalOpen,
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
if (!series || !episode) {
|
||||
return null;
|
||||
}
|
||||
@@ -254,13 +230,12 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
})}
|
||||
|
||||
<HistoryDetailsModal
|
||||
id={id}
|
||||
isOpen={isDetailsModalOpen}
|
||||
eventType={eventType}
|
||||
sourceTitle={sourceTitle}
|
||||
data={data}
|
||||
downloadId={downloadId}
|
||||
isMarkingAsFailed={isMarkingAsFailed}
|
||||
onMarkAsFailedPress={handleMarkAsFailedPress}
|
||||
onModalClose={handleDetailsModalClose}
|
||||
/>
|
||||
</TableRow>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import {
|
||||
createOptionsStore,
|
||||
PageableOptions,
|
||||
} from 'Helpers/Hooks/useOptionsStore';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
export type HistoryOptions = PageableOptions;
|
||||
|
||||
const { useOptions, useOption, setOptions, setOption } =
|
||||
createOptionsStore<HistoryOptions>('history_options', () => {
|
||||
return {
|
||||
includeUnknownSeriesItems: true,
|
||||
pageSize: 20,
|
||||
selectedFilterKey: 'all',
|
||||
sortKey: 'time',
|
||||
sortDirection: 'descending',
|
||||
columns: [
|
||||
{
|
||||
name: 'eventType',
|
||||
label: '',
|
||||
columnLabel: () => translate('EventType'),
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
{
|
||||
name: 'series.sortTitle',
|
||||
label: () => translate('Series'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'episode',
|
||||
label: () => translate('Episode'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'episodes.title',
|
||||
label: () => translate('EpisodeTitle'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'customFormats',
|
||||
label: () => translate('Formats'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
label: () => translate('Date'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'downloadClient',
|
||||
label: () => translate('DownloadClient'),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'indexer',
|
||||
label: () => translate('Indexer'),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'releaseGroup',
|
||||
label: () => translate('ReleaseGroup'),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'sourceTitle',
|
||||
label: () => translate('SourceTitle'),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'customFormatScore',
|
||||
columnLabel: () => translate('CustomFormatScore'),
|
||||
label: React.createElement(Icon, {
|
||||
name: icons.SCORE,
|
||||
title: () => translate('CustomFormatScore'),
|
||||
}),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'details',
|
||||
label: '',
|
||||
columnLabel: () => translate('Details'),
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
export const useHistoryOptions = useOptions;
|
||||
export const setHistoryOptions = setOptions;
|
||||
export const useHistoryOption = useOption;
|
||||
export const setHistoryOption = setOption;
|
||||
@@ -0,0 +1,186 @@
|
||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
|
||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||
import usePage from 'Helpers/Hooks/usePage';
|
||||
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
||||
import { filterBuilderValueTypes } from 'Helpers/Props';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import History from 'typings/History';
|
||||
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { useHistoryOptions } from './historyOptionsStore';
|
||||
|
||||
export const FILTERS: Filter[] = [
|
||||
{
|
||||
key: 'all',
|
||||
label: () => translate('All'),
|
||||
filters: [],
|
||||
},
|
||||
{
|
||||
key: 'grabbed',
|
||||
label: () => translate('Grabbed'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '1',
|
||||
type: 'equal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'imported',
|
||||
label: () => translate('Imported'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '3',
|
||||
type: 'equal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'failed',
|
||||
label: () => translate('Failed'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '4',
|
||||
type: 'equal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'deleted',
|
||||
label: () => translate('Deleted'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '5',
|
||||
type: 'equal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'renamed',
|
||||
label: () => translate('Renamed'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '6',
|
||||
type: 'equal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'ignored',
|
||||
label: () => translate('Ignored'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '7',
|
||||
type: 'equal',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const FILTER_BUILDER: FilterBuilderProp<History>[] = [
|
||||
{
|
||||
name: 'eventType',
|
||||
label: () => translate('EventType'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE,
|
||||
},
|
||||
{
|
||||
name: 'seriesIds',
|
||||
label: () => translate('Series'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.SERIES,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.QUALITY,
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
type: 'contains',
|
||||
valueType: filterBuilderValueTypes.LANGUAGE,
|
||||
},
|
||||
];
|
||||
|
||||
const useHistory = () => {
|
||||
const { page, goToPage } = usePage('history');
|
||||
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
|
||||
useHistoryOptions();
|
||||
const customFilters = useSelector(
|
||||
createCustomFiltersSelector('history')
|
||||
) as CustomFilter[];
|
||||
|
||||
const filters = useMemo(() => {
|
||||
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
|
||||
}, [selectedFilterKey, customFilters]);
|
||||
|
||||
const { refetch, ...query } = usePagedApiQuery<History>({
|
||||
path: '/history',
|
||||
page,
|
||||
pageSize,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
queryOptions: {
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
});
|
||||
|
||||
const handleGoToPage = useCallback(
|
||||
(page: number) => {
|
||||
goToPage(page);
|
||||
},
|
||||
[goToPage]
|
||||
);
|
||||
|
||||
return {
|
||||
...query,
|
||||
goToPage: handleGoToPage,
|
||||
page,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
|
||||
export default useHistory;
|
||||
|
||||
export const useFilters = () => {
|
||||
return FILTERS;
|
||||
};
|
||||
|
||||
export const useMarkAsFailed = (id: number) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { mutate, isPending } = useApiMutation<unknown, void>({
|
||||
path: `/history/failed/${id}`,
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onMutate: () => {
|
||||
setError(null);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/history'] });
|
||||
},
|
||||
onError: () => {
|
||||
setError('Error marking history item as failed');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
markAsFailed: mutate,
|
||||
isMarkingAsFailed: isPending,
|
||||
markAsFailedError: error,
|
||||
};
|
||||
};
|
||||
@@ -54,17 +54,13 @@ import useQueue, {
|
||||
useRemoveQueueItems,
|
||||
} from './useQueue';
|
||||
|
||||
const DEFAULT_DATA = {
|
||||
records: [],
|
||||
totalPages: 0,
|
||||
totalRecords: 0,
|
||||
};
|
||||
|
||||
function Queue() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
data,
|
||||
records,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
error,
|
||||
isFetching,
|
||||
isFetched,
|
||||
@@ -74,8 +70,6 @@ function Queue() {
|
||||
refetch,
|
||||
} = useQueue();
|
||||
|
||||
const { records, totalPages = 0, totalRecords } = data ?? DEFAULT_DATA;
|
||||
|
||||
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||
useQueueOptions();
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@ import useQueue, { FILTER_BUILDER } from './useQueue';
|
||||
type QueueFilterModalProps = FilterModalProps<History>;
|
||||
|
||||
export default function QueueFilterModal(props: QueueFilterModalProps) {
|
||||
const { data } = useQueue();
|
||||
const customFilterType = 'queue';
|
||||
const { records } = useQueue();
|
||||
|
||||
const dispatchSetFilter = useCallback(
|
||||
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
|
||||
@@ -19,9 +18,9 @@ export default function QueueFilterModal(props: QueueFilterModalProps) {
|
||||
return (
|
||||
<FilterModal
|
||||
{...props}
|
||||
sectionItems={data?.records ?? []}
|
||||
sectionItems={records}
|
||||
filterBuilderProps={FILTER_BUILDER}
|
||||
customFilterType={customFilterType}
|
||||
customFilterType="queue"
|
||||
dispatchSetFilter={dispatchSetFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Column from 'Components/Table/Column';
|
||||
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
|
||||
import {
|
||||
createOptionsStore,
|
||||
PageableOptions,
|
||||
} from 'Helpers/Hooks/useOptionsStore';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface QueueRemovalOptions {
|
||||
@@ -11,13 +12,8 @@ interface QueueRemovalOptions {
|
||||
blocklistMethod: 'blocklistAndSearch' | 'blocklistOnly' | 'doNotBlocklist';
|
||||
}
|
||||
|
||||
export interface QueueOptions {
|
||||
export interface QueueOptions extends PageableOptions {
|
||||
includeUnknownSeriesItems: boolean;
|
||||
pageSize: number;
|
||||
selectedFilterKey: string | number;
|
||||
sortKey: string;
|
||||
sortDirection: SortDirection;
|
||||
columns: Column[];
|
||||
removalOptions: QueueRemovalOptions;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
|
||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||
@@ -90,16 +90,9 @@ const useQueue = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const handleGoToPage = useCallback(
|
||||
(page: number) => {
|
||||
goToPage(page);
|
||||
},
|
||||
[goToPage]
|
||||
);
|
||||
|
||||
return {
|
||||
...query,
|
||||
goToPage: handleGoToPage,
|
||||
goToPage,
|
||||
page,
|
||||
refetch,
|
||||
};
|
||||
|
||||
@@ -90,7 +90,6 @@ interface AppState {
|
||||
episodeHistory: HistoryAppState;
|
||||
episodes: EpisodesAppState;
|
||||
episodesSelection: EpisodesAppState;
|
||||
history: HistoryAppState;
|
||||
importSeries: ImportSeriesAppState;
|
||||
interactiveImport: InteractiveImportAppState;
|
||||
oAuth: OAuthAppState;
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface TagDetail extends ModelBase {
|
||||
indexerIds: number[];
|
||||
notificationIds: number[];
|
||||
restrictionIds: number[];
|
||||
excludedReleaseProfileIds: number[];
|
||||
seriesIds: number[];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -65,7 +66,7 @@ const componentMap: Record<InputType, ElementType> = {
|
||||
downloadClientSelect: DownloadClientSelectInput,
|
||||
dynamicSelect: ProviderDataSelectInput,
|
||||
file: TextInput,
|
||||
float: NumberInput,
|
||||
float: FloatInput,
|
||||
indexerFlagsSelect: IndexerFlagsSelectInput,
|
||||
indexerSelect: IndexerSelectInput,
|
||||
keyValueList: KeyValueListInput,
|
||||
@@ -110,7 +111,7 @@ type PickProps<V, C extends InputType> = C extends 'text'
|
||||
: C extends 'file'
|
||||
? TextInputProps
|
||||
: C extends 'float'
|
||||
? TextInputProps
|
||||
? FloatInputProps
|
||||
: C extends 'indexerFlagsSelect'
|
||||
? IndexerFlagsSelectInputProps
|
||||
: C extends 'indexerSelect'
|
||||
|
||||
@@ -24,13 +24,17 @@ 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: (input: InputChanged<number | null>) => void;
|
||||
onChange: (change: NumberInputChanged) => void;
|
||||
}
|
||||
|
||||
function NumberInput({
|
||||
@@ -50,11 +54,14 @@ function NumberInput({
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ name, value: newValue }: InputChanged<string>) => {
|
||||
setValue(newValue);
|
||||
const parsedValue = parseValue(newValue, isFloat, min, max);
|
||||
|
||||
setValue(parsedValue == null ? '' : parsedValue.toString());
|
||||
|
||||
onChange({
|
||||
name,
|
||||
value: parseValue(newValue, isFloat, min, max),
|
||||
value: parsedValue,
|
||||
isFloat,
|
||||
});
|
||||
},
|
||||
[isFloat, min, max, onChange, setValue]
|
||||
@@ -75,6 +82,7 @@ function NumberInput({
|
||||
onChange({
|
||||
name,
|
||||
value: parsedValue,
|
||||
isFloat,
|
||||
});
|
||||
|
||||
isFocused.current = false;
|
||||
|
||||
@@ -5,14 +5,18 @@ 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 } from './TagInput';
|
||||
import TagInput, { TagBase, TagInputProps } from './TagInput';
|
||||
|
||||
interface SeriesTag extends TagBase {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SeriesTagInputProps<V> {
|
||||
export interface SeriesTagInputProps<V>
|
||||
extends Omit<
|
||||
TagInputProps<SeriesTag>,
|
||||
'tags' | 'tagList' | 'onTagAdd' | 'onTagDelete' | 'onChange'
|
||||
> {
|
||||
name: string;
|
||||
value: V;
|
||||
onChange: (change: InputChanged<V>) => void;
|
||||
@@ -63,6 +67,7 @@ export default function SeriesTagInput<V extends number | number[]>({
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
...otherProps
|
||||
}: SeriesTagInputProps<V>) {
|
||||
const dispatch = useDispatch();
|
||||
const isArray = Array.isArray(value);
|
||||
@@ -135,6 +140,7 @@ export default function SeriesTagInput<V extends number | number[]>({
|
||||
|
||||
return (
|
||||
<TagInput
|
||||
{...otherProps}
|
||||
name={name}
|
||||
tags={tags}
|
||||
tagList={tagList}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
.modal {
|
||||
position: relative;
|
||||
display: flex;
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
border-radius: 6px;
|
||||
opacity: 1;
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
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 from './Label';
|
||||
import Label, { LabelProps } from './Label';
|
||||
import styles from './TagList.css';
|
||||
|
||||
interface TagListProps {
|
||||
tags: number[];
|
||||
tagList: Tag[];
|
||||
kind?: Extract<Kind, LabelProps['kind']>;
|
||||
}
|
||||
|
||||
function TagList({ tags, tagList }: TagListProps) {
|
||||
export default function TagList({
|
||||
tags,
|
||||
tagList,
|
||||
kind = kinds.INFO,
|
||||
}: TagListProps) {
|
||||
const sortedTags = tags
|
||||
.map((tagId) => tagList.find((tag) => tag.id === tagId))
|
||||
.filter((tag) => !!tag)
|
||||
@@ -20,7 +26,7 @@ function TagList({ tags, tagList }: TagListProps) {
|
||||
<div className={styles.tags}>
|
||||
{sortedTags.map((tag) => {
|
||||
return (
|
||||
<Label key={tag.id} kind={kinds.INFO}>
|
||||
<Label key={tag.id} kind={kind}>
|
||||
{tag.label}
|
||||
</Label>
|
||||
);
|
||||
@@ -28,5 +34,3 @@ function TagList({ tags, tagList }: TagListProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagList;
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function AuthenticationRequiredModalContent() {
|
||||
dispatch(fetchGeneralSettings());
|
||||
|
||||
return () => {
|
||||
dispatch(clearPendingChanges());
|
||||
dispatch(clearPendingChanges({ section: `settings.${SECTION}` }));
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
'X-Sonarr-Client': 'Sonarr',
|
||||
},
|
||||
};
|
||||
}, [options]);
|
||||
|
||||
@@ -26,6 +26,7 @@ const useApiQuery = <T>(options: QueryOptions<T>) => {
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
'X-Sonarr-Client': 'Sonarr',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
|
||||
@@ -11,6 +12,14 @@ 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];
|
||||
|
||||
@@ -3,12 +3,16 @@ 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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ interface PagedQueryResponse<T> {
|
||||
records: T[];
|
||||
}
|
||||
|
||||
const DEFAULT_RECORDS: never[] = [];
|
||||
|
||||
const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
|
||||
const { requestOptions, queryKey } = useMemo(() => {
|
||||
const {
|
||||
@@ -64,12 +66,13 @@ const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
'X-Sonarr-Client': 'Sonarr',
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [options]);
|
||||
|
||||
return useQuery({
|
||||
const { data, ...query } = useQuery({
|
||||
...options.queryOptions,
|
||||
queryKey,
|
||||
queryFn: async ({ signal }) => {
|
||||
@@ -87,6 +90,13 @@ const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...query,
|
||||
records: data?.records ?? DEFAULT_RECORDS,
|
||||
totalRecords: data?.totalRecords ?? 0,
|
||||
totalPages: data?.totalPages ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePagedApiQuery;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
@@ -6,14 +7,42 @@ import themes from 'Styles/Themes';
|
||||
function createThemeSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
|
||||
(theme) => {
|
||||
return theme;
|
||||
}
|
||||
(theme) => theme
|
||||
);
|
||||
}
|
||||
|
||||
const useTheme = () => {
|
||||
return useSelector(createThemeSelector());
|
||||
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;
|
||||
};
|
||||
|
||||
export default useTheme;
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
|
||||
.title {
|
||||
overflow: auto;
|
||||
max-height: calc(3 * 50px);
|
||||
max-height: calc(3 * 60px);
|
||||
text-wrap: balance;
|
||||
font-weight: 300;
|
||||
font-size: 50px;
|
||||
line-height: 50px;
|
||||
line-height: 60px;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
}
|
||||
|
||||
@@ -82,6 +83,7 @@
|
||||
|
||||
.alternateTitlesIconContainer {
|
||||
align-self: flex-end;
|
||||
margin-bottom: 10px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,10 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
|
||||
<div className={styles.poster}>
|
||||
<div className={styles.posterContainer}>
|
||||
{isSelectMode ? (
|
||||
<SeriesIndexPosterSelect seriesId={seriesId} />
|
||||
<SeriesIndexPosterSelect
|
||||
seriesId={seriesId}
|
||||
titleSlug={titleSlug}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{status === 'ended' ? (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { SyntheticEvent, useCallback, useState } from 'react';
|
||||
import React, { 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';
|
||||
@@ -123,31 +122,8 @@ 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`,
|
||||
@@ -156,7 +132,9 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.posterContainer} title={title}>
|
||||
{isSelectMode ? <SeriesIndexPosterSelect seriesId={seriesId} /> : null}
|
||||
{isSelectMode ? (
|
||||
<SeriesIndexPosterSelect seriesId={seriesId} titleSlug={titleSlug} />
|
||||
) : null}
|
||||
|
||||
<Label className={styles.controls}>
|
||||
<SpinnerIconButton
|
||||
@@ -199,7 +177,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Link className={styles.link} style={elementStyle} {...linkProps}>
|
||||
<Link className={styles.link} style={elementStyle} to={link}>
|
||||
<SeriesPoster
|
||||
style={elementStyle}
|
||||
images={images}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.checkContainer {
|
||||
|
||||
@@ -7,15 +7,23 @@ import styles from './SeriesIndexPosterSelect.css';
|
||||
|
||||
interface SeriesIndexPosterSelectProps {
|
||||
seriesId: number;
|
||||
titleSlug: string;
|
||||
}
|
||||
|
||||
function SeriesIndexPosterSelect(props: SeriesIndexPosterSelectProps) {
|
||||
const { seriesId } = props;
|
||||
function SeriesIndexPosterSelect({
|
||||
seriesId,
|
||||
titleSlug,
|
||||
}: SeriesIndexPosterSelectProps) {
|
||||
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({
|
||||
@@ -25,7 +33,7 @@ function SeriesIndexPosterSelect(props: SeriesIndexPosterSelectProps) {
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[seriesId, isSelected, selectDispatch]
|
||||
[seriesId, titleSlug, isSelected, selectDispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -156,6 +156,7 @@ function GeneralSettings() {
|
||||
enableSsl={settings.enableSsl}
|
||||
sslPort={settings.sslPort}
|
||||
sslCertPath={settings.sslCertPath}
|
||||
sslKeyPath={settings.sslKeyPath}
|
||||
sslCertPassword={settings.sslCertPassword}
|
||||
launchBrowser={settings.launchBrowser}
|
||||
onInputChange={handleInputChange}
|
||||
|
||||
@@ -19,6 +19,7 @@ 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'];
|
||||
@@ -34,6 +35,7 @@ function HostSettings({
|
||||
enableSsl,
|
||||
sslPort,
|
||||
sslCertPath,
|
||||
sslKeyPath,
|
||||
sslCertPassword,
|
||||
launchBrowser,
|
||||
onInputChange,
|
||||
@@ -142,33 +144,46 @@ 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>
|
||||
) : null}
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="sslCertPath"
|
||||
helpText={translate('SslCertPathHelpText')}
|
||||
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
|
||||
onChange={onInputChange}
|
||||
{...sslCertPath}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{enableSsl.value ? (
|
||||
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
|
||||
<FormLabel>{translate('SslCertPassword')}</FormLabel>
|
||||
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
|
||||
<FormLabel>{translate('SslKeyPath')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PASSWORD}
|
||||
name="sslCertPassword"
|
||||
helpText={translate('SslCertPasswordHelpText')}
|
||||
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
|
||||
onChange={onInputChange}
|
||||
{...sslCertPassword}
|
||||
/>
|
||||
</FormGroup>
|
||||
<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>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isWindowsService ? null : (
|
||||
|
||||
@@ -116,6 +116,27 @@ 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();
|
||||
@@ -379,6 +400,82 @@ 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}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ const newReleaseProfile: ReleaseProfile = {
|
||||
required: [],
|
||||
ignored: [],
|
||||
tags: [],
|
||||
excludedTags: [],
|
||||
indexerId: 0,
|
||||
};
|
||||
|
||||
@@ -76,7 +77,8 @@ function EditReleaseProfileModalContent({
|
||||
const { item, isFetching, isSaving, error, saveError, ...otherProps } =
|
||||
useSelector(createReleaseProfileSelector(id));
|
||||
|
||||
const { name, enabled, required, ignored, tags, indexerId } = item;
|
||||
const { name, enabled, required, ignored, tags, excludedTags, indexerId } =
|
||||
item;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const previousIsSaving = usePrevious(isSaving);
|
||||
@@ -202,6 +204,19 @@ 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>
|
||||
|
||||
@@ -28,6 +28,7 @@ function ReleaseProfileItem(props: ReleaseProfileProps) {
|
||||
required = [],
|
||||
ignored = [],
|
||||
tags,
|
||||
excludedTags,
|
||||
indexerId = 0,
|
||||
tagList,
|
||||
indexerList,
|
||||
@@ -92,6 +93,8 @@ 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}>
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface TagDetailsModalContentProps {
|
||||
delayProfileIds: number[];
|
||||
importListIds: number[];
|
||||
notificationIds: number[];
|
||||
restrictionIds: number[];
|
||||
releaseProfileIds: number[];
|
||||
indexerIds: number[];
|
||||
downloadClientIds: number[];
|
||||
autoTagIds: number[];
|
||||
@@ -76,7 +76,7 @@ function TagDetailsModalContent({
|
||||
delayProfileIds = [],
|
||||
importListIds = [],
|
||||
notificationIds = [],
|
||||
restrictionIds = [],
|
||||
releaseProfileIds = [],
|
||||
indexerIds = [],
|
||||
downloadClientIds = [],
|
||||
autoTagIds = [],
|
||||
@@ -109,7 +109,7 @@ function TagDetailsModalContent({
|
||||
|
||||
const releaseProfiles = useSelector(
|
||||
createMatchingItemSelector(
|
||||
restrictionIds,
|
||||
releaseProfileIds,
|
||||
(state: AppState) => state.settings.releaseProfiles.items
|
||||
)
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ function Tag({ id, label }: TagProps) {
|
||||
importListIds = [],
|
||||
notificationIds = [],
|
||||
restrictionIds = [],
|
||||
excludedReleaseProfileIds = [],
|
||||
indexerIds = [],
|
||||
downloadClientIds = [],
|
||||
autoTagIds = [],
|
||||
@@ -35,12 +36,17 @@ 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);
|
||||
}, []);
|
||||
@@ -95,7 +101,7 @@ function Tag({ id, label }: TagProps) {
|
||||
<TagInUse
|
||||
label={translate('ReleaseProfile')}
|
||||
labelPlural={translate('ReleaseProfiles')}
|
||||
count={restrictionIds.length}
|
||||
count={mergedReleaseProfileIds.length}
|
||||
/>
|
||||
|
||||
<TagInUse
|
||||
@@ -126,7 +132,7 @@ function Tag({ id, label }: TagProps) {
|
||||
delayProfileIds={delayProfileIds}
|
||||
importListIds={importListIds}
|
||||
notificationIds={notificationIds}
|
||||
restrictionIds={restrictionIds}
|
||||
releaseProfileIds={mergedReleaseProfileIds}
|
||||
indexerIds={indexerIds}
|
||||
downloadClientIds={downloadClientIds}
|
||||
autoTagIds={autoTagIds}
|
||||
|
||||
@@ -5,7 +5,7 @@ import updateSectionState from 'Utilities/State/updateSectionState';
|
||||
function createSetSettingValueReducer(section) {
|
||||
return (state, { payload }) => {
|
||||
if (section === payload.section) {
|
||||
const { name, value } = payload;
|
||||
const { name, value, isFloat } = payload;
|
||||
const newState = getSectionState(state, section);
|
||||
newState.pendingChanges = Object.assign({}, newState.pendingChanges);
|
||||
|
||||
@@ -15,7 +15,12 @@ function createSetSettingValueReducer(section) {
|
||||
let parsedValue = null;
|
||||
|
||||
if (_.isNumber(currentValue) && value != null) {
|
||||
parsedValue = parseInt(value);
|
||||
// Use isFloat property to determine parsing method
|
||||
if (isFloat) {
|
||||
parsedValue = parseFloat(value);
|
||||
} else {
|
||||
parsedValue = parseInt(value);
|
||||
}
|
||||
} else {
|
||||
parsedValue = value;
|
||||
}
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
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);
|
||||
@@ -1,327 +0,0 @@
|
||||
import React from 'react';
|
||||
import { createAction } from 'redux-actions';
|
||||
import Icon from 'Components/Icon';
|
||||
import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, icons, sortDirections } from 'Helpers/Props';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import serverSideCollectionHandlers from 'Utilities/State/serverSideCollectionHandlers';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { updateItem } from './baseActions';
|
||||
import createHandleActions from './Creators/createHandleActions';
|
||||
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
|
||||
import createClearReducer from './Creators/Reducers/createClearReducer';
|
||||
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
|
||||
|
||||
//
|
||||
// Variables
|
||||
|
||||
export const section = 'history';
|
||||
|
||||
//
|
||||
// State
|
||||
|
||||
export const defaultState = {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
pageSize: 20,
|
||||
sortKey: 'date',
|
||||
sortDirection: sortDirections.DESCENDING,
|
||||
items: [],
|
||||
|
||||
columns: [
|
||||
{
|
||||
name: 'eventType',
|
||||
columnLabel: () => translate('EventType'),
|
||||
isVisible: true,
|
||||
isModifiable: false
|
||||
},
|
||||
{
|
||||
name: 'series.sortTitle',
|
||||
label: () => translate('Series'),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'episode',
|
||||
label: () => translate('Episode'),
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'episodes.title',
|
||||
label: () => translate('EpisodeTitle'),
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'customFormats',
|
||||
label: () => translate('Formats'),
|
||||
isSortable: false,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
label: () => translate('Date'),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'downloadClient',
|
||||
label: () => translate('DownloadClient'),
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'indexer',
|
||||
label: () => translate('Indexer'),
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'releaseGroup',
|
||||
label: () => translate('ReleaseGroup'),
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'sourceTitle',
|
||||
label: () => translate('SourceTitle'),
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'customFormatScore',
|
||||
columnLabel: () => translate('CustomFormatScore'),
|
||||
label: React.createElement(Icon, {
|
||||
name: icons.SCORE,
|
||||
title: () => translate('CustomFormatScore')
|
||||
}),
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'details',
|
||||
columnLabel: () => translate('Details'),
|
||||
isVisible: true,
|
||||
isModifiable: false
|
||||
}
|
||||
],
|
||||
|
||||
selectedFilterKey: 'all',
|
||||
|
||||
filters: [
|
||||
{
|
||||
key: 'all',
|
||||
label: () => translate('All'),
|
||||
filters: []
|
||||
},
|
||||
{
|
||||
key: 'grabbed',
|
||||
label: () => translate('Grabbed'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '1',
|
||||
type: filterTypes.EQUAL
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'imported',
|
||||
label: () => translate('Imported'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '3',
|
||||
type: filterTypes.EQUAL
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'failed',
|
||||
label: () => translate('Failed'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '4',
|
||||
type: filterTypes.EQUAL
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'deleted',
|
||||
label: () => translate('Deleted'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '5',
|
||||
type: filterTypes.EQUAL
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'renamed',
|
||||
label: () => translate('Renamed'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '6',
|
||||
type: filterTypes.EQUAL
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'ignored',
|
||||
label: () => translate('Ignored'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '7',
|
||||
type: filterTypes.EQUAL
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
filterBuilderProps: [
|
||||
{
|
||||
name: 'eventType',
|
||||
label: () => translate('EventType'),
|
||||
type: filterBuilderTypes.EQUAL,
|
||||
valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE
|
||||
},
|
||||
{
|
||||
name: 'seriesIds',
|
||||
label: () => translate('Series'),
|
||||
type: filterBuilderTypes.EQUAL,
|
||||
valueType: filterBuilderValueTypes.SERIES
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
type: filterBuilderTypes.EQUAL,
|
||||
valueType: filterBuilderValueTypes.QUALITY
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
type: filterBuilderTypes.CONTAINS,
|
||||
valueType: filterBuilderValueTypes.LANGUAGE
|
||||
}
|
||||
]
|
||||
|
||||
};
|
||||
|
||||
export const persistState = [
|
||||
'history.pageSize',
|
||||
'history.sortKey',
|
||||
'history.sortDirection',
|
||||
'history.selectedFilterKey',
|
||||
'history.columns'
|
||||
];
|
||||
|
||||
//
|
||||
// Actions Types
|
||||
|
||||
export const FETCH_HISTORY = 'history/fetchHistory';
|
||||
export const GOTO_FIRST_HISTORY_PAGE = 'history/gotoHistoryFirstPage';
|
||||
export const GOTO_PREVIOUS_HISTORY_PAGE = 'history/gotoHistoryPreviousPage';
|
||||
export const GOTO_NEXT_HISTORY_PAGE = 'history/gotoHistoryNextPage';
|
||||
export const GOTO_LAST_HISTORY_PAGE = 'history/gotoHistoryLastPage';
|
||||
export const GOTO_HISTORY_PAGE = 'history/gotoHistoryPage';
|
||||
export const SET_HISTORY_SORT = 'history/setHistorySort';
|
||||
export const SET_HISTORY_FILTER = 'history/setHistoryFilter';
|
||||
export const SET_HISTORY_TABLE_OPTION = 'history/setHistoryTableOption';
|
||||
export const CLEAR_HISTORY = 'history/clearHistory';
|
||||
export const MARK_AS_FAILED = 'history/markAsFailed';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
||||
export const fetchHistory = createThunk(FETCH_HISTORY);
|
||||
export const gotoHistoryFirstPage = createThunk(GOTO_FIRST_HISTORY_PAGE);
|
||||
export const gotoHistoryPreviousPage = createThunk(GOTO_PREVIOUS_HISTORY_PAGE);
|
||||
export const gotoHistoryNextPage = createThunk(GOTO_NEXT_HISTORY_PAGE);
|
||||
export const gotoHistoryLastPage = createThunk(GOTO_LAST_HISTORY_PAGE);
|
||||
export const gotoHistoryPage = createThunk(GOTO_HISTORY_PAGE);
|
||||
export const setHistorySort = createThunk(SET_HISTORY_SORT);
|
||||
export const setHistoryFilter = createThunk(SET_HISTORY_FILTER);
|
||||
export const setHistoryTableOption = createAction(SET_HISTORY_TABLE_OPTION);
|
||||
export const clearHistory = createAction(CLEAR_HISTORY);
|
||||
export const markAsFailed = createThunk(MARK_AS_FAILED);
|
||||
|
||||
//
|
||||
// Action Handlers
|
||||
|
||||
export const actionHandlers = handleThunks({
|
||||
...createServerSideCollectionHandlers(
|
||||
section,
|
||||
'/history',
|
||||
fetchHistory,
|
||||
{
|
||||
[serverSideCollectionHandlers.FETCH]: FETCH_HISTORY,
|
||||
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_HISTORY_PAGE,
|
||||
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_HISTORY_PAGE,
|
||||
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_HISTORY_PAGE,
|
||||
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_HISTORY_PAGE,
|
||||
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_HISTORY_PAGE,
|
||||
[serverSideCollectionHandlers.SORT]: SET_HISTORY_SORT,
|
||||
[serverSideCollectionHandlers.FILTER]: SET_HISTORY_FILTER
|
||||
}),
|
||||
|
||||
[MARK_AS_FAILED]: function(getState, payload, dispatch) {
|
||||
const id = payload.id;
|
||||
|
||||
dispatch(updateItem({
|
||||
section,
|
||||
id,
|
||||
isMarkingAsFailed: true
|
||||
}));
|
||||
|
||||
const promise = createAjaxRequest({
|
||||
url: `/history/failed/${id}`,
|
||||
method: 'POST',
|
||||
dataType: 'json'
|
||||
}).request;
|
||||
|
||||
promise.done(() => {
|
||||
dispatch(updateItem({
|
||||
section,
|
||||
id,
|
||||
isMarkingAsFailed: false,
|
||||
markAsFailedError: null
|
||||
}));
|
||||
});
|
||||
|
||||
promise.fail((xhr) => {
|
||||
dispatch(updateItem({
|
||||
section,
|
||||
id,
|
||||
isMarkingAsFailed: false,
|
||||
markAsFailedError: xhr
|
||||
}));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
//
|
||||
// Reducers
|
||||
|
||||
export const reducers = createHandleActions({
|
||||
|
||||
[SET_HISTORY_TABLE_OPTION]: createSetTableOptionReducer(section),
|
||||
|
||||
[CLEAR_HISTORY]: createClearReducer(section, {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
items: [],
|
||||
totalPages: 0,
|
||||
totalRecords: 0
|
||||
})
|
||||
|
||||
}, defaultState, section);
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -8,7 +7,6 @@ import * as episodes from './episodeActions';
|
||||
import * as episodeFiles from './episodeFileActions';
|
||||
import * as episodeHistory from './episodeHistoryActions';
|
||||
import * as episodeSelection from './episodeSelectionActions';
|
||||
import * as history from './historyActions';
|
||||
import * as importSeries from './importSeriesActions';
|
||||
import * as interactiveImportActions from './interactiveImportActions';
|
||||
import * as oAuth from './oAuthActions';
|
||||
@@ -28,7 +26,6 @@ import * as wanted from './wantedActions';
|
||||
|
||||
export default [
|
||||
app,
|
||||
blocklist,
|
||||
calendar,
|
||||
captcha,
|
||||
commands,
|
||||
@@ -37,7 +34,6 @@ export default [
|
||||
episodeFiles,
|
||||
episodeHistory,
|
||||
episodeSelection,
|
||||
history,
|
||||
importSeries,
|
||||
interactiveImportActions,
|
||||
oAuth,
|
||||
|
||||
@@ -28,10 +28,17 @@ import useEvents, { useFilters } from './useEvents';
|
||||
|
||||
function LogsTable() {
|
||||
const dispatch = useDispatch();
|
||||
const { data, error, isFetching, isFetched, isLoading, page, goToPage } =
|
||||
useEvents();
|
||||
|
||||
const { records = [], totalPages = 0, totalRecords } = data ?? {};
|
||||
const {
|
||||
records,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
error,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isLoading,
|
||||
page,
|
||||
goToPage,
|
||||
} = useEvents();
|
||||
|
||||
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||
useEventOptions();
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import Column from 'Components/Table/Column';
|
||||
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import {
|
||||
createOptionsStore,
|
||||
PageableOptions,
|
||||
} from 'Helpers/Hooks/useOptionsStore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
export interface EventOptions {
|
||||
pageSize: number;
|
||||
selectedFilterKey: string | number;
|
||||
sortKey: string;
|
||||
sortDirection: SortDirection;
|
||||
columns: Column[];
|
||||
}
|
||||
export type EventOptions = PageableOptions;
|
||||
|
||||
const { useOptions, setOptions, setOption } = createOptionsStore<EventOptions>(
|
||||
'event_options',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { keepPreviousData } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Filter } from 'App/State/AppState';
|
||||
import usePage from 'Helpers/Hooks/usePage';
|
||||
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
||||
@@ -69,17 +69,9 @@ const useEvents = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const handleGoToPage = useCallback(
|
||||
(page: number) => {
|
||||
goToPage(page);
|
||||
refetch();
|
||||
},
|
||||
[goToPage, refetch]
|
||||
);
|
||||
|
||||
return {
|
||||
...query,
|
||||
goToPage: handleGoToPage,
|
||||
goToPage,
|
||||
page,
|
||||
refetch,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ interface Blocklist extends ModelBase {
|
||||
seriesId?: number;
|
||||
indexer?: string;
|
||||
message?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export default Blocklist;
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface GrabbedHistoryData {
|
||||
export interface DownloadFailedHistory {
|
||||
message: string;
|
||||
indexer?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface DownloadFolderImportedHistory {
|
||||
|
||||
@@ -23,6 +23,7 @@ export default interface General {
|
||||
branch: string;
|
||||
apiKey: string;
|
||||
sslCertPath: string;
|
||||
sslKeyPath: string;
|
||||
sslCertPassword: string;
|
||||
urlBase: string;
|
||||
instanceName: string;
|
||||
|
||||
@@ -20,4 +20,6 @@ export default interface MediaManagement {
|
||||
extraFileExtensions: string;
|
||||
userRejectedExtensions: string;
|
||||
enableMediaInfo: boolean;
|
||||
seasonPackUpgrade: string;
|
||||
seasonPackUpgradeThreshold: number;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ interface ReleaseProfile extends ModelBase {
|
||||
ignored: string[];
|
||||
indexerId: number;
|
||||
tags: number[];
|
||||
excludedTags: number[];
|
||||
}
|
||||
|
||||
export default ReleaseProfile;
|
||||
|
||||
@@ -84,8 +84,9 @@
|
||||
|
||||
<Deterministic Condition="$(AssemblyVersion.EndsWith('*'))">False</Deterministic>
|
||||
|
||||
<PathMap>$(MSBuildProjectDirectory)=./$(MSBuildProjectName)/</PathMap>
|
||||
<PathMap>$(MSBuildThisFileDirectory)=./</PathMap>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Set the AssemblyConfiguration attribute for projects -->
|
||||
<ItemGroup Condition="'$(SonarrProject)'=='true'">
|
||||
<AssemblyAttribute Include="System.Reflection.AssemblyConfigurationAttribute">
|
||||
@@ -122,14 +123,11 @@
|
||||
|
||||
<!-- 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="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" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="5.1.0" />
|
||||
<PackageReference Include="NunitXml.TestLogger" Version="3.1.20" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(SonarrProject)'=='true' and '$(EnableAnalyzers)'=='false'">
|
||||
|
||||
+1
-4
@@ -5,9 +5,6 @@
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -222,7 +222,7 @@ namespace NzbDrone.Common.EnvironmentInfo
|
||||
|
||||
private void RemovePidFile()
|
||||
{
|
||||
if (OsInfo.IsNotWindows)
|
||||
if (OsInfo.IsNotWindows && _diskProvider.FolderExists(_appFolderInfo.AppDataFolder))
|
||||
{
|
||||
_diskProvider.DeleteFile(Path.Combine(_appFolderInfo.AppDataFolder, "sonarr.pid"));
|
||||
}
|
||||
|
||||
@@ -27,15 +27,17 @@ namespace NzbDrone.Common.EnvironmentInfo
|
||||
_dataSpecialFolder = Environment.SpecialFolder.ApplicationData;
|
||||
}
|
||||
|
||||
if (startupContext.Args.ContainsKey(StartupContext.APPDATA))
|
||||
if (startupContext.Args.TryGetValue(StartupContext.APPDATA, out var argsAppDataFolder))
|
||||
{
|
||||
AppDataFolder = startupContext.Args[StartupContext.APPDATA];
|
||||
AppDataFolder = argsAppDataFolder;
|
||||
Logger.Info("Data directory is being overridden to [{0}]", AppDataFolder);
|
||||
}
|
||||
else
|
||||
{
|
||||
AppDataFolder = Path.Combine(Environment.GetFolderPath(_dataSpecialFolder, Environment.SpecialFolderOption.DoNotVerify), "Sonarr");
|
||||
LegacyAppDataFolder = Path.Combine(Environment.GetFolderPath(_dataSpecialFolder, Environment.SpecialFolderOption.DoNotVerify), "NzbDrone");
|
||||
LegacyAppDataFolder = OsInfo.IsOsx
|
||||
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.DoNotVerify), ".config", "NzbDrone")
|
||||
: Path.Combine(Environment.GetFolderPath(_dataSpecialFolder, Environment.SpecialFolderOption.DoNotVerify), "NzbDrone");
|
||||
}
|
||||
|
||||
StartUpFolder = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory.FullName;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
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"))
|
||||
@@ -11,5 +16,17 @@ 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@ 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; }
|
||||
}
|
||||
|
||||
@@ -15,13 +15,14 @@
|
||||
<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" />
|
||||
|
||||
@@ -85,6 +85,10 @@ 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;
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -437,5 +438,102 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,5 +124,34 @@
|
||||
<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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_bypass_proxy()
|
||||
[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)
|
||||
{
|
||||
var settings = GetProxySettings();
|
||||
|
||||
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();
|
||||
Subject.ShouldProxyBeBypassed(settings, new HttpUri(url)).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_bypass_proxy()
|
||||
[TestCase("http://bing.com/get")]
|
||||
[TestCase("http://172.3.0.1:8989/api/v3/indexer/schema")]
|
||||
public void should_not_bypass_proxy(string url)
|
||||
{
|
||||
var settings = GetProxySettings();
|
||||
|
||||
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();
|
||||
Subject.ShouldProxyBeBypassed(settings, new HttpUri(url)).Should().BeFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,8 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
|
||||
[TestCase("nuked=0 attribute")]
|
||||
[TestCase("prematch=1 and nuked=1 attributes", IndexerFlags.Scene, IndexerFlags.Nuked)]
|
||||
[TestCase("haspretime=0 and nuked=0 attributes")]
|
||||
[TestCase("subs=eng", IndexerFlags.Subtitles)]
|
||||
[TestCase("subs=''")]
|
||||
public async Task should_parse_indexer_flags(string releaseGuid, params IndexerFlags[] indexerFlags)
|
||||
{
|
||||
var feed = ReadAllText(@"Files/Indexers/Newznab/newznab_indexerflags.xml");
|
||||
|
||||
@@ -167,6 +167,19 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
result.Special.Should().BeTrue();
|
||||
}
|
||||
|
||||
[TestCase("[Underwater] Another OVA - The Other -Karma- (BD 1080p) [3A561D0E].mkv", "Another", 0)]
|
||||
public void should_parse_absolute_specials_without_absolute_number(string postTitle, string title, int absoluteEpisodeNumber)
|
||||
{
|
||||
var result = Parser.Parser.ParseTitle(postTitle);
|
||||
result.Should().NotBeNull();
|
||||
result.AbsoluteEpisodeNumbers.Should().BeEmpty();
|
||||
result.SeasonNumber.Should().Be(0);
|
||||
result.EpisodeNumbers.Should().BeEmpty();
|
||||
result.SeriesTitle.Should().Be(title);
|
||||
result.FullSeason.Should().BeFalse();
|
||||
result.Special.Should().BeTrue();
|
||||
}
|
||||
|
||||
[TestCase("[ANBU-AonE]_SeriesTitle_26-27_[F224EF26].avi", "SeriesTitle", 26, 27)]
|
||||
[TestCase("[Doutei] Some Good, Anime Show - 01-12 [BD][720p-AAC]", "Some Good, Anime Show", 1, 12)]
|
||||
[TestCase("Series Title (2010) - 01-02-03 - Episode Title (1) HDTV-720p", "Series Title (2010)", 1, 3)]
|
||||
|
||||
@@ -42,7 +42,7 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[TestCase("Title.the.Italy.Series.S02E01.720p.HDTV.x264-TLA")]
|
||||
[TestCase("Series Title - S01E01 - Pilot.en.sub")]
|
||||
[TestCase("Series.Title.S01E01.SUBFRENCH.1080p.WEB.x264-GROUP")]
|
||||
|
||||
[TestCase("[Judas] Series Japanese Name (Series English Name) - S02E10 [1080P][HEVC x256 10bit][Eng-Subs] (Weekly)")]
|
||||
public void should_parse_language_unknown(string postTitle)
|
||||
{
|
||||
var result = LanguageParser.ParseLanguages(postTitle);
|
||||
@@ -175,6 +175,7 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[TestCase("[abc] My Series - 01 [简繁内封字幕]")]
|
||||
[TestCase("[ABC字幕组] My Series - 01 [HDTV]")]
|
||||
[TestCase("[喵萌奶茶屋&LoliHouse] 拳愿阿修罗 / Kengan Ashura - 17 [WebRip 1080p HEVC-10bit AAC][中日双语字幕]")]
|
||||
[TestCase("Series.Towards.You.S01.国语音轨.2023.1080p.NF.WEB-DL.H264.DDP2.0-SeeWEB")]
|
||||
public void should_parse_language_chinese(string postTitle)
|
||||
{
|
||||
var result = LanguageParser.ParseLanguages(postTitle);
|
||||
@@ -545,6 +546,21 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
result.Should().BeEquivalentTo(new[] { Language.Russian, Language.Georgian });
|
||||
}
|
||||
|
||||
[TestCase("The Boys S02 Eng Fre Ger Ita Por Spa 2160p WEBMux HDR10Plus HDR HEVC DDP SGF")]
|
||||
public void should_parse_language_english_french_german_italian_portuguese_spanish(string postTitle)
|
||||
{
|
||||
var result = LanguageParser.ParseLanguages(postTitle);
|
||||
result.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
Language.English,
|
||||
Language.French,
|
||||
Language.German,
|
||||
Language.Italian,
|
||||
Language.Portuguese,
|
||||
Language.Spanish
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].testtitle.default.eng.forced.ass", new[] { "default", "forced" }, "testtitle", "English")]
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].eng.default.testtitle.forced.ass", new[] { "default", "forced" }, "testtitle", "English")]
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].default.eng.testtitle.forced.ass", new[] { "default", "forced" }, "testtitle", "English")]
|
||||
|
||||
@@ -147,5 +147,33 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
result.SeriesTitle.Should().Be(title);
|
||||
result.FullSeason.Should().BeFalse();
|
||||
}
|
||||
|
||||
[TestCase("【高清剧集网发布 www.DDHDTV.com】当我飞奔向你[全24集][国语音轨+简繁英字幕].Series.Towards.You.S01.2023.1080p.NF.WEB-DL.H264.DDP2.0-SeeWEB", "Series Towards You", "SeeWEB", 1)]
|
||||
public void should_parse_full_season_releases(string postTitle, string title, string releaseGroup, int season)
|
||||
{
|
||||
postTitle = XmlCleaner.ReplaceUnicode(postTitle);
|
||||
|
||||
var result = Parser.Parser.ParseTitle(postTitle);
|
||||
result.Should().NotBeNull();
|
||||
result.ReleaseGroup.Should().Be(releaseGroup);
|
||||
result.EpisodeNumbers.Should().BeEmpty();
|
||||
result.AbsoluteEpisodeNumbers.Should().BeEmpty();
|
||||
result.SeriesTitle.Should().Be(title);
|
||||
result.FullSeason.Should().BeTrue();
|
||||
result.SeasonNumber.Should().Be(season);
|
||||
}
|
||||
|
||||
[TestCase("【高清剧集网发布 www.DDHDTV.com】当我飞奔向你[第01-15集][国语配音+中文字幕].Series.Towards.You.S01.2023.2160p.YK.WEB-DL.H265.AAC-BlackTV", "Series Towards You", "BlackTV", 1, new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 })]
|
||||
public void should_parse_multi_episode_release(string postTitle, string title, string releaseGroup, int season, int[] episodes)
|
||||
{
|
||||
postTitle = XmlCleaner.ReplaceUnicode(postTitle);
|
||||
|
||||
var result = Parser.Parser.ParseTitle(postTitle);
|
||||
result.SeasonNumber.Should().Be(season);
|
||||
result.EpisodeNumbers.Should().BeEquivalentTo(episodes);
|
||||
result.SeriesTitle.Should().Be(title);
|
||||
result.AbsoluteEpisodeNumbers.Should().BeEmpty();
|
||||
result.FullSeason.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FizzWare.NBuilder;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.Profiles.Releases;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Test.Profiles
|
||||
{
|
||||
[TestFixture]
|
||||
public class ReleaseProfileServiceFixture : CoreTest<ReleaseProfileService>
|
||||
{
|
||||
private List<ReleaseProfile> _releaseProfiles;
|
||||
private ReleaseProfile _defaultReleaseProfile;
|
||||
private ReleaseProfile _includedReleaseProfile;
|
||||
private ReleaseProfile _excludedReleaseProfile;
|
||||
private ReleaseProfile _includedAndExcludedReleaseProfile;
|
||||
private int _providedTag;
|
||||
private int _providedTagToExclude;
|
||||
private int _notUsedTag;
|
||||
private List<ReleaseProfile> _releaseProfilesWithoutTags;
|
||||
private List<ReleaseProfile> _releaseProfilesWithProvidedTag;
|
||||
private List<ReleaseProfile> _releaseProfilesWithProvidedTagOrWithoutTags;
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
_providedTag = 1;
|
||||
_providedTagToExclude = 2;
|
||||
_notUsedTag = 3;
|
||||
|
||||
_releaseProfiles = Builder<ReleaseProfile>.CreateListOfSize(5)
|
||||
.TheFirst(1)
|
||||
.With(r => r.Required = ["required_one"])
|
||||
.TheNext(1)
|
||||
.With(r => r.Required = ["required_two"])
|
||||
.With(r => r.Tags = [_providedTag])
|
||||
.TheNext(1)
|
||||
.With(r => r.Required = ["required_three"])
|
||||
.With(r => r.ExcludedTags = [_providedTagToExclude])
|
||||
.TheNext(1)
|
||||
.With(r => r.Required = ["required_four"])
|
||||
.With(r => r.Tags = [_providedTag])
|
||||
.With(r => r.ExcludedTags = [_providedTagToExclude])
|
||||
.TheNext(1)
|
||||
.With(r => r.Required = ["required_five"])
|
||||
.With(r => r.Tags = [_notUsedTag])
|
||||
.Build()
|
||||
.ToList();
|
||||
|
||||
_defaultReleaseProfile = _releaseProfiles[0];
|
||||
_includedReleaseProfile = _releaseProfiles[1];
|
||||
_excludedReleaseProfile = _releaseProfiles[2];
|
||||
_includedAndExcludedReleaseProfile = _releaseProfiles[3];
|
||||
|
||||
_releaseProfilesWithoutTags = [_defaultReleaseProfile, _excludedReleaseProfile];
|
||||
_releaseProfilesWithProvidedTag = [_includedReleaseProfile, _includedAndExcludedReleaseProfile];
|
||||
_releaseProfilesWithProvidedTagOrWithoutTags = [_defaultReleaseProfile, _includedReleaseProfile, _excludedReleaseProfile, _includedAndExcludedReleaseProfile];
|
||||
|
||||
Mocker.GetMock<IRestrictionRepository>()
|
||||
.Setup(s => s.All())
|
||||
.Returns(_releaseProfiles);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void all_for_tags_should_return_release_profiles_without_tags_by_default()
|
||||
{
|
||||
var releaseProfiles = Subject.AllForTags([]);
|
||||
releaseProfiles.Should().Equal(_releaseProfilesWithoutTags);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void all_for_tags_should_return_release_profiles_with_provided_tag_or_without_tags()
|
||||
{
|
||||
var releaseProfiles = Subject.AllForTags([_providedTag]);
|
||||
releaseProfiles.Should().Equal(_releaseProfilesWithProvidedTagOrWithoutTags);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void all_for_tags_should_not_return_release_profiles_with_provided_tag_excluded()
|
||||
{
|
||||
var releaseProfiles = Subject.AllForTags([_providedTagToExclude]);
|
||||
releaseProfiles.Should().NotContain(_excludedReleaseProfile);
|
||||
releaseProfiles.Should().NotContain(_includedAndExcludedReleaseProfile);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void all_for_tag_should_return_release_profiles_with_provided_tag()
|
||||
{
|
||||
var releaseProfiles = Subject.AllForTag(_providedTag);
|
||||
releaseProfiles.Should().Equal(_releaseProfilesWithProvidedTag);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void all_should_return_all_release_profiles()
|
||||
{
|
||||
var releaseProfiles = Subject.All();
|
||||
releaseProfiles.Should().Equal(_releaseProfiles);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void all_for_tags_should_not_return_release_profiles_with_a_provided_tag_both_included_and_excluded()
|
||||
{
|
||||
var releaseProfiles = Subject.AllForTags([_providedTag, _providedTagToExclude]);
|
||||
releaseProfiles.Should().Equal([_defaultReleaseProfile, _includedReleaseProfile]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void all_for_tags_should_return_matching_tags_that_are_not_excluded_tags()
|
||||
{
|
||||
var releaseProfiles = Subject.AllForTags([_providedTag]);
|
||||
releaseProfiles.Should().Equal([_defaultReleaseProfile, _includedReleaseProfile, _excludedReleaseProfile, _includedAndExcludedReleaseProfile]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.Data.SQLite.Core.Servarr" Version="1.0.115.5-18" />
|
||||
<PackageReference Remove="StyleCop.Analyzers" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -24,6 +24,7 @@ namespace NzbDrone.Core.Blocklisting
|
||||
public IndexerFlags IndexerFlags { get; set; }
|
||||
public ReleaseType ReleaseType { get; set; }
|
||||
public string Message { get; set; }
|
||||
public string Source { get; set; }
|
||||
public string TorrentInfoHash { get; set; }
|
||||
public List<Language> Languages { get; set; }
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace NzbDrone.Core.Blocklisting
|
||||
bool Blocklisted(int seriesId, ReleaseInfo release);
|
||||
bool BlocklistedTorrentHash(int seriesId, string hash);
|
||||
PagingSpec<Blocklist> Paged(PagingSpec<Blocklist> pagingSpec);
|
||||
void Block(RemoteEpisode remoteEpisode, string message);
|
||||
void Block(RemoteEpisode remoteEpisode, string message, string source);
|
||||
void Delete(int id);
|
||||
void Delete(List<int> ids);
|
||||
}
|
||||
@@ -71,7 +71,7 @@ namespace NzbDrone.Core.Blocklisting
|
||||
return _blocklistRepository.GetPaged(pagingSpec);
|
||||
}
|
||||
|
||||
public void Block(RemoteEpisode remoteEpisode, string message)
|
||||
public void Block(RemoteEpisode remoteEpisode, string message, string source)
|
||||
{
|
||||
var blocklist = new Blocklist
|
||||
{
|
||||
@@ -85,6 +85,7 @@ namespace NzbDrone.Core.Blocklisting
|
||||
Indexer = remoteEpisode.Release.Indexer,
|
||||
Protocol = remoteEpisode.Release.DownloadProtocol,
|
||||
Message = message,
|
||||
Source = source,
|
||||
Languages = remoteEpisode.ParsedEpisodeInfo.Languages
|
||||
};
|
||||
|
||||
@@ -185,6 +186,7 @@ namespace NzbDrone.Core.Blocklisting
|
||||
Indexer = message.Data.GetValueOrDefault("indexer"),
|
||||
Protocol = (DownloadProtocol)Convert.ToInt32(message.Data.GetValueOrDefault("protocol")),
|
||||
Message = message.Message,
|
||||
Source = message.Source,
|
||||
Languages = message.Languages,
|
||||
TorrentInfoHash = message.TrackedDownload?.Protocol == DownloadProtocol.Torrent
|
||||
? message.TrackedDownload.DownloadItem.DownloadId
|
||||
|
||||
@@ -47,6 +47,7 @@ namespace NzbDrone.Core.Configuration
|
||||
string Branch { get; }
|
||||
string ApiKey { get; }
|
||||
string SslCertPath { get; }
|
||||
string SslKeyPath { get; }
|
||||
string SslCertPassword { get; }
|
||||
string UrlBase { get; }
|
||||
string UiFolder { get; }
|
||||
@@ -257,6 +258,7 @@ namespace NzbDrone.Core.Configuration
|
||||
public int LogSizeLimit => Math.Min(Math.Max(_logOptions.SizeLimit ?? GetValueInt("LogSizeLimit", 1, persist: false), 0), 10);
|
||||
public bool FilterSentryEvents => _logOptions.FilterSentryEvents ?? GetValueBoolean("FilterSentryEvents", true, persist: false);
|
||||
public string SslCertPath => _serverOptions.SslCertPath ?? GetValue("SslCertPath", "");
|
||||
public string SslKeyPath => _serverOptions.SslKeyPath ?? GetValue("SslKeyPath", "");
|
||||
public string SslCertPassword => _serverOptions.SslCertPassword ?? GetValue("SslCertPassword", "");
|
||||
|
||||
public string UrlBase
|
||||
|
||||
@@ -263,6 +263,18 @@ namespace NzbDrone.Core.Configuration
|
||||
set { SetValue("UserRejectedExtensions", value); }
|
||||
}
|
||||
|
||||
public SeasonPackUpgradeType SeasonPackUpgrade
|
||||
{
|
||||
get { return GetValueEnum("SeasonPackUpgrade", SeasonPackUpgradeType.All); }
|
||||
set { SetValue("SeasonPackUpgrade", value); }
|
||||
}
|
||||
|
||||
public double SeasonPackUpgradeThreshold
|
||||
{
|
||||
get { return GetValueDouble("SeasonPackUpgradeThreshold", 100.0); }
|
||||
set { SetValue("SeasonPackUpgradeThreshold", value); }
|
||||
}
|
||||
|
||||
public bool SetPermissionsLinux
|
||||
{
|
||||
get { return GetValueBoolean("SetPermissionsLinux", false); }
|
||||
@@ -417,6 +429,11 @@ namespace NzbDrone.Core.Configuration
|
||||
return Convert.ToInt32(GetValue(key, defaultValue));
|
||||
}
|
||||
|
||||
private double GetValueDouble(string key, double defaultValue = 0)
|
||||
{
|
||||
return Convert.ToDouble(GetValue(key, defaultValue), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private T GetValueEnum<T>(string key, T defaultValue)
|
||||
{
|
||||
return (T)Enum.Parse(typeof(T), GetValue(key, defaultValue), true);
|
||||
@@ -454,6 +471,11 @@ namespace NzbDrone.Core.Configuration
|
||||
SetValue(key, value.ToString());
|
||||
}
|
||||
|
||||
private void SetValue(string key, double value)
|
||||
{
|
||||
SetValue(key, value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
private void SetValue(string key, Enum value)
|
||||
{
|
||||
SetValue(key, value.ToString().ToLower());
|
||||
|
||||
@@ -43,6 +43,10 @@ namespace NzbDrone.Core.Configuration
|
||||
EpisodeTitleRequiredType EpisodeTitleRequired { get; set; }
|
||||
string UserRejectedExtensions { get; set; }
|
||||
|
||||
// Season Pack Upgrade (Media Management)
|
||||
SeasonPackUpgradeType SeasonPackUpgrade { get; set; }
|
||||
double SeasonPackUpgradeThreshold { get; set; }
|
||||
|
||||
// Permissions (Media Management)
|
||||
bool SetPermissionsLinux { get; set; }
|
||||
string ChmodFolder { get; set; }
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Data.SQLite;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Dapper;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Instrumentation;
|
||||
using NzbDrone.Core.Datastore.Events;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
||||
namespace NzbDrone.Core.Datastore
|
||||
{
|
||||
@@ -40,12 +45,31 @@ namespace NzbDrone.Core.Datastore
|
||||
public class BasicRepository<TModel> : IBasicRepository<TModel>
|
||||
where TModel : ModelBase, new()
|
||||
{
|
||||
private static readonly ILogger Logger = NzbDroneLogger.GetLogger(typeof(BasicRepository<TModel>));
|
||||
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly PropertyInfo _keyProperty;
|
||||
private readonly List<PropertyInfo> _properties;
|
||||
private readonly string _updateSql;
|
||||
private readonly string _insertSql;
|
||||
|
||||
private static ResiliencePipeline RetryStrategy => new ResiliencePipelineBuilder()
|
||||
.AddRetry(new RetryStrategyOptions
|
||||
{
|
||||
ShouldHandle = new PredicateBuilder().Handle<SQLiteException>(ex => ex.ResultCode == SQLiteErrorCode.Busy),
|
||||
Delay = TimeSpan.FromMilliseconds(100),
|
||||
MaxRetryAttempts = 3,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
UseJitter = true,
|
||||
OnRetry = args =>
|
||||
{
|
||||
Logger.Warn(args.Outcome.Exception, "Failed writing to database. Retry #{0}", args.AttemptNumber);
|
||||
|
||||
return default;
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
|
||||
protected readonly IDatabase _database;
|
||||
protected readonly string _table;
|
||||
|
||||
@@ -186,7 +210,9 @@ namespace NzbDrone.Core.Datastore
|
||||
private TModel Insert(IDbConnection connection, IDbTransaction transaction, TModel model)
|
||||
{
|
||||
SqlBuilderExtensions.LogQuery(_insertSql, model);
|
||||
var multi = connection.QueryMultiple(_insertSql, model, transaction);
|
||||
|
||||
var multi = RetryStrategy.Execute(static (state, _) => state.connection.QueryMultiple(state._insertSql, state.model, state.transaction), (connection, _insertSql, model, transaction));
|
||||
|
||||
var multiRead = multi.Read();
|
||||
var id = (int)(multiRead.First().id ?? multiRead.First().Id);
|
||||
_keyProperty.SetValue(model, id);
|
||||
@@ -381,7 +407,7 @@ namespace NzbDrone.Core.Datastore
|
||||
|
||||
SqlBuilderExtensions.LogQuery(sql, model);
|
||||
|
||||
connection.Execute(sql, model, transaction: transaction);
|
||||
RetryStrategy.Execute(static (state, _) => state.connection.Execute(state.sql, state.model, transaction: state.transaction), (connection, sql, model, transaction));
|
||||
}
|
||||
|
||||
private void UpdateFields(IDbConnection connection, IDbTransaction transaction, IList<TModel> models, List<PropertyInfo> propertiesToUpdate)
|
||||
@@ -393,7 +419,7 @@ namespace NzbDrone.Core.Datastore
|
||||
SqlBuilderExtensions.LogQuery(sql, model);
|
||||
}
|
||||
|
||||
connection.Execute(sql, models, transaction: transaction);
|
||||
RetryStrategy.Execute(static (state, _) => state.connection.Execute(state.sql, state.models, transaction: state.transaction), (connection, sql, models, transaction));
|
||||
}
|
||||
|
||||
protected virtual SqlBuilder PagedBuilder() => Builder();
|
||||
|
||||
@@ -7,7 +7,7 @@ using NzbDrone.Common.Instrumentation;
|
||||
namespace NzbDrone.Core.Datastore.Migration
|
||||
{
|
||||
[Maintenance(MigrationStage.BeforeAll, TransactionBehavior.None)]
|
||||
public class DatabaseEngineVersionCheck : FluentMigrator.Migration
|
||||
public class DatabaseEngineVersionCheck : ForwardOnlyMigration
|
||||
{
|
||||
protected readonly Logger _logger;
|
||||
|
||||
@@ -22,11 +22,6 @@ namespace NzbDrone.Core.Datastore.Migration
|
||||
IfDatabase("postgres").Execute.WithConnection(LogPostgresVersion);
|
||||
}
|
||||
|
||||
public override void Down()
|
||||
{
|
||||
// No-op
|
||||
}
|
||||
|
||||
private void LogSqliteVersion(IDbConnection conn, IDbTransaction tran)
|
||||
{
|
||||
using (var versionCmd = conn.CreateCommand())
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using FluentMigrator;
|
||||
using NzbDrone.Core.Datastore.Migration.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Datastore.Migration
|
||||
{
|
||||
[Migration(221)]
|
||||
public class add_exclusion_tags_to_release_profiles : NzbDroneMigrationBase
|
||||
{
|
||||
protected override void MainDbUpgrade()
|
||||
{
|
||||
Alter.Table("ReleaseProfiles").AddColumn("ExcludedTags").AsString().NotNullable().WithDefaultValue("[]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using FluentMigrator;
|
||||
using NzbDrone.Core.Datastore.Migration.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Datastore.Migration
|
||||
{
|
||||
[Migration(223)]
|
||||
public class add_source_to_blocklist : NzbDroneMigrationBase
|
||||
{
|
||||
protected override void MainDbUpgrade()
|
||||
{
|
||||
Alter.Table("Blocklist").AddColumn("Source").AsString().Nullable();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ using FluentMigrator.Runner.Generators;
|
||||
using FluentMigrator.Runner.Initialization;
|
||||
using FluentMigrator.Runner.Processors;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NLog;
|
||||
using NLog.Extensions.Logging;
|
||||
|
||||
@@ -20,13 +19,10 @@ namespace NzbDrone.Core.Datastore.Migration.Framework
|
||||
public class MigrationController : IMigrationController
|
||||
{
|
||||
private readonly Logger _logger;
|
||||
private readonly ILoggerProvider _migrationLoggerProvider;
|
||||
|
||||
public MigrationController(Logger logger,
|
||||
ILoggerProvider migrationLoggerProvider)
|
||||
public MigrationController(Logger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_migrationLoggerProvider = migrationLoggerProvider;
|
||||
}
|
||||
|
||||
public void Migrate(string connectionString, MigrationContext migrationContext, DatabaseType databaseType)
|
||||
@@ -35,16 +31,13 @@ namespace NzbDrone.Core.Datastore.Migration.Framework
|
||||
|
||||
_logger.Info("*** Migrating {0} ***", connectionString);
|
||||
|
||||
ServiceProvider serviceProvider;
|
||||
|
||||
var db = databaseType == DatabaseType.SQLite ? "sqlite" : "postgres";
|
||||
|
||||
serviceProvider = new ServiceCollection()
|
||||
var serviceProvider = new ServiceCollection()
|
||||
.AddLogging(b => b.AddNLog())
|
||||
.AddFluentMigratorCore()
|
||||
.Configure<RunnerOptions>(cfg => cfg.IncludeUntaggedMaintenances = true)
|
||||
.ConfigureRunner(
|
||||
builder => builder
|
||||
.ConfigureRunner(builder => builder
|
||||
.AddPostgres()
|
||||
.AddNzbDroneSQLite()
|
||||
.WithGlobalConnectionString(connectionString)
|
||||
|
||||
@@ -4,9 +4,14 @@ using FluentMigrator.Builders.Create;
|
||||
using FluentMigrator.Builders.Create.Table;
|
||||
using FluentMigrator.Runner;
|
||||
using FluentMigrator.Runner.BatchParser;
|
||||
using FluentMigrator.Runner.Generators;
|
||||
using FluentMigrator.Runner.Generators.SQLite;
|
||||
using FluentMigrator.Runner.Initialization;
|
||||
using FluentMigrator.Runner.Processors;
|
||||
using FluentMigrator.Runner.Processors.SQLite;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace NzbDrone.Core.Datastore.Migration.Framework
|
||||
{
|
||||
@@ -26,23 +31,40 @@ namespace NzbDrone.Core.Datastore.Migration.Framework
|
||||
return command;
|
||||
}
|
||||
|
||||
public static void AddParameter(this System.Data.IDbCommand command, object value)
|
||||
public static void AddParameter(this IDbCommand command, object value)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.Value = value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
|
||||
public static IMigrationRunnerBuilder AddNzbDroneSQLite(this IMigrationRunnerBuilder builder)
|
||||
public static IMigrationRunnerBuilder AddNzbDroneSQLite(this IMigrationRunnerBuilder builder, bool binaryGuid = false, bool useStrictTables = false)
|
||||
{
|
||||
builder.Services
|
||||
.AddTransient<SQLiteBatchParser>()
|
||||
.AddScoped<SQLiteDbFactory>()
|
||||
.AddScoped<NzbDroneSQLiteProcessor>()
|
||||
.AddScoped<NzbDroneSQLiteProcessor>(sp =>
|
||||
{
|
||||
var factory = sp.GetService<SQLiteDbFactory>();
|
||||
var logger = sp.GetService<ILogger<NzbDroneSQLiteProcessor>>();
|
||||
var options = sp.GetService<IOptionsSnapshot<ProcessorOptions>>();
|
||||
var connectionStringAccessor = sp.GetService<IConnectionStringAccessor>();
|
||||
var sqliteQuoter = new SQLiteQuoter(false);
|
||||
return new NzbDroneSQLiteProcessor(factory, sp.GetService<SQLiteGenerator>(), logger, options, connectionStringAccessor, sp, sqliteQuoter);
|
||||
})
|
||||
.AddScoped<ISQLiteTypeMap>(_ => new NzbDroneSQLiteTypeMap(useStrictTables))
|
||||
.AddScoped<IMigrationProcessor>(sp => sp.GetRequiredService<NzbDroneSQLiteProcessor>())
|
||||
.AddScoped<SQLiteQuoter>()
|
||||
.AddScoped<SQLiteGenerator>()
|
||||
.AddScoped(
|
||||
sp =>
|
||||
{
|
||||
var typeMap = sp.GetRequiredService<ISQLiteTypeMap>();
|
||||
return new SQLiteGenerator(
|
||||
new SQLiteQuoter(binaryGuid),
|
||||
typeMap,
|
||||
new OptionsWrapper<GeneratorOptions>(new GeneratorOptions()));
|
||||
})
|
||||
.AddScoped<IMigrationGenerator>(sp => sp.GetRequiredService<SQLiteGenerator>());
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Data;
|
||||
using FluentMigrator.Runner.Generators.Base;
|
||||
using FluentMigrator.Runner.Generators.SQLite;
|
||||
|
||||
namespace NzbDrone.Core.Datastore.Migration.Framework;
|
||||
|
||||
// Based on https://github.com/fluentmigrator/fluentmigrator/blob/v6.2.0/src/FluentMigrator.Runner.SQLite/Generators/SQLite/SQLiteTypeMap.cs
|
||||
public sealed class NzbDroneSQLiteTypeMap : TypeMapBase, ISQLiteTypeMap
|
||||
{
|
||||
public bool UseStrictTables { get; }
|
||||
|
||||
public NzbDroneSQLiteTypeMap(bool useStrictTables = false)
|
||||
{
|
||||
UseStrictTables = useStrictTables;
|
||||
|
||||
SetupTypeMaps();
|
||||
}
|
||||
|
||||
// Must be kept in sync with upstream
|
||||
protected override void SetupTypeMaps()
|
||||
{
|
||||
SetTypeMap(DbType.Binary, "BLOB");
|
||||
SetTypeMap(DbType.Byte, "INTEGER");
|
||||
SetTypeMap(DbType.Int16, "INTEGER");
|
||||
SetTypeMap(DbType.Int32, "INTEGER");
|
||||
SetTypeMap(DbType.Int64, "INTEGER");
|
||||
SetTypeMap(DbType.SByte, "INTEGER");
|
||||
SetTypeMap(DbType.UInt16, "INTEGER");
|
||||
SetTypeMap(DbType.UInt32, "INTEGER");
|
||||
SetTypeMap(DbType.UInt64, "INTEGER");
|
||||
|
||||
if (!UseStrictTables)
|
||||
{
|
||||
SetTypeMap(DbType.Currency, "NUMERIC");
|
||||
SetTypeMap(DbType.Decimal, "NUMERIC");
|
||||
SetTypeMap(DbType.Double, "NUMERIC");
|
||||
SetTypeMap(DbType.Single, "NUMERIC");
|
||||
SetTypeMap(DbType.VarNumeric, "NUMERIC");
|
||||
SetTypeMap(DbType.Date, "DATETIME");
|
||||
SetTypeMap(DbType.DateTime, "DATETIME");
|
||||
SetTypeMap(DbType.DateTime2, "DATETIME");
|
||||
SetTypeMap(DbType.Time, "DATETIME");
|
||||
SetTypeMap(DbType.Guid, "UNIQUEIDENTIFIER");
|
||||
|
||||
// Custom so that we can use DateTimeOffset in Postgres for appropriate DB typing
|
||||
SetTypeMap(DbType.DateTimeOffset, "DATETIME");
|
||||
}
|
||||
else
|
||||
{
|
||||
SetTypeMap(DbType.Currency, "TEXT");
|
||||
SetTypeMap(DbType.Decimal, "TEXT");
|
||||
SetTypeMap(DbType.Double, "REAL");
|
||||
SetTypeMap(DbType.Single, "REAL");
|
||||
SetTypeMap(DbType.VarNumeric, "TEXT");
|
||||
SetTypeMap(DbType.Date, "TEXT");
|
||||
SetTypeMap(DbType.DateTime, "TEXT");
|
||||
SetTypeMap(DbType.DateTime2, "TEXT");
|
||||
SetTypeMap(DbType.Time, "TEXT");
|
||||
SetTypeMap(DbType.Guid, "TEXT");
|
||||
|
||||
// Custom so that we can use DateTimeOffset in Postgres for appropriate DB typing
|
||||
SetTypeMap(DbType.DateTimeOffset, "TEXT");
|
||||
}
|
||||
|
||||
SetTypeMap(DbType.AnsiString, "TEXT");
|
||||
SetTypeMap(DbType.String, "TEXT");
|
||||
SetTypeMap(DbType.AnsiStringFixedLength, "TEXT");
|
||||
SetTypeMap(DbType.StringFixedLength, "TEXT");
|
||||
SetTypeMap(DbType.Boolean, "INTEGER");
|
||||
}
|
||||
|
||||
public override string GetTypeMap(DbType type, int? size, int? precision)
|
||||
{
|
||||
return base.GetTypeMap(type, size: null, precision: null);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ namespace NzbDrone.Core.Datastore.Migration.Framework
|
||||
{
|
||||
public class NzbDroneSQLiteProcessor : SQLiteProcessor
|
||||
{
|
||||
private readonly SQLiteQuoter _quoter;
|
||||
|
||||
public NzbDroneSQLiteProcessor(SQLiteDbFactory factory,
|
||||
SQLiteGenerator generator,
|
||||
ILogger<NzbDroneSQLiteProcessor> logger,
|
||||
@@ -24,6 +26,7 @@ namespace NzbDrone.Core.Datastore.Migration.Framework
|
||||
SQLiteQuoter quoter)
|
||||
: base(factory, generator, logger, options, connectionStringAccessor, serviceProvider, quoter)
|
||||
{
|
||||
_quoter = quoter;
|
||||
}
|
||||
|
||||
public override void Process(AlterColumnExpression expression)
|
||||
@@ -35,7 +38,7 @@ namespace NzbDrone.Core.Datastore.Migration.Framework
|
||||
|
||||
if (columnIndex == -1)
|
||||
{
|
||||
throw new ApplicationException(string.Format("Column {0} does not exist on table {1}.", expression.Column.Name, expression.TableName));
|
||||
throw new ApplicationException($"Column {expression.Column.Name} does not exist on table {expression.TableName}.");
|
||||
}
|
||||
|
||||
columnDefinitions[columnIndex] = expression.Column;
|
||||
@@ -45,6 +48,28 @@ namespace NzbDrone.Core.Datastore.Migration.Framework
|
||||
ProcessAlterTable(tableDefinition);
|
||||
}
|
||||
|
||||
public override void Process(AlterDefaultConstraintExpression expression)
|
||||
{
|
||||
var tableDefinition = GetTableSchema(expression.TableName);
|
||||
|
||||
var columnDefinitions = tableDefinition.Columns.ToList();
|
||||
var columnIndex = columnDefinitions.FindIndex(c => c.Name == expression.ColumnName);
|
||||
|
||||
if (columnIndex == -1)
|
||||
{
|
||||
throw new ApplicationException($"Column {expression.ColumnName} does not exist on table {expression.TableName}.");
|
||||
}
|
||||
|
||||
var changedColumn = columnDefinitions[columnIndex];
|
||||
changedColumn.DefaultValue = expression.DefaultValue;
|
||||
|
||||
columnDefinitions[columnIndex] = changedColumn;
|
||||
|
||||
tableDefinition.Columns = columnDefinitions;
|
||||
|
||||
ProcessAlterTable(tableDefinition);
|
||||
}
|
||||
|
||||
public override void Process(DeleteColumnExpression expression)
|
||||
{
|
||||
var tableDefinition = GetTableSchema(expression.TableName);
|
||||
@@ -62,7 +87,7 @@ namespace NzbDrone.Core.Datastore.Migration.Framework
|
||||
|
||||
if (columnsToRemove.Any())
|
||||
{
|
||||
throw new ApplicationException(string.Format("Column {0} does not exist on table {1}.", columnsToRemove.First(), expression.TableName));
|
||||
throw new ApplicationException($"Column {columnsToRemove.First()} does not exist on table {expression.TableName}.");
|
||||
}
|
||||
|
||||
ProcessAlterTable(tableDefinition);
|
||||
@@ -78,12 +103,12 @@ namespace NzbDrone.Core.Datastore.Migration.Framework
|
||||
|
||||
if (columnIndex == -1)
|
||||
{
|
||||
throw new ApplicationException(string.Format("Column {0} does not exist on table {1}.", expression.OldName, expression.TableName));
|
||||
throw new ApplicationException($"Column {expression.OldName} does not exist on table {expression.TableName}.");
|
||||
}
|
||||
|
||||
if (columnDefinitions.Any(c => c.Name == expression.NewName))
|
||||
{
|
||||
throw new ApplicationException(string.Format("Column {0} already exists on table {1}.", expression.NewName, expression.TableName));
|
||||
throw new ApplicationException($"Column {expression.NewName} already exists on table {expression.TableName}.");
|
||||
}
|
||||
|
||||
oldColumnDefinitions[columnIndex] = (ColumnDefinition)columnDefinitions[columnIndex].Clone();
|
||||
@@ -128,21 +153,20 @@ namespace NzbDrone.Core.Datastore.Migration.Framework
|
||||
}
|
||||
|
||||
// What is the cleanest way to do this? Add function to Generator?
|
||||
var quoter = new SQLiteQuoter();
|
||||
var columnsToInsert = string.Join(", ", tableDefinition.Columns.Select(c => quoter.QuoteColumnName(c.Name)));
|
||||
var columnsToFetch = string.Join(", ", (oldColumnDefinitions ?? tableDefinition.Columns).Select(c => quoter.QuoteColumnName(c.Name)));
|
||||
var columnsToInsert = string.Join(", ", tableDefinition.Columns.Select(c => _quoter.QuoteColumnName(c.Name)));
|
||||
var columnsToFetch = string.Join(", ", (oldColumnDefinitions ?? tableDefinition.Columns).Select(c => _quoter.QuoteColumnName(c.Name)));
|
||||
|
||||
Process(new CreateTableExpression() { TableName = tempTableName, Columns = tableDefinition.Columns.ToList() });
|
||||
Process(new CreateTableExpression { TableName = tempTableName, Columns = tableDefinition.Columns.ToList() });
|
||||
|
||||
Process(string.Format("INSERT INTO {0} ({1}) SELECT {2} FROM {3}", quoter.QuoteTableName(tempTableName), columnsToInsert, columnsToFetch, quoter.QuoteTableName(tableName)));
|
||||
Process($"INSERT INTO {_quoter.QuoteTableName(tempTableName)} ({columnsToInsert}) SELECT {columnsToFetch} FROM {_quoter.QuoteTableName(tableName)}");
|
||||
|
||||
Process(new DeleteTableExpression() { TableName = tableName });
|
||||
Process(new DeleteTableExpression { TableName = tableName });
|
||||
|
||||
Process(new RenameTableExpression() { OldName = tempTableName, NewName = tableName });
|
||||
Process(new RenameTableExpression { OldName = tempTableName, NewName = tableName });
|
||||
|
||||
foreach (var index in tableDefinition.Indexes)
|
||||
{
|
||||
Process(new CreateIndexExpression() { Index = index });
|
||||
Process(new CreateIndexExpression { Index = index });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,5 +74,6 @@ public enum DownloadRejectionReason
|
||||
DiskCustomFormatCutoffMet,
|
||||
DiskCustomFormatScore,
|
||||
DiskCustomFormatScoreIncrement,
|
||||
DiskUpgradesNotAllowed
|
||||
DiskUpgradesNotAllowed,
|
||||
DiskNotUpgrade
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.CustomFormats;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
|
||||
namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
@@ -8,13 +10,16 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
public class UpgradeDiskSpecification : IDownloadDecisionEngineSpecification
|
||||
{
|
||||
private readonly UpgradableSpecification _upgradableSpecification;
|
||||
private readonly IConfigService _configService;
|
||||
private readonly ICustomFormatCalculationService _formatService;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public UpgradeDiskSpecification(UpgradableSpecification upgradableSpecification,
|
||||
IConfigService configService,
|
||||
ICustomFormatCalculationService formatService,
|
||||
Logger logger)
|
||||
{
|
||||
_configService = configService;
|
||||
_upgradableSpecification = upgradableSpecification;
|
||||
_formatService = formatService;
|
||||
_logger = logger;
|
||||
@@ -27,66 +32,155 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
{
|
||||
var qualityProfile = subject.Series.QualityProfile.Value;
|
||||
|
||||
if (subject.ParsedEpisodeInfo.FullSeason)
|
||||
{
|
||||
var totalEpisodesInPack = subject.Episodes.Count;
|
||||
|
||||
if (totalEpisodesInPack == 0)
|
||||
{
|
||||
// Should not happen, but good to guard against it.
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
|
||||
// Count missing episodes as upgradable
|
||||
var missingEpisodesCount = subject.Episodes.Count(c => c.EpisodeFileId == 0);
|
||||
var upgradedCount = missingEpisodesCount;
|
||||
_logger.Debug("{0} episodes are missing from disk and are considered upgradable.", upgradedCount);
|
||||
|
||||
// Filter for episodes that already exist on disk to check for quality upgrades
|
||||
var existingEpisodeFiles = subject.Episodes.Where(c => c.EpisodeFileId != 0)
|
||||
.Select(c => c.EpisodeFile.Value)
|
||||
.ToList();
|
||||
|
||||
// If all episodes in the pack are missing, accept it immediately.
|
||||
if (!existingEpisodeFiles.Any())
|
||||
{
|
||||
_logger.Debug("All episodes in season pack are missing, accepting.");
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
|
||||
// Check if any of the existing files can also be upgraded
|
||||
foreach (var file in existingEpisodeFiles)
|
||||
{
|
||||
_logger.Debug("Comparing file quality with report. Existing file is {0}.", file.Quality);
|
||||
|
||||
if (!_upgradableSpecification.CutoffNotMet(qualityProfile,
|
||||
file.Quality,
|
||||
_formatService.ParseCustomFormat(file),
|
||||
subject.ParsedEpisodeInfo.Quality))
|
||||
{
|
||||
_logger.Debug("Cutoff already met for existing file, not an upgrade.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var customFormats = _formatService.ParseCustomFormat(file);
|
||||
|
||||
var upgradeableRejectReason = _upgradableSpecification.IsUpgradable(qualityProfile,
|
||||
file.Quality,
|
||||
customFormats,
|
||||
subject.ParsedEpisodeInfo.Quality,
|
||||
subject.CustomFormats);
|
||||
|
||||
if (upgradeableRejectReason == UpgradeableRejectReason.None)
|
||||
{
|
||||
_logger.Debug("Existing episode is upgradable.");
|
||||
upgradedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
var seasonPackUpgrade = _configService.SeasonPackUpgrade;
|
||||
var seasonPackUpgradeThreshold = _configService.SeasonPackUpgradeThreshold;
|
||||
_logger.Debug("Total upgradable episodes: {0} out of {1}. Season import setting: {2}, Threshold: {3}%", upgradedCount, totalEpisodesInPack, seasonPackUpgrade, seasonPackUpgradeThreshold);
|
||||
var upgradablePercentage = (double)upgradedCount / totalEpisodesInPack * 100;
|
||||
if (seasonPackUpgrade == SeasonPackUpgradeType.Any)
|
||||
{
|
||||
if (upgradedCount > 0)
|
||||
{
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var threshold = seasonPackUpgrade == SeasonPackUpgradeType.All
|
||||
? 100.0
|
||||
: _configService.SeasonPackUpgradeThreshold;
|
||||
if (upgradablePercentage >= threshold)
|
||||
{
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
}
|
||||
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskNotUpgrade, $"Season pack does not meet the upgrade criteria. Upgradable: {upgradedCount}/{totalEpisodesInPack} ({upgradablePercentage:0.##}%), Mode: {seasonPackUpgrade}, Threshold: {seasonPackUpgradeThreshold}%");
|
||||
}
|
||||
|
||||
foreach (var file in subject.Episodes.Where(c => c.EpisodeFileId != 0).Select(c => c.EpisodeFile.Value))
|
||||
{
|
||||
if (file == null)
|
||||
var decision = CheckUpgradeSpecification(file, qualityProfile, subject);
|
||||
if (decision != null)
|
||||
{
|
||||
_logger.Debug("File is no longer available, skipping this file.");
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Debug("Comparing file quality with report. Existing file is {0}.", file.Quality);
|
||||
|
||||
if (!_upgradableSpecification.CutoffNotMet(qualityProfile,
|
||||
file.Quality,
|
||||
_formatService.ParseCustomFormat(file),
|
||||
subject.ParsedEpisodeInfo.Quality))
|
||||
{
|
||||
_logger.Debug("Cutoff already met, rejecting.");
|
||||
|
||||
var cutoff = qualityProfile.UpgradeAllowed ? qualityProfile.Cutoff : qualityProfile.FirststAllowedQuality().Id;
|
||||
var qualityCutoff = qualityProfile.Items[qualityProfile.GetIndex(cutoff).Index];
|
||||
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskCutoffMet, "Existing file meets cutoff: {0}", qualityCutoff);
|
||||
}
|
||||
|
||||
var customFormats = _formatService.ParseCustomFormat(file);
|
||||
|
||||
var upgradeableRejectReason = _upgradableSpecification.IsUpgradable(qualityProfile,
|
||||
file.Quality,
|
||||
customFormats,
|
||||
subject.ParsedEpisodeInfo.Quality,
|
||||
subject.CustomFormats);
|
||||
|
||||
switch (upgradeableRejectReason)
|
||||
{
|
||||
case UpgradeableRejectReason.None:
|
||||
continue;
|
||||
|
||||
case UpgradeableRejectReason.BetterQuality:
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskHigherPreference, "Existing file on disk is of equal or higher preference: {0}", file.Quality);
|
||||
|
||||
case UpgradeableRejectReason.BetterRevision:
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskHigherRevision, "Existing file on disk is of equal or higher revision: {0}", file.Quality.Revision);
|
||||
|
||||
case UpgradeableRejectReason.QualityCutoff:
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskCutoffMet, "Existing file on disk meets quality cutoff: {0}", qualityProfile.Items[qualityProfile.GetIndex(qualityProfile.Cutoff).Index]);
|
||||
|
||||
case UpgradeableRejectReason.CustomFormatCutoff:
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskCustomFormatCutoffMet, "Existing file on disk meets Custom Format cutoff: {0}", qualityProfile.CutoffFormatScore);
|
||||
|
||||
case UpgradeableRejectReason.CustomFormatScore:
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskCustomFormatScore, "Existing file on disk has a equal or higher Custom Format score: {0}", qualityProfile.CalculateCustomFormatScore(customFormats));
|
||||
|
||||
case UpgradeableRejectReason.MinCustomFormatScore:
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskCustomFormatScoreIncrement, "Existing file on disk has Custom Format score within Custom Format score increment: {0}", qualityProfile.MinUpgradeFormatScore);
|
||||
|
||||
case UpgradeableRejectReason.UpgradesNotAllowed:
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskUpgradesNotAllowed, "Existing file on disk and Quality Profile '{0}' does not allow upgrades", qualityProfile.Name);
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
|
||||
return DownloadSpecDecision.Accept();
|
||||
}
|
||||
|
||||
private DownloadSpecDecision CheckUpgradeSpecification(NzbDrone.Core.MediaFiles.EpisodeFile file, NzbDrone.Core.Profiles.Qualities.QualityProfile qualityProfile, RemoteEpisode subject)
|
||||
{
|
||||
if (file == null)
|
||||
{
|
||||
_logger.Debug("File is no longer available, skipping this file.");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.Debug("Comparing file quality with report. Existing file is {0}.", file.Quality);
|
||||
|
||||
if (!_upgradableSpecification.CutoffNotMet(qualityProfile,
|
||||
file.Quality,
|
||||
_formatService.ParseCustomFormat(file),
|
||||
subject.ParsedEpisodeInfo.Quality))
|
||||
{
|
||||
_logger.Debug("Cutoff already met, rejecting.");
|
||||
var cutoff = qualityProfile.UpgradeAllowed ? qualityProfile.Cutoff : qualityProfile.FirststAllowedQuality().Id;
|
||||
var qualityCutoff = qualityProfile.Items[qualityProfile.GetIndex(cutoff).Index];
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskCutoffMet, "Existing file meets cutoff: {0}", qualityCutoff);
|
||||
}
|
||||
|
||||
var customFormats = _formatService.ParseCustomFormat(file);
|
||||
var upgradeableRejectReason = _upgradableSpecification.IsUpgradable(qualityProfile,
|
||||
file.Quality,
|
||||
customFormats,
|
||||
subject.ParsedEpisodeInfo.Quality,
|
||||
subject.CustomFormats);
|
||||
|
||||
switch (upgradeableRejectReason)
|
||||
{
|
||||
case UpgradeableRejectReason.None:
|
||||
return null;
|
||||
|
||||
case UpgradeableRejectReason.BetterQuality:
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskHigherPreference, "Existing file on disk is of equal or higher preference: {0}", file.Quality);
|
||||
|
||||
case UpgradeableRejectReason.BetterRevision:
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskHigherRevision, "Existing file on disk is of equal or higher revision: {0}", file.Quality.Revision);
|
||||
|
||||
case UpgradeableRejectReason.QualityCutoff:
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskCutoffMet, "Existing file on disk meets quality cutoff: {0}", qualityProfile.Items[qualityProfile.GetIndex(qualityProfile.Cutoff).Index]);
|
||||
|
||||
case UpgradeableRejectReason.CustomFormatCutoff:
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskCustomFormatCutoffMet, "Existing file on disk meets Custom Format cutoff: {0}", qualityProfile.CutoffFormatScore);
|
||||
|
||||
case UpgradeableRejectReason.CustomFormatScore:
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskCustomFormatScore, "Existing file on disk has a equal or higher Custom Format score: {0}", qualityProfile.CalculateCustomFormatScore(customFormats));
|
||||
|
||||
case UpgradeableRejectReason.MinCustomFormatScore:
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskCustomFormatScoreIncrement, "Existing file on disk has Custom Format score within Custom Format score increment: {0}", qualityProfile.MinUpgradeFormatScore);
|
||||
|
||||
case UpgradeableRejectReason.UpgradesNotAllowed:
|
||||
return DownloadSpecDecision.Reject(DownloadRejectionReason.DiskUpgradesNotAllowed, "Existing file on disk and Quality Profile '{0}' does not allow upgrades", qualityProfile.Name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace NzbDrone.Core.Download
|
||||
public string DownloadClient { get; set; }
|
||||
public string DownloadId { get; set; }
|
||||
public string Message { get; set; }
|
||||
public string Source { get; set; }
|
||||
public Dictionary<string, string> Data { get; set; }
|
||||
public TrackedDownload TrackedDownload { get; set; }
|
||||
public List<Language> Languages { get; set; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Download.TrackedDownloads;
|
||||
using NzbDrone.Core.History;
|
||||
@@ -11,8 +12,8 @@ namespace NzbDrone.Core.Download
|
||||
{
|
||||
public interface IFailedDownloadService
|
||||
{
|
||||
void MarkAsFailed(int historyId, bool skipRedownload = false);
|
||||
void MarkAsFailed(TrackedDownload trackedDownload, bool skipRedownload = false);
|
||||
void MarkAsFailed(int historyId, string message = null, string source = null, bool skipRedownload = false);
|
||||
void MarkAsFailed(TrackedDownload trackedDownload, string message = null, string source = null, bool skipRedownload = false);
|
||||
void Check(TrackedDownload trackedDownload);
|
||||
void ProcessFailed(TrackedDownload trackedDownload);
|
||||
}
|
||||
@@ -30,15 +31,16 @@ namespace NzbDrone.Core.Download
|
||||
_eventAggregator = eventAggregator;
|
||||
}
|
||||
|
||||
public void MarkAsFailed(int historyId, bool skipRedownload = false)
|
||||
public void MarkAsFailed(int historyId, string message, string source = null, bool skipRedownload = false)
|
||||
{
|
||||
var history = _historyService.Get(historyId);
|
||||
message ??= "Manually marked as failed";
|
||||
|
||||
var history = _historyService.Get(historyId);
|
||||
var downloadId = history.DownloadId;
|
||||
|
||||
if (downloadId.IsNullOrWhiteSpace())
|
||||
{
|
||||
PublishDownloadFailedEvent(history, new List<int> { history.EpisodeId }, "Manually marked as failed", skipRedownload: skipRedownload);
|
||||
PublishDownloadFailedEvent(history, new List<int> { history.EpisodeId }, message, source, skipRedownload: skipRedownload);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -55,16 +57,16 @@ namespace NzbDrone.Core.Download
|
||||
grabbedHistory.AddRange(GetGrabbedHistory(downloadId));
|
||||
grabbedHistory = grabbedHistory.DistinctBy(h => h.Id).ToList();
|
||||
|
||||
PublishDownloadFailedEvent(history, GetEpisodeIds(grabbedHistory), "Manually marked as failed");
|
||||
PublishDownloadFailedEvent(history, GetEpisodeIds(grabbedHistory), message, source);
|
||||
}
|
||||
|
||||
public void MarkAsFailed(TrackedDownload trackedDownload, bool skipRedownload = false)
|
||||
public void MarkAsFailed(TrackedDownload trackedDownload, string message, string source = null, bool skipRedownload = false)
|
||||
{
|
||||
var history = GetGrabbedHistory(trackedDownload.DownloadItem.DownloadId);
|
||||
|
||||
if (history.Any())
|
||||
{
|
||||
PublishDownloadFailedEvent(history.First(), GetEpisodeIds(history), "Manually marked as failed", trackedDownload, skipRedownload: skipRedownload);
|
||||
PublishDownloadFailedEvent(history.First(), GetEpisodeIds(history), message ?? "Manually marked as failed", source, trackedDownload, skipRedownload: skipRedownload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,10 +119,10 @@ namespace NzbDrone.Core.Download
|
||||
}
|
||||
|
||||
trackedDownload.State = TrackedDownloadState.Failed;
|
||||
PublishDownloadFailedEvent(grabbedItems.First(), GetEpisodeIds(grabbedItems), failure, trackedDownload);
|
||||
PublishDownloadFailedEvent(grabbedItems.First(), GetEpisodeIds(grabbedItems), failure, $"{BuildInfo.AppName} Failed Download Handling", trackedDownload);
|
||||
}
|
||||
|
||||
private void PublishDownloadFailedEvent(EpisodeHistory historyItem, List<int> episodeIds, string message, TrackedDownload trackedDownload = null, bool skipRedownload = false)
|
||||
private void PublishDownloadFailedEvent(EpisodeHistory historyItem, List<int> episodeIds, string message, string source, TrackedDownload trackedDownload = null, bool skipRedownload = false)
|
||||
{
|
||||
Enum.TryParse(historyItem.Data.GetValueOrDefault(EpisodeHistory.RELEASE_SOURCE, ReleaseSourceType.Unknown.ToString()), out ReleaseSourceType releaseSource);
|
||||
|
||||
@@ -133,6 +135,7 @@ namespace NzbDrone.Core.Download
|
||||
DownloadClient = historyItem.Data.GetValueOrDefault(EpisodeHistory.DOWNLOAD_CLIENT),
|
||||
DownloadId = historyItem.DownloadId,
|
||||
Message = message,
|
||||
Source = source,
|
||||
Data = historyItem.Data,
|
||||
TrackedDownload = trackedDownload,
|
||||
Languages = historyItem.Languages,
|
||||
|
||||
@@ -249,6 +249,7 @@ namespace NzbDrone.Core.History
|
||||
history.Data.Add("DownloadClient", message.DownloadClient);
|
||||
history.Data.Add("DownloadClientName", message.TrackedDownload?.DownloadItem.DownloadClientInfo.Name);
|
||||
history.Data.Add("Message", message.Message);
|
||||
history.Data.Add("Source", message.Source);
|
||||
history.Data.Add("ReleaseGroup", message.TrackedDownload?.RemoteEpisode?.ParsedEpisodeInfo?.ReleaseGroup ?? message.Data.GetValueOrDefault(EpisodeHistory.RELEASE_GROUP));
|
||||
history.Data.Add("Size", message.TrackedDownload?.DownloadItem.TotalSize.ToString() ?? message.Data.GetValueOrDefault(EpisodeHistory.SIZE));
|
||||
history.Data.Add("Indexer", message.TrackedDownload?.RemoteEpisode?.Release?.Indexer ?? message.Data.GetValueOrDefault(EpisodeHistory.INDEXER));
|
||||
|
||||
@@ -210,6 +210,11 @@ namespace NzbDrone.Core.Indexers.Newznab
|
||||
flags |= IndexerFlags.Nuked;
|
||||
}
|
||||
|
||||
if (TryGetNewznabAttribute(item, "subs").IsNotNullOrWhiteSpace())
|
||||
{
|
||||
flags |= IndexerFlags.Subtitles;
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"AddNewRestriction": "Add new restriction",
|
||||
"AddNewSeries": "Add New Series",
|
||||
"AddNewSeriesError": "Failed to load search results, please try again.",
|
||||
"AddNewSeriesHelpText": "It's easy to add a new series, just start typing the name the series you want to add.",
|
||||
"AddNewSeriesHelpText": "It's easy to add a new series, just start typing the name of the series you want to add.",
|
||||
"AddNewSeriesRootFolderHelpText": "'{folder}' subfolder will be created automatically",
|
||||
"AddNewSeriesSearchForCutoffUnmetEpisodes": "Start search for cutoff unmet episodes",
|
||||
"AddNewSeriesSearchForMissingEpisodes": "Start search for missing episodes",
|
||||
@@ -690,6 +690,9 @@
|
||||
"Events": "Events",
|
||||
"Example": "Example",
|
||||
"Exception": "Exception",
|
||||
"ExcludedTags": "Excluded Tags",
|
||||
"ExcludedReleaseProfile": "Excluded Release Profile",
|
||||
"ExcludedReleaseProfiles": "Excluded Release Profiles",
|
||||
"Existing": "Existing",
|
||||
"ExistingSeries": "Existing Series",
|
||||
"ExistingTag": "Existing tag",
|
||||
@@ -1699,6 +1702,7 @@
|
||||
"ReleaseGroups": "Release Groups",
|
||||
"ReleaseHash": "Release Hash",
|
||||
"ReleaseProfile": "Release Profile",
|
||||
"ReleaseProfileExcludedTagSeriesHelpText": "Release profiles will not apply to series with at least one matching tag.",
|
||||
"ReleaseProfileIndexerHelpText": "Specify what indexer the profile applies to",
|
||||
"ReleaseProfileIndexerHelpTextWarning": "Setting a specific indexer on a release profile will cause this profile to only apply to releases from that indexer.",
|
||||
"ReleaseProfileTagSeriesHelpText": "Release profiles will apply to series with at least one matching tag. Leave blank to apply to all series",
|
||||
@@ -1858,6 +1862,12 @@
|
||||
"SeasonFinale": "Season Finale",
|
||||
"SeasonFolder": "Season Folder",
|
||||
"SeasonFolderFormat": "Season Folder Format",
|
||||
"SeasonPackUpgradeAllowAnyWarning": "Allow a season pack if it upgrades any episode. This applies to all sources of automatic grabs.",
|
||||
"SeasonPackUpgradeAllowHelpText": "Require a season pack to be a quality or custom format upgrade for all episodes",
|
||||
"SeasonPackUpgradeAllowLabel": "Allow Season Pack Upgrades",
|
||||
"SeasonPackUpgradeThresholdHelpText": "Require a season pack to be an upgrade for at least X percent of episodes.",
|
||||
"SeasonPackUpgradeThresholdHelpTextExample": "{numberEpisodes} of {totalEpisodes} episodes: {count}%",
|
||||
"SeasonPackUpgradeThresholdLabel": "Season Pack Upgrade Threshold",
|
||||
"SeasonInformation": "Season Information",
|
||||
"SeasonNumber": "Season Number",
|
||||
"SeasonNumberToken": "Season {seasonNumber}",
|
||||
@@ -1992,7 +2002,9 @@
|
||||
"SslCertPassword": "SSL Cert Password",
|
||||
"SslCertPasswordHelpText": "Password for pfx file",
|
||||
"SslCertPath": "SSL Cert Path",
|
||||
"SslCertPathHelpText": "Path to pfx file",
|
||||
"SslCertPathHelpText": "Path to pfx or pem file",
|
||||
"SslKeyPath": "SSL Key Path",
|
||||
"SslKeyPathHelpText": "Path to key file used with pem file",
|
||||
"SslPort": "SSL Port",
|
||||
"Standard": "Standard",
|
||||
"StandardEpisodeFormat": "Standard Episode Format",
|
||||
|
||||
@@ -2184,5 +2184,19 @@
|
||||
"DownloadClientTriblerSettingsAnonymityLevelHelpText": "Número de proxies a usar cuando se descarga contenido. Establecer a 0 para deshabilitarlo. Los proxies reducen la velocidad de descarga/subida. Ver {url}",
|
||||
"DownloadClientTriblerProviderMessage": "La integración con Tribler es altamente experimental. Probado con {clientName} versión {clientVersionRange}.",
|
||||
"DownloadClientTriblerSettingsSafeSeedingHelpText": "Cuando se habilita, solo se siembra a través de los proxies.",
|
||||
"DownloadClientTriblerSettingsSafeSeeding": "Sembrado seguro"
|
||||
"DownloadClientTriblerSettingsSafeSeeding": "Sembrado seguro",
|
||||
"EpisodeMaybePlural": "Episodio(s)",
|
||||
"EpisodeTitles": "Títulos del episodio",
|
||||
"EpisodeTitleMaybePlural": "Título(s) del episodio",
|
||||
"SeasonPackUpgradeAllowAnyWarning": "Permite un pack de temporada si se actualiza cualquier episodio. Esto se aplica a todas las fuentes de capturas automáticas.",
|
||||
"SeasonPackUpgradeAllowLabel": "Permitir actualizaciones de pack de temporada",
|
||||
"SeasonPackUpgradeThresholdHelpText": "Se requiere un pack de temporada para obtener una actualización para al menos un X porcentaje de episodios.",
|
||||
"SeasonPackUpgradeAllowHelpText": "Se requiere un pack de temporada para obtener una actualización de calidad o de formato personalizado para todos los episodios",
|
||||
"SeasonPackUpgradeThresholdHelpTextExample": "{numberEpisodes} de {totalEpisodes} episodios: {count}%",
|
||||
"SeasonPackUpgradeThresholdLabel": "Límite de actualización de pack de temporada",
|
||||
"ExcludedTags": "Etiquetas excluidas",
|
||||
"ExcludedReleaseProfile": "Perfil de lanzamiento excluido",
|
||||
"ExcludedReleaseProfiles": "Perfiles de lanzamiento excluidos",
|
||||
"MultipleEpisodes": "Episodios múltiples",
|
||||
"ReleaseProfileExcludedTagSeriesHelpText": "Los perfiles de lanzamiento no se aplicarán a series con al menos una etiqueta coincidente."
|
||||
}
|
||||
|
||||
@@ -2184,5 +2184,19 @@
|
||||
"DownloadClientTriblerSettingsSafeSeeding": "Partage protégé",
|
||||
"DownloadClientTriblerSettingsSafeSeedingHelpText": "Lorsque activé, seulement partager via un proxy.",
|
||||
"DownloadClientTriblerProviderMessage": "L’intégration avec tribler est hautement expérimental. Tester sur {clientName} version {clientVersionRange}.",
|
||||
"NotificationsAppriseSettingsIncludePoster": "Inclure le poster"
|
||||
"NotificationsAppriseSettingsIncludePoster": "Inclure le poster",
|
||||
"ReleaseProfileExcludedTagSeriesHelpText": "Profils de version ne d’appliqueront pas aux séries ayant au moins un tag correspondant.",
|
||||
"SeasonPackUpgradeThresholdHelpTextExample": "{numberEpisodes} de {totalEpisodes} épisodes : {count}%",
|
||||
"EpisodeMaybePlural": "Épisode(s)",
|
||||
"EpisodeTitles": "Titre des Épisodes",
|
||||
"EpisodeTitleMaybePlural": "Titre(s) d’épisode",
|
||||
"MultipleEpisodes": "Épisodes multiples",
|
||||
"SeasonPackUpgradeAllowAnyWarning": "Permettre un pack de saison s’il améliore au moins un épisode. Ceci s’applique à toute les sources de saisi automatique.",
|
||||
"SeasonPackUpgradeAllowHelpText": "Obliger qu’un pack de saison soit une amélioration de qualité ou de format personnalisé pour tous les épisodes",
|
||||
"SeasonPackUpgradeAllowLabel": "Permettre les mise à niveau via pack de saison",
|
||||
"SeasonPackUpgradeThresholdHelpText": "Obliger que le pack de saison soit une mise à niveau pour au moins X pour-cent des épisodes.",
|
||||
"ExcludedTags": "Tags exclus",
|
||||
"ExcludedReleaseProfile": "Profil de version exclu",
|
||||
"ExcludedReleaseProfiles": "Profils de version exclus",
|
||||
"SeasonPackUpgradeThresholdLabel": "Minimum pour les mise à jour via pack de saison"
|
||||
}
|
||||
|
||||
@@ -2184,5 +2184,19 @@
|
||||
"DownloadClientTriblerSettingsDirectoryHelpText": "Local opcional para colocar downloads, deixe em branco para usar o local padrão do Tribler",
|
||||
"DownloadClientTriblerSettingsSafeSeeding": "Semeadura Segura",
|
||||
"DownloadClientTriblerSettingsSafeSeedingHelpText": "Quando ativado, apenas semeia por meio de proxies.",
|
||||
"DownloadClientTriblerProviderMessage": "A integração tribler é altamente experimental. Testado em {clientName} versão {clientVersionRange}."
|
||||
"DownloadClientTriblerProviderMessage": "A integração tribler é altamente experimental. Testado em {clientName} versão {clientVersionRange}.",
|
||||
"ExcludedTags": "Etiquetas Excluídas",
|
||||
"EpisodeMaybePlural": "Episódio(s)",
|
||||
"EpisodeTitles": "Títulos dos Episódios",
|
||||
"EpisodeTitleMaybePlural": "Título(s) do(s) Episódio(s)",
|
||||
"MultipleEpisodes": "Múltiplos Episódios",
|
||||
"SeasonPackUpgradeAllowLabel": "Permitir Atualizações do Pacote de Temporada",
|
||||
"SeasonPackUpgradeAllowAnyWarning": "Permita um pacote de temporada se ele atualizar qualquer episódio. Isto aplica-se a todas as fontes de obtenção automáticas.",
|
||||
"SeasonPackUpgradeAllowHelpText": "Exigir que um pacote de temporada seja uma atualização de qualidade ou formato personalizado para todos os episódios",
|
||||
"SeasonPackUpgradeThresholdHelpText": "Exija que um pacote de temporada seja uma atualização para pelo menos X por cento dos episódios.",
|
||||
"SeasonPackUpgradeThresholdHelpTextExample": "{numberEpisodes} de {totalEpisodes} episódios: {count}%",
|
||||
"SeasonPackUpgradeThresholdLabel": "Limite de Atualização do Pacote de Temporada",
|
||||
"ExcludedReleaseProfile": "Perfil de Lançamento Excluído",
|
||||
"ExcludedReleaseProfiles": "Perfis de Lançamentos Excluídos",
|
||||
"ReleaseProfileExcludedTagSeriesHelpText": "Os perfis de lançamento não se aplicarão a séries com pelo menos uma etiqueta correspondente."
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user