mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-17 21:26:13 -04:00
Compare commits
61 Commits
v4.0.1.113
...
v4.0.2.126
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f09903a06 | ||
|
|
fa4c11a943 | ||
|
|
653963a247 | ||
|
|
32c32e2f88 | ||
|
|
07bd159436 | ||
|
|
20273b07ad | ||
|
|
e5f19f01fa | ||
|
|
13af6f5779 | ||
|
|
71c2c0570b | ||
|
|
64c6a8879b | ||
|
|
7f061a9583 | ||
|
|
4285691064 | ||
|
|
de9899c60e | ||
|
|
6c8758c27a | ||
|
|
086d3b5afa | ||
|
|
f8a0751775 | ||
|
|
c99d81e79b | ||
|
|
9fd193d2a8 | ||
|
|
64f4365fe9 | ||
|
|
2773f77e1c | ||
|
|
0a84b4a8e9 | ||
|
|
236d8e4c50 | ||
|
|
16d3827dbd | ||
|
|
fa600e62e0 | ||
|
|
fb6fc568c5 | ||
|
|
1f97679868 | ||
|
|
b34e0f8259 | ||
|
|
4c170d0452 | ||
|
|
6dc0a88004 | ||
|
|
33b44a8a53 | ||
|
|
cb72e752f9 | ||
|
|
a11ee7bc11 | ||
|
|
98d60e1a8e | ||
|
|
6377c688fc | ||
|
|
7a37f130f9 | ||
|
|
724dd7e733 | ||
|
|
e1be3b20e9 | ||
|
|
2f041f9ec1 | ||
|
|
f10ccf587d | ||
|
|
0242b40eda | ||
|
|
7a768b5d0f | ||
|
|
a57254640f | ||
|
|
1a6f45bafd | ||
|
|
c6071f6d81 | ||
|
|
2a47a237d4 | ||
|
|
a7607ac7d6 | ||
|
|
43797b326d | ||
|
|
5c4f829993 | ||
|
|
8dd8c95f36 | ||
|
|
6f6036a199 | ||
|
|
625e500132 | ||
|
|
39575b1248 | ||
|
|
f1d343218c | ||
|
|
b0829d5537 | ||
|
|
965e7c22d9 | ||
|
|
75535e61d9 | ||
|
|
c0b17d9345 | ||
|
|
84e657482d | ||
|
|
ed27bcf213 | ||
|
|
9ee2fe6f5c | ||
|
|
d5e19b8c3c |
13
.github/actions/test/action.yml
vendored
13
.github/actions/test/action.yml
vendored
@@ -27,7 +27,7 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v3
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
- name: Setup Postgres
|
||||
if: ${{ inputs.use_postgres }}
|
||||
@@ -77,7 +77,7 @@ runs:
|
||||
|
||||
- name: Run tests
|
||||
shell: bash
|
||||
run: dotnet test ./_tests/Sonarr.*.Test.dll --filter "${{ inputs.filter }}" --logger "trx;LogFileName=${{ env.RESULTS_NAME }}.trx"
|
||||
run: dotnet test ./_tests/Sonarr.*.Test.dll --filter "${{ inputs.filter }}" --logger "trx;LogFileName=${{ env.RESULTS_NAME }}.trx" --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true"
|
||||
|
||||
- name: Upload Test Results
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -85,12 +85,3 @@ runs:
|
||||
with:
|
||||
name: results-${{ env.RESULTS_NAME }}
|
||||
path: TestResults/*.trx
|
||||
|
||||
- name: Publish Test Results
|
||||
uses: phoenix-actions/test-reporting@v12
|
||||
with:
|
||||
name: Test Results
|
||||
output-to: step-summary
|
||||
path: '*.trx'
|
||||
reporter: dotnet-trx
|
||||
working-directory: TestResults
|
||||
|
||||
4
.github/workflows/api_docs.yml
vendored
4
.github/workflows/api_docs.yml
vendored
@@ -26,10 +26,10 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v3
|
||||
uses: actions/setup-dotnet@v4
|
||||
id: setup-dotnet
|
||||
|
||||
- name: Create openapi.json
|
||||
|
||||
26
.github/workflows/build.yml
vendored
26
.github/workflows/build.yml
vendored
@@ -5,9 +5,14 @@ on:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
paths-ignore:
|
||||
- 'src/Sonarr.Api.*/openapi.json'
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
paths-ignore:
|
||||
- 'src/NzbDrone.Core/Localization/Core/**'
|
||||
- 'src/Sonarr.Api.*/openapi.json'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -15,9 +20,9 @@ concurrency:
|
||||
|
||||
env:
|
||||
FRAMEWORK: net6.0
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
||||
SONARR_MAJOR_VERSION: 4
|
||||
VERSION: 4.0.1
|
||||
VERSION: 4.0.2
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
@@ -28,10 +33,10 @@ jobs:
|
||||
version: ${{ steps.variables.outputs.version }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v3
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
- name: Setup Environment Variables
|
||||
id: variables
|
||||
@@ -43,6 +48,8 @@ jobs:
|
||||
|
||||
echo "SDK_PATH=${{ env.DOTNET_ROOT }}/sdk/${DOTNET_VERSION}" >> "$GITHUB_ENV"
|
||||
echo "SONARR_VERSION=$SONARR_VERSION" >> "$GITHUB_ENV"
|
||||
echo "BRANCH=${RAW_BRANCH_NAME/\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
echo "framework=${{ env.FRAMEWORK }}" >> "$GITHUB_OUTPUT"
|
||||
echo "major_version=${{ env.SONARR_MAJOR_VERSION }}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$SONARR_VERSION" >> "$GITHUB_OUTPUT"
|
||||
@@ -102,12 +109,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Volta
|
||||
uses: volta-cli/action@v4
|
||||
|
||||
- name: Yarn Intsall
|
||||
- name: Yarn Install
|
||||
run: yarn install
|
||||
|
||||
- name: Lint
|
||||
@@ -144,7 +151,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
@@ -159,7 +166,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
@@ -173,6 +180,7 @@ jobs:
|
||||
integration_test:
|
||||
needs: backend
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
include:
|
||||
@@ -194,7 +202,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
|
||||
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Package
|
||||
uses: ./.github/actions/package
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
41
.github/workflows/publish-test-results.yml
vendored
41
.github/workflows/publish-test-results.yml
vendored
@@ -1,41 +0,0 @@
|
||||
name: Publish Test Results
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Build']
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
report:
|
||||
if: ${{ github.event.workflow_run.conclusion != 'skipped' && github.event.workflow_run.conclusion != 'cancelled' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Download Test Reports
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: test-results
|
||||
pattern: results-*
|
||||
merge-multiple: true
|
||||
repository: ${{ github.event.repository.owner.login }}/${{ github.event.repository.name }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish Test Results
|
||||
uses: phoenix-actions/test-reporting@v12
|
||||
with:
|
||||
list-suites: failed
|
||||
list-tests: failed
|
||||
name: Test Results
|
||||
only-summary: true
|
||||
path: '*.trx'
|
||||
reporter: dotnet-trx
|
||||
working-directory: test-results
|
||||
@@ -38,6 +38,7 @@ export interface AppSectionItemState<T> {
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
error: Error;
|
||||
pendingChanges: Partial<T>;
|
||||
item: T;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,15 @@ import AppSectionState, {
|
||||
AppSectionItemState,
|
||||
AppSectionSaveState,
|
||||
AppSectionSchemaState,
|
||||
PagedAppSectionState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Language from 'Language/Language';
|
||||
import DownloadClient from 'typings/DownloadClient';
|
||||
import ImportList from 'typings/ImportList';
|
||||
import ImportListExclusion from 'typings/ImportListExclusion';
|
||||
import ImportListOptionsSettings from 'typings/ImportListOptionsSettings';
|
||||
import Indexer from 'typings/Indexer';
|
||||
import IndexerFlag from 'typings/IndexerFlag';
|
||||
import Notification from 'typings/Notification';
|
||||
import QualityProfile from 'typings/QualityProfile';
|
||||
import { UiSettings } from 'typings/UiSettings';
|
||||
@@ -40,19 +43,30 @@ export interface ImportListOptionsSettingsAppState
|
||||
extends AppSectionItemState<ImportListOptionsSettings>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface ImportListExclusionsSettingsAppState
|
||||
extends AppSectionState<ImportListExclusion>,
|
||||
AppSectionSaveState,
|
||||
PagedAppSectionState,
|
||||
AppSectionDeleteState {
|
||||
pendingChanges: Partial<ImportListExclusion>;
|
||||
}
|
||||
|
||||
export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>;
|
||||
export type LanguageSettingsAppState = AppSectionState<Language>;
|
||||
export type UiSettingsAppState = AppSectionItemState<UiSettings>;
|
||||
|
||||
interface SettingsAppState {
|
||||
advancedSettings: boolean;
|
||||
downloadClients: DownloadClientAppState;
|
||||
importListExclusions: ImportListExclusionsSettingsAppState;
|
||||
importListOptions: ImportListOptionsSettingsAppState;
|
||||
importLists: ImportListAppState;
|
||||
indexerFlags: IndexerFlagSettingsAppState;
|
||||
indexers: IndexerAppState;
|
||||
languages: LanguageSettingsAppState;
|
||||
notifications: NotificationAppState;
|
||||
qualityProfiles: QualityProfilesAppState;
|
||||
ui: UiSettingsAppState;
|
||||
importListOptions: ImportListOptionsSettingsAppState;
|
||||
}
|
||||
|
||||
export default SettingsAppState;
|
||||
|
||||
@@ -117,7 +117,7 @@ class FileBrowserModalContent extends Component {
|
||||
className={styles.mappedDrivesWarning}
|
||||
kind={kinds.WARNING}
|
||||
>
|
||||
<InlineMarkdown data={translate('MappedNetworkDrivesWindowsService')} />
|
||||
<InlineMarkdown data={translate('MappedNetworkDrivesWindowsService', { url: 'https://wiki.servarr.com/sonarr/faq#why-cant-sonarr-see-my-files-on-a-remote-server' })} />
|
||||
</Alert>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { maxBy } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
@@ -8,6 +9,7 @@ import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import FilterBuilderRow from './FilterBuilderRow';
|
||||
import styles from './FilterBuilderModalContent.css';
|
||||
|
||||
@@ -49,7 +51,7 @@ class FilterBuilderModalContent extends Component {
|
||||
if (id) {
|
||||
dispatchSetFilter({ selectedFilterKey: id });
|
||||
} else {
|
||||
const last = customFilters[customFilters.length -1];
|
||||
const last = maxBy(customFilters, 'id');
|
||||
dispatchSetFilter({ selectedFilterKey: last.id });
|
||||
}
|
||||
|
||||
@@ -107,7 +109,7 @@ class FilterBuilderModalContent extends Component {
|
||||
this.setState({
|
||||
labelErrors: [
|
||||
{
|
||||
message: 'Label is required'
|
||||
message: translate('LabelIsRequired')
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -145,13 +147,13 @@ class FilterBuilderModalContent extends Component {
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
Custom Filter
|
||||
{translate('CustomFilter')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div className={styles.labelContainer}>
|
||||
<div className={styles.label}>
|
||||
Label
|
||||
{translate('Label')}
|
||||
</div>
|
||||
|
||||
<div className={styles.labelInputContainer}>
|
||||
@@ -165,7 +167,9 @@ class FilterBuilderModalContent extends Component {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.label}>Filters</div>
|
||||
<div className={styles.label}>
|
||||
{translate('Filters')}
|
||||
</div>
|
||||
|
||||
<div className={styles.rows}>
|
||||
{
|
||||
@@ -192,7 +196,7 @@ class FilterBuilderModalContent extends Component {
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onCancelPress}>
|
||||
Cancel
|
||||
{translate('Cancel')}
|
||||
</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
@@ -200,7 +204,7 @@ class FilterBuilderModalContent extends Component {
|
||||
error={saveError}
|
||||
onPress={this.onSaveFilterPress}
|
||||
>
|
||||
Save
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@@ -37,8 +37,8 @@ class CustomFilter extends Component {
|
||||
dispatchSetFilter
|
||||
} = this.props;
|
||||
|
||||
// Assume that delete and then unmounting means the delete was successful.
|
||||
// Moving this check to a ancestor would be more accurate, but would have
|
||||
// Assume that delete and then unmounting means the deletion was successful.
|
||||
// Moving this check to an ancestor would be more accurate, but would have
|
||||
// more boilerplate.
|
||||
if (this.state.isDeleting && id === selectedFilterKey) {
|
||||
dispatchSetFilter({ selectedFilterKey: 'all' });
|
||||
|
||||
@@ -26,7 +26,8 @@ function createMapStateToProps() {
|
||||
const values = _.map(filteredItems.sort(sortByName), (downloadClient) => {
|
||||
return {
|
||||
key: downloadClient.id,
|
||||
value: downloadClient.name
|
||||
value: downloadClient.name,
|
||||
hint: `(${downloadClient.id})`
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import DownloadClientSelectInputConnector from './DownloadClientSelectInputConne
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
import EnhancedSelectInputConnector from './EnhancedSelectInputConnector';
|
||||
import FormInputHelpText from './FormInputHelpText';
|
||||
import IndexerFlagsSelectInput from './IndexerFlagsSelectInput';
|
||||
import IndexerSelectInputConnector from './IndexerSelectInputConnector';
|
||||
import KeyValueListInput from './KeyValueListInput';
|
||||
import MonitorEpisodesSelectInput from './MonitorEpisodesSelectInput';
|
||||
@@ -71,6 +72,9 @@ function getComponent(type) {
|
||||
case inputTypes.INDEXER_SELECT:
|
||||
return IndexerSelectInputConnector;
|
||||
|
||||
case inputTypes.INDEXER_FLAGS_SELECT:
|
||||
return IndexerFlagsSelectInput;
|
||||
|
||||
case inputTypes.DOWNLOAD_CLIENT_SELECT:
|
||||
return DownloadClientSelectInputConnector;
|
||||
|
||||
@@ -279,6 +283,7 @@ FormInputGroup.propTypes = {
|
||||
includeNoChange: PropTypes.bool,
|
||||
includeNoChangeDisabled: PropTypes.bool,
|
||||
selectedValueOptions: PropTypes.object,
|
||||
indexerFlags: PropTypes.number,
|
||||
pending: PropTypes.bool,
|
||||
errors: PropTypes.arrayOf(PropTypes.object),
|
||||
warnings: PropTypes.arrayOf(PropTypes.object),
|
||||
|
||||
62
frontend/src/Components/Form/IndexerFlagsSelectInput.tsx
Normal file
62
frontend/src/Components/Form/IndexerFlagsSelectInput.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
|
||||
const selectIndexerFlagsValues = (selectedFlags: number) =>
|
||||
createSelector(
|
||||
(state: AppState) => state.settings.indexerFlags,
|
||||
(indexerFlags) => {
|
||||
const value = indexerFlags.items.reduce((acc: number[], { id }) => {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if ((selectedFlags & id) === id) {
|
||||
acc.push(id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const values = indexerFlags.items.map(({ id, name }) => ({
|
||||
key: id,
|
||||
value: name,
|
||||
}));
|
||||
|
||||
return {
|
||||
value,
|
||||
values,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
interface IndexerFlagsSelectInputProps {
|
||||
name: string;
|
||||
indexerFlags: number;
|
||||
onChange(payload: object): void;
|
||||
}
|
||||
|
||||
function IndexerFlagsSelectInput(props: IndexerFlagsSelectInputProps) {
|
||||
const { indexerFlags, onChange } = props;
|
||||
|
||||
const { value, values } = useSelector(selectIndexerFlagsValues(indexerFlags));
|
||||
|
||||
const onChangeWrapper = useCallback(
|
||||
({ name, value }: { name: string; value: number[] }) => {
|
||||
const indexerFlags = value.reduce((acc, flagId) => acc + flagId, 0);
|
||||
|
||||
onChange({ name, value: indexerFlags });
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<EnhancedSelectInput
|
||||
{...props}
|
||||
value={value}
|
||||
values={values}
|
||||
onChange={onChangeWrapper}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default IndexerFlagsSelectInput;
|
||||
@@ -91,6 +91,7 @@ class TextTagInputConnector extends Component {
|
||||
render() {
|
||||
return (
|
||||
<TagInput
|
||||
delimiters={['Tab', 'Enter', ',']}
|
||||
tagList={[]}
|
||||
onTagAdd={this.onTagAdd}
|
||||
onTagDelete={this.onTagDelete}
|
||||
|
||||
@@ -6,7 +6,13 @@ import { createSelector } from 'reselect';
|
||||
import { fetchTranslations, saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions';
|
||||
import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
|
||||
import { fetchSeries } from 'Store/Actions/seriesActions';
|
||||
import { fetchImportLists, fetchLanguages, fetchQualityProfiles, fetchUISettings } from 'Store/Actions/settingsActions';
|
||||
import {
|
||||
fetchImportLists,
|
||||
fetchIndexerFlags,
|
||||
fetchLanguages,
|
||||
fetchQualityProfiles,
|
||||
fetchUISettings
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import { fetchStatus } from 'Store/Actions/systemActions';
|
||||
import { fetchTags } from 'Store/Actions/tagActions';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
@@ -51,6 +57,7 @@ const selectIsPopulated = createSelector(
|
||||
(state) => state.settings.qualityProfiles.isPopulated,
|
||||
(state) => state.settings.languages.isPopulated,
|
||||
(state) => state.settings.importLists.isPopulated,
|
||||
(state) => state.settings.indexerFlags.isPopulated,
|
||||
(state) => state.system.status.isPopulated,
|
||||
(state) => state.app.translations.isPopulated,
|
||||
(
|
||||
@@ -61,6 +68,7 @@ const selectIsPopulated = createSelector(
|
||||
qualityProfilesIsPopulated,
|
||||
languagesIsPopulated,
|
||||
importListsIsPopulated,
|
||||
indexerFlagsIsPopulated,
|
||||
systemStatusIsPopulated,
|
||||
translationsIsPopulated
|
||||
) => {
|
||||
@@ -72,6 +80,7 @@ const selectIsPopulated = createSelector(
|
||||
qualityProfilesIsPopulated &&
|
||||
languagesIsPopulated &&
|
||||
importListsIsPopulated &&
|
||||
indexerFlagsIsPopulated &&
|
||||
systemStatusIsPopulated &&
|
||||
translationsIsPopulated
|
||||
);
|
||||
@@ -86,6 +95,7 @@ const selectErrors = createSelector(
|
||||
(state) => state.settings.qualityProfiles.error,
|
||||
(state) => state.settings.languages.error,
|
||||
(state) => state.settings.importLists.error,
|
||||
(state) => state.settings.indexerFlags.error,
|
||||
(state) => state.system.status.error,
|
||||
(state) => state.app.translations.error,
|
||||
(
|
||||
@@ -96,6 +106,7 @@ const selectErrors = createSelector(
|
||||
qualityProfilesError,
|
||||
languagesError,
|
||||
importListsError,
|
||||
indexerFlagsError,
|
||||
systemStatusError,
|
||||
translationsError
|
||||
) => {
|
||||
@@ -107,6 +118,7 @@ const selectErrors = createSelector(
|
||||
qualityProfilesError ||
|
||||
languagesError ||
|
||||
importListsError ||
|
||||
indexerFlagsError ||
|
||||
systemStatusError ||
|
||||
translationsError
|
||||
);
|
||||
@@ -120,6 +132,7 @@ const selectErrors = createSelector(
|
||||
qualityProfilesError,
|
||||
languagesError,
|
||||
importListsError,
|
||||
indexerFlagsError,
|
||||
systemStatusError,
|
||||
translationsError
|
||||
};
|
||||
@@ -174,6 +187,9 @@ function createMapDispatchToProps(dispatch, props) {
|
||||
dispatchFetchImportLists() {
|
||||
dispatch(fetchImportLists());
|
||||
},
|
||||
dispatchFetchIndexerFlags() {
|
||||
dispatch(fetchIndexerFlags());
|
||||
},
|
||||
dispatchFetchUISettings() {
|
||||
dispatch(fetchUISettings());
|
||||
},
|
||||
@@ -213,6 +229,7 @@ class PageConnector extends Component {
|
||||
this.props.dispatchFetchQualityProfiles();
|
||||
this.props.dispatchFetchLanguages();
|
||||
this.props.dispatchFetchImportLists();
|
||||
this.props.dispatchFetchIndexerFlags();
|
||||
this.props.dispatchFetchUISettings();
|
||||
this.props.dispatchFetchStatus();
|
||||
this.props.dispatchFetchTranslations();
|
||||
@@ -238,6 +255,7 @@ class PageConnector extends Component {
|
||||
dispatchFetchQualityProfiles,
|
||||
dispatchFetchLanguages,
|
||||
dispatchFetchImportLists,
|
||||
dispatchFetchIndexerFlags,
|
||||
dispatchFetchUISettings,
|
||||
dispatchFetchStatus,
|
||||
dispatchFetchTranslations,
|
||||
@@ -278,6 +296,7 @@ PageConnector.propTypes = {
|
||||
dispatchFetchQualityProfiles: PropTypes.func.isRequired,
|
||||
dispatchFetchLanguages: PropTypes.func.isRequired,
|
||||
dispatchFetchImportLists: PropTypes.func.isRequired,
|
||||
dispatchFetchIndexerFlags: PropTypes.func.isRequired,
|
||||
dispatchFetchUISettings: PropTypes.func.isRequired,
|
||||
dispatchFetchStatus: PropTypes.func.isRequired,
|
||||
dispatchFetchTranslations: PropTypes.func.isRequired,
|
||||
|
||||
26
frontend/src/Episode/IndexerFlags.tsx
Normal file
26
frontend/src/Episode/IndexerFlags.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import createIndexerFlagsSelector from 'Store/Selectors/createIndexerFlagsSelector';
|
||||
|
||||
interface IndexerFlagsProps {
|
||||
indexerFlags: number;
|
||||
}
|
||||
|
||||
function IndexerFlags({ indexerFlags = 0 }: IndexerFlagsProps) {
|
||||
const allIndexerFlags = useSelector(createIndexerFlagsSelector);
|
||||
|
||||
const flags = allIndexerFlags.items.filter(
|
||||
// eslint-disable-next-line no-bitwise
|
||||
(item) => (indexerFlags & item.id) === item.id
|
||||
);
|
||||
|
||||
return flags.length ? (
|
||||
<ul>
|
||||
{flags.map((flag, index) => {
|
||||
return <li key={index}>{flag.name}</li>;
|
||||
})}
|
||||
</ul>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export default IndexerFlags;
|
||||
@@ -16,6 +16,7 @@ export interface EpisodeFile extends ModelBase {
|
||||
languages: Language[];
|
||||
quality: QualityModel;
|
||||
customFormats: CustomFormat[];
|
||||
indexerFlags: number;
|
||||
mediaInfo: MediaInfo;
|
||||
qualityCutoffNotMet: boolean;
|
||||
}
|
||||
|
||||
17
frontend/src/Helpers/Hooks/useModalOpenState.ts
Normal file
17
frontend/src/Helpers/Hooks/useModalOpenState.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export default function useModalOpenState(
|
||||
initialState: boolean
|
||||
): [boolean, () => void, () => void] {
|
||||
const [isOpen, setOpen] = useState(initialState);
|
||||
|
||||
const setModalOpen = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, [setOpen]);
|
||||
|
||||
const setModalClosed = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
return [isOpen, setModalOpen, setModalClosed];
|
||||
}
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
faFileExport as fasFileExport,
|
||||
faFileInvoice as farFileInvoice,
|
||||
faFilter as fasFilter,
|
||||
faFlag as fasFlag,
|
||||
faFolderOpen as fasFolderOpen,
|
||||
faForward as fasForward,
|
||||
faHeart as fasHeart,
|
||||
@@ -154,6 +155,7 @@ export const FILE_MISSING = fasFileCircleQuestion;
|
||||
export const FILTER = fasFilter;
|
||||
export const FINALE_SEASON = fasCirclePause;
|
||||
export const FINALE_SERIES = fasCircleStop;
|
||||
export const FLAG = fasFlag;
|
||||
export const FOOTNOTE = fasAsterisk;
|
||||
export const FOLDER = farFolder;
|
||||
export const FOLDER_OPEN = fasFolderOpen;
|
||||
|
||||
@@ -12,6 +12,7 @@ export const PASSWORD = 'password';
|
||||
export const PATH = 'path';
|
||||
export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect';
|
||||
export const INDEXER_SELECT = 'indexerSelect';
|
||||
export const INDEXER_FLAGS_SELECT = 'indexerFlagsSelect';
|
||||
export const LANGUAGE_SELECT = 'languageSelect';
|
||||
export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect';
|
||||
export const ROOT_FOLDER_SELECT = 'rootFolderSelect';
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import SelectIndexerFlagsModalContent from './SelectIndexerFlagsModalContent';
|
||||
|
||||
interface SelectIndexerFlagsModalProps {
|
||||
isOpen: boolean;
|
||||
indexerFlags: number;
|
||||
modalTitle: string;
|
||||
onIndexerFlagsSelect(indexerFlags: number): void;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function SelectIndexerFlagsModal(props: SelectIndexerFlagsModalProps) {
|
||||
const {
|
||||
isOpen,
|
||||
indexerFlags,
|
||||
modalTitle,
|
||||
onIndexerFlagsSelect,
|
||||
onModalClose,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<SelectIndexerFlagsModalContent
|
||||
indexerFlags={indexerFlags}
|
||||
modalTitle={modalTitle}
|
||||
onIndexerFlagsSelect={onIndexerFlagsSelect}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectIndexerFlagsModal;
|
||||
@@ -0,0 +1,7 @@
|
||||
.modalBody {
|
||||
composes: modalBody from '~Components/Modal/ModalBody.css';
|
||||
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
7
frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts
vendored
Normal file
7
frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'modalBody': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Button from 'Components/Link/Button';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes, kinds, scrollDirections } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './SelectIndexerFlagsModalContent.css';
|
||||
|
||||
interface SelectIndexerFlagsModalContentProps {
|
||||
indexerFlags: number;
|
||||
modalTitle: string;
|
||||
onIndexerFlagsSelect(indexerFlags: number): void;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function SelectIndexerFlagsModalContent(
|
||||
props: SelectIndexerFlagsModalContentProps
|
||||
) {
|
||||
const { modalTitle, onIndexerFlagsSelect, onModalClose } = props;
|
||||
const [indexerFlags, setIndexerFlags] = useState(props.indexerFlags);
|
||||
|
||||
const onIndexerFlagsChange = useCallback(
|
||||
({ value }: { value: number }) => {
|
||||
setIndexerFlags(value);
|
||||
},
|
||||
[setIndexerFlags]
|
||||
);
|
||||
|
||||
const onIndexerFlagsSelectWrapper = useCallback(() => {
|
||||
onIndexerFlagsSelect(indexerFlags);
|
||||
}, [indexerFlags, onIndexerFlagsSelect]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('SetIndexerFlagsModalTitle', { modalTitle })}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody
|
||||
className={styles.modalBody}
|
||||
scrollDirection={scrollDirections.NONE}
|
||||
>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IndexerFlags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.INDEXER_FLAGS_SELECT}
|
||||
name="indexerFlags"
|
||||
indexerFlags={indexerFlags}
|
||||
autoFocus={true}
|
||||
onChange={onIndexerFlagsChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<Button kind={kinds.SUCCESS} onPress={onIndexerFlagsSelectWrapper}>
|
||||
{translate('SetIndexerFlags')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectIndexerFlagsModalContent;
|
||||
@@ -29,6 +29,7 @@ import { align, icons, kinds, scrollDirections } from 'Helpers/Props';
|
||||
import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal';
|
||||
import { SelectedEpisode } from 'InteractiveImport/Episode/SelectEpisodeModalContent';
|
||||
import ImportMode from 'InteractiveImport/ImportMode';
|
||||
import SelectIndexerFlagsModal from 'InteractiveImport/IndexerFlags/SelectIndexerFlagsModal';
|
||||
import InteractiveImport, {
|
||||
InteractiveImportCommandOptions,
|
||||
} from 'InteractiveImport/InteractiveImport';
|
||||
@@ -71,7 +72,8 @@ type SelectType =
|
||||
| 'episode'
|
||||
| 'releaseGroup'
|
||||
| 'quality'
|
||||
| 'language';
|
||||
| 'language'
|
||||
| 'indexerFlags';
|
||||
|
||||
type FilterExistingFiles = 'all' | 'new';
|
||||
|
||||
@@ -135,11 +137,21 @@ const COLUMNS = [
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'indexerFlags',
|
||||
label: React.createElement(Icon, {
|
||||
name: icons.FLAG,
|
||||
title: () => translate('IndexerFlags'),
|
||||
}),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'rejections',
|
||||
label: React.createElement(Icon, {
|
||||
name: icons.DANGER,
|
||||
kind: kinds.DANGER,
|
||||
title: () => translate('Rejections'),
|
||||
}),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
@@ -284,8 +296,18 @@ function InteractiveImportModalContent(
|
||||
}
|
||||
}
|
||||
|
||||
const showIndexerFlags = items.some((item) => item.indexerFlags);
|
||||
|
||||
if (!showIndexerFlags) {
|
||||
const indexerFlagsColumn = result.find((c) => c.name === 'indexerFlags');
|
||||
|
||||
if (indexerFlagsColumn) {
|
||||
indexerFlagsColumn.isVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [showSeries]);
|
||||
}, [showSeries, items]);
|
||||
|
||||
const selectedIds: number[] = useMemo(() => {
|
||||
return getSelectedIds(selectedState);
|
||||
@@ -343,6 +365,10 @@ function InteractiveImportModalContent(
|
||||
key: 'language',
|
||||
value: translate('SelectLanguage'),
|
||||
},
|
||||
{
|
||||
key: 'indexerFlags',
|
||||
value: translate('SelectIndexerFlags'),
|
||||
},
|
||||
];
|
||||
|
||||
if (allowSeriesChange) {
|
||||
@@ -483,6 +509,7 @@ function InteractiveImportModalContent(
|
||||
releaseGroup,
|
||||
quality,
|
||||
languages,
|
||||
indexerFlags,
|
||||
episodeFileId,
|
||||
} = item;
|
||||
|
||||
@@ -532,6 +559,7 @@ function InteractiveImportModalContent(
|
||||
releaseGroup,
|
||||
quality,
|
||||
languages,
|
||||
indexerFlags,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -546,6 +574,7 @@ function InteractiveImportModalContent(
|
||||
releaseGroup,
|
||||
quality,
|
||||
languages,
|
||||
indexerFlags,
|
||||
downloadId,
|
||||
episodeFileId,
|
||||
});
|
||||
@@ -742,6 +771,22 @@ function InteractiveImportModalContent(
|
||||
[selectedIds, dispatch]
|
||||
);
|
||||
|
||||
const onIndexerFlagsSelect = useCallback(
|
||||
(indexerFlags: number) => {
|
||||
dispatch(
|
||||
updateInteractiveImportItems({
|
||||
ids: selectedIds,
|
||||
indexerFlags,
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
|
||||
|
||||
setSelectModalOpen(null);
|
||||
},
|
||||
[selectedIds, dispatch]
|
||||
);
|
||||
|
||||
const orderedSelectedIds = items.reduce((acc: number[], file) => {
|
||||
if (selectedIds.includes(file.id)) {
|
||||
acc.push(file.id);
|
||||
@@ -947,6 +992,14 @@ function InteractiveImportModalContent(
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
|
||||
<SelectIndexerFlagsModal
|
||||
isOpen={selectModalOpen === 'indexerFlags'}
|
||||
indexerFlags={0}
|
||||
modalTitle={modalTitle}
|
||||
onIndexerFlagsSelect={onIndexerFlagsSelect}
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isConfirmDeleteModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
|
||||
@@ -12,9 +12,11 @@ import Episode from 'Episode/Episode';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import IndexerFlags from 'Episode/IndexerFlags';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal';
|
||||
import { SelectedEpisode } from 'InteractiveImport/Episode/SelectEpisodeModalContent';
|
||||
import SelectIndexerFlagsModal from 'InteractiveImport/IndexerFlags/SelectIndexerFlagsModal';
|
||||
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
|
||||
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
|
||||
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
|
||||
@@ -41,7 +43,8 @@ type SelectType =
|
||||
| 'episode'
|
||||
| 'releaseGroup'
|
||||
| 'quality'
|
||||
| 'language';
|
||||
| 'language'
|
||||
| 'indexerFlags';
|
||||
|
||||
type SelectedChangeProps = SelectStateInputProps & {
|
||||
hasEpisodeFileId: boolean;
|
||||
@@ -60,6 +63,7 @@ interface InteractiveImportRowProps {
|
||||
size: number;
|
||||
customFormats?: object[];
|
||||
customFormatScore?: number;
|
||||
indexerFlags: number;
|
||||
rejections: Rejection[];
|
||||
columns: Column[];
|
||||
episodeFileId?: number;
|
||||
@@ -84,6 +88,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
size,
|
||||
customFormats,
|
||||
customFormatScore,
|
||||
indexerFlags,
|
||||
rejections,
|
||||
isReprocessing,
|
||||
isSelected,
|
||||
@@ -100,6 +105,10 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
() => columns.find((c) => c.name === 'series')?.isVisible ?? false,
|
||||
[columns]
|
||||
);
|
||||
const isIndexerFlagsColumnVisible = useMemo(
|
||||
() => columns.find((c) => c.name === 'indexerFlags')?.isVisible ?? false,
|
||||
[columns]
|
||||
);
|
||||
|
||||
const [selectModalOpen, setSelectModalOpen] = useState<SelectType | null>(
|
||||
null
|
||||
@@ -306,6 +315,27 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
|
||||
);
|
||||
|
||||
const onSelectIndexerFlagsPress = useCallback(() => {
|
||||
setSelectModalOpen('indexerFlags');
|
||||
}, [setSelectModalOpen]);
|
||||
|
||||
const onIndexerFlagsSelect = useCallback(
|
||||
(indexerFlags: number) => {
|
||||
dispatch(
|
||||
updateInteractiveImportItem({
|
||||
id,
|
||||
indexerFlags,
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
|
||||
|
||||
setSelectModalOpen(null);
|
||||
selectRowAfterChange();
|
||||
},
|
||||
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
|
||||
);
|
||||
|
||||
const seriesTitle = series ? series.title : '';
|
||||
const isAnime = series?.seriesType === 'anime';
|
||||
|
||||
@@ -332,6 +362,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
const showReleaseGroupPlaceholder = isSelected && !releaseGroup;
|
||||
const showQualityPlaceholder = isSelected && !quality;
|
||||
const showLanguagePlaceholder = isSelected && !languages;
|
||||
const showIndexerFlagsPlaceholder = isSelected && !indexerFlags;
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
@@ -448,6 +479,28 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
) : null}
|
||||
</TableRowCell>
|
||||
|
||||
{isIndexerFlagsColumnVisible ? (
|
||||
<TableRowCellButton
|
||||
title={translate('ClickToChangeIndexerFlags')}
|
||||
onPress={onSelectIndexerFlagsPress}
|
||||
>
|
||||
{showIndexerFlagsPlaceholder ? (
|
||||
<InteractiveImportRowCellPlaceholder isOptional={true} />
|
||||
) : (
|
||||
<>
|
||||
{indexerFlags ? (
|
||||
<Popover
|
||||
anchor={<Icon name={icons.FLAG} kind={kinds.PRIMARY} />}
|
||||
title={translate('IndexerFlags')}
|
||||
body={<IndexerFlags indexerFlags={indexerFlags} />}
|
||||
position={tooltipPositions.LEFT}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</TableRowCellButton>
|
||||
) : null}
|
||||
|
||||
<TableRowCell>
|
||||
{rejections.length ? (
|
||||
<Popover
|
||||
@@ -518,6 +571,14 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
onLanguagesSelect={onLanguagesSelect}
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
|
||||
<SelectIndexerFlagsModal
|
||||
isOpen={selectModalOpen === 'indexerFlags'}
|
||||
indexerFlags={indexerFlags ?? 0}
|
||||
modalTitle={modalTitle}
|
||||
onIndexerFlagsSelect={onIndexerFlagsSelect}
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface InteractiveImportCommandOptions {
|
||||
releaseGroup?: string;
|
||||
quality: QualityModel;
|
||||
languages: Language[];
|
||||
indexerFlags: number;
|
||||
downloadId?: string;
|
||||
episodeFileId?: number;
|
||||
}
|
||||
@@ -31,6 +32,7 @@ interface InteractiveImport extends ModelBase {
|
||||
episodes: Episode[];
|
||||
qualityWeight: number;
|
||||
customFormats: object[];
|
||||
indexerFlags: number;
|
||||
rejections: Rejection[];
|
||||
episodeFileId?: number;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,15 @@ const columns = [
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'indexerFlags',
|
||||
label: React.createElement(Icon, {
|
||||
name: icons.FLAG,
|
||||
title: () => translate('IndexerFlags')
|
||||
}),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'rejections',
|
||||
label: React.createElement(Icon, {
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.rejected {
|
||||
.rejected,
|
||||
.indexerFlags {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 50px;
|
||||
|
||||
@@ -6,6 +6,7 @@ interface CssExports {
|
||||
'download': string;
|
||||
'downloadIcon': string;
|
||||
'indexer': string;
|
||||
'indexerFlags': string;
|
||||
'interactiveIcon': string;
|
||||
'languages': string;
|
||||
'manualDownloadContent': string;
|
||||
|
||||
@@ -12,6 +12,7 @@ import type DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import IndexerFlags from 'Episode/IndexerFlags';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
@@ -98,6 +99,7 @@ interface InteractiveSearchRowProps {
|
||||
mappedEpisodeNumbers?: number[];
|
||||
mappedAbsoluteEpisodeNumbers?: number[];
|
||||
mappedEpisodeInfo: ReleaseEpisode[];
|
||||
indexerFlags: number;
|
||||
rejections: string[];
|
||||
episodeRequested: boolean;
|
||||
downloadAllowed: boolean;
|
||||
@@ -139,6 +141,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
|
||||
mappedEpisodeNumbers,
|
||||
mappedAbsoluteEpisodeNumbers,
|
||||
mappedEpisodeInfo,
|
||||
indexerFlags = 0,
|
||||
rejections = [],
|
||||
episodeRequested,
|
||||
downloadAllowed,
|
||||
@@ -254,10 +257,21 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
|
||||
customFormats.length
|
||||
)}
|
||||
tooltip={<EpisodeFormats formats={customFormats} />}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
position={tooltipPositions.LEFT}
|
||||
/>
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.indexerFlags}>
|
||||
{indexerFlags ? (
|
||||
<Popover
|
||||
anchor={<Icon name={icons.FLAG} kind={kinds.PRIMARY} />}
|
||||
title={translate('IndexerFlags')}
|
||||
body={<IndexerFlags indexerFlags={indexerFlags} />}
|
||||
position={tooltipPositions.LEFT}
|
||||
/>
|
||||
) : null}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.rejected}>
|
||||
{rejections.length ? (
|
||||
<Popover
|
||||
|
||||
@@ -62,3 +62,9 @@
|
||||
|
||||
width: 55px;
|
||||
}
|
||||
|
||||
.indexerFlags {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ interface CssExports {
|
||||
'customFormatScore': string;
|
||||
'episodeNumber': string;
|
||||
'episodeNumberAnime': string;
|
||||
'indexerFlags': string;
|
||||
'languages': string;
|
||||
'monitored': string;
|
||||
'releaseGroup': string;
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeNumber from 'Episode/EpisodeNumber';
|
||||
import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector';
|
||||
import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector';
|
||||
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
||||
import IndexerFlags from 'Episode/IndexerFlags';
|
||||
import EpisodeFileLanguageConnector from 'EpisodeFile/EpisodeFileLanguageConnector';
|
||||
import MediaInfoConnector from 'EpisodeFile/MediaInfoConnector';
|
||||
import * as mediaInfoTypes from 'EpisodeFile/mediaInfoTypes';
|
||||
import { tooltipPositions } from 'Helpers/Props';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import formatRuntime from 'Utilities/Number/formatRuntime';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EpisodeRow.css';
|
||||
|
||||
class EpisodeRow extends Component {
|
||||
@@ -77,6 +81,7 @@ class EpisodeRow extends Component {
|
||||
releaseGroup,
|
||||
customFormats,
|
||||
customFormatScore,
|
||||
indexerFlags,
|
||||
alternateTitles,
|
||||
columns
|
||||
} = this.props;
|
||||
@@ -211,7 +216,7 @@ class EpisodeRow extends Component {
|
||||
customFormats.length
|
||||
)}
|
||||
tooltip={<EpisodeFormats formats={customFormats} />}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
position={tooltipPositions.LEFT}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
@@ -322,6 +327,24 @@ class EpisodeRow extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'indexerFlags') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.indexerFlags}
|
||||
>
|
||||
{indexerFlags ? (
|
||||
<Popover
|
||||
anchor={<Icon name={icons.FLAG} kind={kinds.PRIMARY} />}
|
||||
title={translate('IndexerFlags')}
|
||||
body={<IndexerFlags indexerFlags={indexerFlags} />}
|
||||
position={tooltipPositions.LEFT}
|
||||
/>
|
||||
) : null}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'status') {
|
||||
return (
|
||||
<TableRowCell
|
||||
@@ -381,6 +404,7 @@ EpisodeRow.propTypes = {
|
||||
releaseGroup: PropTypes.string,
|
||||
customFormats: PropTypes.arrayOf(PropTypes.object),
|
||||
customFormatScore: PropTypes.number.isRequired,
|
||||
indexerFlags: PropTypes.number.isRequired,
|
||||
mediaInfo: PropTypes.object,
|
||||
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
@@ -389,7 +413,8 @@ EpisodeRow.propTypes = {
|
||||
|
||||
EpisodeRow.defaultProps = {
|
||||
alternateTitles: [],
|
||||
customFormats: []
|
||||
customFormats: [],
|
||||
indexerFlags: 0
|
||||
};
|
||||
|
||||
export default EpisodeRow;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint max-params: 0 */
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
|
||||
@@ -20,6 +19,7 @@ function createMapStateToProps() {
|
||||
releaseGroup: episodeFile ? episodeFile.releaseGroup : null,
|
||||
customFormats: episodeFile ? episodeFile.customFormats : [],
|
||||
customFormatScore: episodeFile ? episodeFile.customFormatScore : 0,
|
||||
indexerFlags: episodeFile ? episodeFile.indexerFlags : 0,
|
||||
alternateTitles: series.alternateTitles
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,10 +55,10 @@ function EditSpecificationModalContent(props) {
|
||||
<InlineMarkdown data={translate('ConditionUsingRegularExpressions')} />
|
||||
</div>
|
||||
<div>
|
||||
<InlineMarkdown data={translate('RegularExpressionsTutorialLink')} />
|
||||
<InlineMarkdown data={translate('RegularExpressionsTutorialLink', { url: 'https://www.regular-expressions.info/tutorial.html' })} />
|
||||
</div>
|
||||
<div>
|
||||
<InlineMarkdown data={translate('RegularExpressionsCanBeTested')} />
|
||||
<InlineMarkdown data={translate('RegularExpressionsCanBeTested', { url: 'http://regexstorm.net/tester' })} />
|
||||
</div>
|
||||
</Alert>
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import EditImportListExclusionModalContentConnector from './EditImportListExclusionModalContentConnector';
|
||||
|
||||
function EditImportListExclusionModal({ isOpen, onModalClose, ...otherProps }) {
|
||||
return (
|
||||
<Modal
|
||||
size={sizes.MEDIUM}
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<EditImportListExclusionModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
EditImportListExclusionModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EditImportListExclusionModal;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import EditImportListExclusionModalContent from './EditImportListExclusionModalContent';
|
||||
|
||||
interface EditImportListExclusionModalProps {
|
||||
id?: number;
|
||||
isOpen: boolean;
|
||||
onModalClose: () => void;
|
||||
onDeleteImportListExclusionPress?: () => void;
|
||||
}
|
||||
|
||||
function EditImportListExclusionModal(
|
||||
props: EditImportListExclusionModalProps
|
||||
) {
|
||||
const { isOpen, onModalClose, ...otherProps } = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onModalClosePress = useCallback(() => {
|
||||
dispatch(
|
||||
clearPendingChanges({
|
||||
section: 'settings.importListExclusions',
|
||||
})
|
||||
);
|
||||
onModalClose();
|
||||
}, [dispatch, onModalClose]);
|
||||
|
||||
return (
|
||||
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={onModalClosePress}>
|
||||
<EditImportListExclusionModalContent
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditImportListExclusionModal;
|
||||
@@ -1,43 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import EditImportListExclusionModal from './EditImportListExclusionModal';
|
||||
|
||||
function mapStateToProps() {
|
||||
return {};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
clearPendingChanges
|
||||
};
|
||||
|
||||
class EditImportListExclusionModalConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onModalClose = () => {
|
||||
this.props.clearPendingChanges({ section: 'settings.importListExclusions' });
|
||||
this.props.onModalClose();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EditImportListExclusionModal
|
||||
{...this.props}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditImportListExclusionModalConnector.propTypes = {
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
clearPendingChanges: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EditImportListExclusionModalConnector);
|
||||
@@ -1,139 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes, kinds } from 'Helpers/Props';
|
||||
import { numberSettingShape, stringSettingShape } from 'Helpers/Props/Shapes/settingShape';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EditImportListExclusionModalContent.css';
|
||||
|
||||
function EditImportListExclusionModalContent(props) {
|
||||
const {
|
||||
id,
|
||||
isFetching,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
item,
|
||||
onInputChange,
|
||||
onSavePress,
|
||||
onModalClose,
|
||||
onDeleteImportListExclusionPress,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const {
|
||||
title,
|
||||
tvdbId
|
||||
} = item;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{id ? translate('EditImportListExclusion') : translate('AddImportListExclusion')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody className={styles.body}>
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !!error &&
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('AddImportListExclusionError')}
|
||||
</Alert>
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !error &&
|
||||
<Form
|
||||
{...otherProps}
|
||||
>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Title')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="title"
|
||||
helpText={translate('SeriesTitleToExcludeHelpText')}
|
||||
{...title}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('TvdbId')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="tvdbId"
|
||||
helpText={translate('TvdbIdExcludeHelpText')}
|
||||
{...tvdbId}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{
|
||||
id &&
|
||||
<Button
|
||||
className={styles.deleteButton}
|
||||
kind={kinds.DANGER}
|
||||
onPress={onDeleteImportListExclusionPress}
|
||||
>
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
}
|
||||
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Cancel')}
|
||||
</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isSaving}
|
||||
error={saveError}
|
||||
onPress={onSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
const ImportListExclusionShape = {
|
||||
title: PropTypes.shape(stringSettingShape).isRequired,
|
||||
tvdbId: PropTypes.shape(numberSettingShape).isRequired
|
||||
};
|
||||
|
||||
EditImportListExclusionModalContent.propTypes = {
|
||||
id: PropTypes.number,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
item: PropTypes.shape(ImportListExclusionShape).isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onSavePress: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
onDeleteImportListExclusionPress: PropTypes.func
|
||||
};
|
||||
|
||||
export default EditImportListExclusionModalContent;
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Alert from 'Components/Alert';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { inputTypes, kinds } from 'Helpers/Props';
|
||||
import {
|
||||
saveImportListExclusion,
|
||||
setImportListExclusionValue,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import ImportListExclusion from 'typings/ImportListExclusion';
|
||||
import { PendingSection } from 'typings/pending';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EditImportListExclusionModalContent.css';
|
||||
|
||||
const newImportListExclusion = {
|
||||
title: '',
|
||||
tvdbId: 0,
|
||||
};
|
||||
|
||||
interface EditImportListExclusionModalContentProps {
|
||||
id?: number;
|
||||
onModalClose: () => void;
|
||||
onDeleteImportListExclusionPress?: () => void;
|
||||
}
|
||||
|
||||
function createImportListExclusionSelector(id?: number) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.importListExclusions,
|
||||
(importListExclusions) => {
|
||||
const { isFetching, error, isSaving, saveError, pendingChanges, items } =
|
||||
importListExclusions;
|
||||
|
||||
const mapping = id
|
||||
? items.find((i) => i.id === id)
|
||||
: newImportListExclusion;
|
||||
const settings = selectSettings(mapping, pendingChanges, saveError);
|
||||
|
||||
return {
|
||||
id,
|
||||
isFetching,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
item: settings.settings as PendingSection<ImportListExclusion>,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function EditImportListExclusionModalContent(
|
||||
props: EditImportListExclusionModalContentProps
|
||||
) {
|
||||
const { id, onModalClose, onDeleteImportListExclusionPress } = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const dispatchSetImportListExclusionValue = (payload: {
|
||||
name: string;
|
||||
value: string | number;
|
||||
}) => {
|
||||
// @ts-expect-error 'setImportListExclusionValue' isn't typed yet
|
||||
dispatch(setImportListExclusionValue(payload));
|
||||
};
|
||||
|
||||
const { isFetching, isSaving, item, error, saveError, ...otherProps } =
|
||||
useSelector(createImportListExclusionSelector(props.id));
|
||||
const previousIsSaving = usePrevious(isSaving);
|
||||
|
||||
const { title, tvdbId } = item;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
Object.keys(newImportListExclusion).forEach((name) => {
|
||||
dispatchSetImportListExclusionValue({
|
||||
name,
|
||||
value:
|
||||
newImportListExclusion[name as keyof typeof newImportListExclusion],
|
||||
});
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousIsSaving && !isSaving && !saveError) {
|
||||
onModalClose();
|
||||
}
|
||||
});
|
||||
|
||||
const onSavePress = useCallback(() => {
|
||||
dispatch(saveImportListExclusion({ id }));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const onInputChange = useCallback(
|
||||
(payload: { name: string; value: string | number }) => {
|
||||
// @ts-expect-error 'setImportListExclusionValue' isn't typed yet
|
||||
dispatch(setImportListExclusionValue(payload));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{id
|
||||
? translate('EditImportListExclusion')
|
||||
: translate('AddImportListExclusion')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody className={styles.body}>
|
||||
{isFetching && <LoadingIndicator />}
|
||||
|
||||
{!isFetching && !!error && (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('AddImportListExclusionError')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isFetching && !error && (
|
||||
<Form {...otherProps}>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Title')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="title"
|
||||
helpText={translate('SeriesTitleToExcludeHelpText')}
|
||||
{...title}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('TvdbId')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="tvdbId"
|
||||
helpText={translate('TvdbIdExcludeHelpText')}
|
||||
{...tvdbId}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
)}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{id && (
|
||||
<Button
|
||||
className={styles.deleteButton}
|
||||
kind={kinds.DANGER}
|
||||
onPress={onDeleteImportListExclusionPress}
|
||||
>
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isSaving}
|
||||
error={saveError}
|
||||
onPress={onSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditImportListExclusionModalContent;
|
||||
@@ -1,117 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { saveImportListExclusion, setImportListExclusionValue } from 'Store/Actions/settingsActions';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import EditImportListExclusionModalContent from './EditImportListExclusionModalContent';
|
||||
|
||||
const newImportListExclusion = {
|
||||
title: '',
|
||||
tvdbId: 0
|
||||
};
|
||||
|
||||
function createImportListExclusionSelector() {
|
||||
return createSelector(
|
||||
(state, { id }) => id,
|
||||
(state) => state.settings.importListExclusions,
|
||||
(id, importListExclusions) => {
|
||||
const {
|
||||
isFetching,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
pendingChanges,
|
||||
items
|
||||
} = importListExclusions;
|
||||
|
||||
const mapping = id ? items.find((i) => i.id === id) : newImportListExclusion;
|
||||
const settings = selectSettings(mapping, pendingChanges, saveError);
|
||||
|
||||
return {
|
||||
id,
|
||||
isFetching,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
item: settings.settings,
|
||||
...settings
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createImportListExclusionSelector(),
|
||||
(importListExclusion) => {
|
||||
return {
|
||||
...importListExclusion
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setImportListExclusionValue,
|
||||
saveImportListExclusion
|
||||
};
|
||||
|
||||
class EditImportListExclusionModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.id) {
|
||||
Object.keys(newImportListExclusion).forEach((name) => {
|
||||
this.props.setImportListExclusionValue({
|
||||
name,
|
||||
value: newImportListExclusion[name]
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
|
||||
this.props.onModalClose();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.props.setImportListExclusionValue({ name, value });
|
||||
};
|
||||
|
||||
onSavePress = () => {
|
||||
this.props.saveImportListExclusion({ id: this.props.id });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EditImportListExclusionModalContent
|
||||
{...this.props}
|
||||
onSavePress={this.onSavePress}
|
||||
onInputChange={this.onInputChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditImportListExclusionModalContentConnector.propTypes = {
|
||||
id: PropTypes.number,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
item: PropTypes.object.isRequired,
|
||||
setImportListExclusionValue: PropTypes.func.isRequired,
|
||||
saveImportListExclusion: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(EditImportListExclusionModalContentConnector);
|
||||
@@ -1,25 +0,0 @@
|
||||
.importListExclusion {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
margin-bottom: 10px;
|
||||
height: 30px;
|
||||
border-bottom: 1px solid var(--borderColor);
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.title {
|
||||
@add-mixin truncate;
|
||||
|
||||
flex: 0 1 600px;
|
||||
}
|
||||
|
||||
.tvdbId {
|
||||
flex: 0 0 70px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex: 1 0 auto;
|
||||
padding-right: 10px;
|
||||
}
|
||||
@@ -2,9 +2,6 @@
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'actions': string;
|
||||
'importListExclusion': string;
|
||||
'title': string;
|
||||
'tvdbId': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditImportListExclusionModalConnector from './EditImportListExclusionModalConnector';
|
||||
import styles from './ImportListExclusion.css';
|
||||
|
||||
class ImportListExclusion extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isEditImportListExclusionModalOpen: false,
|
||||
isDeleteImportListExclusionModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onEditImportListExclusionPress = () => {
|
||||
this.setState({ isEditImportListExclusionModalOpen: true });
|
||||
};
|
||||
|
||||
onEditImportListExclusionModalClose = () => {
|
||||
this.setState({ isEditImportListExclusionModalOpen: false });
|
||||
};
|
||||
|
||||
onDeleteImportListExclusionPress = () => {
|
||||
this.setState({
|
||||
isEditImportListExclusionModalOpen: false,
|
||||
isDeleteImportListExclusionModalOpen: true
|
||||
});
|
||||
};
|
||||
|
||||
onDeleteImportListExclusionModalClose = () => {
|
||||
this.setState({ isDeleteImportListExclusionModalOpen: false });
|
||||
};
|
||||
|
||||
onConfirmDeleteImportListExclusion = () => {
|
||||
this.props.onConfirmDeleteImportListExclusion(this.props.id);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
tvdbId
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.importListExclusion
|
||||
)}
|
||||
>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<div className={styles.tvdbId}>{tvdbId}</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link
|
||||
onPress={this.onEditImportListExclusionPress}
|
||||
>
|
||||
<Icon name={icons.EDIT} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<EditImportListExclusionModalConnector
|
||||
id={id}
|
||||
isOpen={this.state.isEditImportListExclusionModalOpen}
|
||||
onModalClose={this.onEditImportListExclusionModalClose}
|
||||
onDeleteImportListExclusionPress={this.onDeleteImportListExclusionPress}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={this.state.isDeleteImportListExclusionModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('DeleteImportListExclusion')}
|
||||
message={translate('DeleteImportListExclusionMessageText')}
|
||||
confirmLabel={translate('Delete')}
|
||||
onConfirm={this.onConfirmDeleteImportListExclusion}
|
||||
onCancel={this.onDeleteImportListExclusionModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportListExclusion.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
tvdbId: PropTypes.number.isRequired,
|
||||
onConfirmDeleteImportListExclusion: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
ImportListExclusion.defaultProps = {
|
||||
// The drag preview will not connect the drag handle.
|
||||
connectDragSource: (node) => node
|
||||
};
|
||||
|
||||
export default ImportListExclusion;
|
||||
@@ -0,0 +1,6 @@
|
||||
.actions {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 35px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
7
frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionRow.css.d.ts
vendored
Normal file
7
frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionRow.css.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'actions': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import ImportListExclusion from 'typings/ImportListExclusion';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditImportListExclusionModal from './EditImportListExclusionModal';
|
||||
import styles from './ImportListExclusionRow.css';
|
||||
|
||||
interface ImportListExclusionRowProps extends ImportListExclusion {
|
||||
onConfirmDeleteImportListExclusion: (id: number) => void;
|
||||
}
|
||||
|
||||
function ImportListExclusionRow(props: ImportListExclusionRowProps) {
|
||||
const { id, title, tvdbId, onConfirmDeleteImportListExclusion } = props;
|
||||
|
||||
const [
|
||||
isEditImportListExclusionModalOpen,
|
||||
setEditImportListExclusionModalOpen,
|
||||
setEditImportListExclusionModalClosed,
|
||||
] = useModalOpenState(false);
|
||||
|
||||
const [
|
||||
isDeleteImportListExclusionModalOpen,
|
||||
setDeleteImportListExclusionModalOpen,
|
||||
setDeleteImportListExclusionModalClosed,
|
||||
] = useModalOpenState(false);
|
||||
|
||||
const onConfirmDeleteImportListExclusionPress = useCallback(() => {
|
||||
onConfirmDeleteImportListExclusion(id);
|
||||
}, [id, onConfirmDeleteImportListExclusion]);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableRowCell>{title}</TableRowCell>
|
||||
<TableRowCell>{tvdbId}</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.actions}>
|
||||
<IconButton
|
||||
name={icons.EDIT}
|
||||
onPress={setEditImportListExclusionModalOpen}
|
||||
/>
|
||||
</TableRowCell>
|
||||
|
||||
<EditImportListExclusionModal
|
||||
id={id}
|
||||
isOpen={isEditImportListExclusionModalOpen}
|
||||
onModalClose={setEditImportListExclusionModalClosed}
|
||||
onDeleteImportListExclusionPress={setDeleteImportListExclusionModalOpen}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteImportListExclusionModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('DeleteImportListExclusion')}
|
||||
message={translate('DeleteImportListExclusionMessageText')}
|
||||
confirmLabel={translate('Delete')}
|
||||
onConfirm={onConfirmDeleteImportListExclusionPress}
|
||||
onCancel={setDeleteImportListExclusionModalClosed}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportListExclusionRow;
|
||||
@@ -1,23 +0,0 @@
|
||||
.importListExclusionsHeader {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 0 1 600px;
|
||||
}
|
||||
|
||||
.tvdbId {
|
||||
flex: 0 0 70px;
|
||||
}
|
||||
|
||||
.addImportListExclusion {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.addButton {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -3,9 +3,6 @@
|
||||
interface CssExports {
|
||||
'addButton': string;
|
||||
'addImportListExclusion': string;
|
||||
'importListExclusionsHeader': string;
|
||||
'title': string;
|
||||
'tvdbId': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditImportListExclusionModalConnector from './EditImportListExclusionModalConnector';
|
||||
import ImportListExclusion from './ImportListExclusion';
|
||||
import styles from './ImportListExclusions.css';
|
||||
|
||||
class ImportListExclusions extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isAddImportListExclusionModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onAddImportListExclusionPress = () => {
|
||||
this.setState({ isAddImportListExclusionModalOpen: true });
|
||||
};
|
||||
|
||||
onModalClose = () => {
|
||||
this.setState({ isAddImportListExclusionModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
items,
|
||||
onConfirmDeleteImportListExclusion,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<FieldSet legend={translate('ImportListExclusions')}>
|
||||
<PageSectionContent
|
||||
errorMessage={translate('ImportListExclusionsLoadError')}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.importListExclusionsHeader}>
|
||||
<div className={styles.title}>
|
||||
{translate('Title')}
|
||||
</div>
|
||||
<div className={styles.tvdbId}>
|
||||
{translate('TvdbId')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{
|
||||
items.map((item, index) => {
|
||||
return (
|
||||
<ImportListExclusion
|
||||
key={item.id}
|
||||
{...item}
|
||||
{...otherProps}
|
||||
index={index}
|
||||
onConfirmDeleteImportListExclusion={onConfirmDeleteImportListExclusion}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.addImportListExclusion}>
|
||||
<Link
|
||||
className={styles.addButton}
|
||||
onPress={this.onAddImportListExclusionPress}
|
||||
>
|
||||
<Icon name={icons.ADD} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<EditImportListExclusionModalConnector
|
||||
isOpen={this.state.isAddImportListExclusionModalOpen}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
|
||||
</PageSectionContent>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportListExclusions.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onConfirmDeleteImportListExclusion: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ImportListExclusions;
|
||||
@@ -0,0 +1,234 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import TablePager from 'Components/Table/TablePager';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import * as importListExclusionActions from 'Store/Actions/Settings/importListExclusions';
|
||||
import {
|
||||
registerPagePopulator,
|
||||
unregisterPagePopulator,
|
||||
} from 'Utilities/pagePopulator';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditImportListExclusionModal from './EditImportListExclusionModal';
|
||||
import ImportListExclusionRow from './ImportListExclusionRow';
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
name: 'title',
|
||||
label: () => translate('Title'),
|
||||
isVisible: true,
|
||||
isSortable: true,
|
||||
},
|
||||
{
|
||||
name: 'tvdbid',
|
||||
label: () => translate('TvdbId'),
|
||||
isVisible: true,
|
||||
isSortable: true,
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
isVisible: true,
|
||||
isSortable: false,
|
||||
},
|
||||
];
|
||||
|
||||
interface ImportListExclusionsProps {
|
||||
useCurrentPage: number;
|
||||
totalRecords: number;
|
||||
}
|
||||
|
||||
function createImportListExlucionsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.importListExclusions,
|
||||
(importListExclusions) => {
|
||||
return {
|
||||
...importListExclusions,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function ImportListExclusions(props: ImportListExclusionsProps) {
|
||||
const { useCurrentPage, totalRecords } = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const fetchImportListExclusions = useCallback(() => {
|
||||
dispatch(importListExclusionActions.fetchImportListExclusions());
|
||||
}, [dispatch]);
|
||||
|
||||
const deleteImportListExclusion = useCallback(
|
||||
(payload: { id: number }) => {
|
||||
dispatch(importListExclusionActions.deleteImportListExclusion(payload));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const gotoImportListExclusionFirstPage = useCallback(() => {
|
||||
dispatch(importListExclusionActions.gotoImportListExclusionFirstPage());
|
||||
}, [dispatch]);
|
||||
|
||||
const gotoImportListExclusionPreviousPage = useCallback(() => {
|
||||
dispatch(importListExclusionActions.gotoImportListExclusionPreviousPage());
|
||||
}, [dispatch]);
|
||||
|
||||
const gotoImportListExclusionNextPage = useCallback(() => {
|
||||
dispatch(importListExclusionActions.gotoImportListExclusionNextPage());
|
||||
}, [dispatch]);
|
||||
|
||||
const gotoImportListExclusionLastPage = useCallback(() => {
|
||||
dispatch(importListExclusionActions.gotoImportListExclusionLastPage());
|
||||
}, [dispatch]);
|
||||
|
||||
const gotoImportListExclusionPage = useCallback(
|
||||
(page: number) => {
|
||||
dispatch(
|
||||
importListExclusionActions.gotoImportListExclusionPage({ page })
|
||||
);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const setImportListExclusionSort = useCallback(
|
||||
(sortKey: { sortKey: string }) => {
|
||||
dispatch(
|
||||
importListExclusionActions.setImportListExclusionSort({ sortKey })
|
||||
);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const setImportListTableOption = useCallback(
|
||||
(payload: { pageSize: number }) => {
|
||||
dispatch(
|
||||
importListExclusionActions.setImportListExclusionTableOption(payload)
|
||||
);
|
||||
|
||||
if (payload.pageSize) {
|
||||
dispatch(importListExclusionActions.gotoImportListExclusionFirstPage());
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const repopulate = useCallback(() => {
|
||||
gotoImportListExclusionFirstPage();
|
||||
}, [gotoImportListExclusionFirstPage]);
|
||||
|
||||
useEffect(() => {
|
||||
registerPagePopulator(repopulate);
|
||||
|
||||
if (useCurrentPage) {
|
||||
fetchImportListExclusions();
|
||||
} else {
|
||||
gotoImportListExclusionFirstPage();
|
||||
}
|
||||
|
||||
return () => unregisterPagePopulator(repopulate);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onConfirmDeleteImportListExclusion = useCallback(
|
||||
(id: number) => {
|
||||
deleteImportListExclusion({ id });
|
||||
repopulate();
|
||||
},
|
||||
[deleteImportListExclusion, repopulate]
|
||||
);
|
||||
|
||||
const selected = useSelector(createImportListExlucionsSelector());
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
items,
|
||||
pageSize,
|
||||
sortKey,
|
||||
error,
|
||||
sortDirection,
|
||||
...otherProps
|
||||
} = selected;
|
||||
|
||||
const [
|
||||
isAddImportListExclusionModalOpen,
|
||||
setAddImportListExclusionModalOpen,
|
||||
setAddImportListExclusionModalClosed,
|
||||
] = useModalOpenState(false);
|
||||
|
||||
const isFetchingForFirstTime = isFetching && !isPopulated;
|
||||
|
||||
return (
|
||||
<FieldSet legend={translate('ImportListExclusions')}>
|
||||
<PageSectionContent
|
||||
errorMessage={translate('ImportListExclusionsLoadError')}
|
||||
isFetching={isFetchingForFirstTime}
|
||||
isPopulated={isPopulated}
|
||||
error={error}
|
||||
>
|
||||
<Table
|
||||
columns={COLUMNS}
|
||||
canModifyColumns={false}
|
||||
pageSize={pageSize}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onSortPress={setImportListExclusionSort}
|
||||
onTableOptionChange={setImportListTableOption}
|
||||
>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<ImportListExclusionRow
|
||||
key={item.id}
|
||||
{...item}
|
||||
onConfirmDeleteImportListExclusion={
|
||||
onConfirmDeleteImportListExclusion
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<TableRow>
|
||||
<TableRowCell />
|
||||
<TableRowCell />
|
||||
|
||||
<TableRowCell>
|
||||
<IconButton
|
||||
name={icons.ADD}
|
||||
onPress={setAddImportListExclusionModalOpen}
|
||||
/>
|
||||
</TableRowCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<TablePager
|
||||
totalRecords={totalRecords}
|
||||
pageSize={pageSize}
|
||||
isFetching={isFetching}
|
||||
onFirstPagePress={gotoImportListExclusionFirstPage}
|
||||
onPreviousPagePress={gotoImportListExclusionPreviousPage}
|
||||
onNextPagePress={gotoImportListExclusionNextPage}
|
||||
onLastPagePress={gotoImportListExclusionLastPage}
|
||||
onPageSelect={gotoImportListExclusionPage}
|
||||
{...otherProps}
|
||||
/>
|
||||
|
||||
<EditImportListExclusionModal
|
||||
isOpen={isAddImportListExclusionModalOpen}
|
||||
onModalClose={setAddImportListExclusionModalClosed}
|
||||
/>
|
||||
</PageSectionContent>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportListExclusions;
|
||||
@@ -1,59 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { deleteImportListExclusion, fetchImportListExclusions } from 'Store/Actions/settingsActions';
|
||||
import ImportListExclusions from './ImportListExclusions';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.importListExclusions,
|
||||
(importListExclusions) => {
|
||||
return {
|
||||
...importListExclusions
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchImportListExclusions,
|
||||
deleteImportListExclusion
|
||||
};
|
||||
|
||||
class ImportListExclusionsConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchImportListExclusions();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onConfirmDeleteImportListExclusion = (id) => {
|
||||
this.props.deleteImportListExclusion({ id });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ImportListExclusions
|
||||
{...this.state}
|
||||
{...this.props}
|
||||
onConfirmDeleteImportListExclusion={this.onConfirmDeleteImportListExclusion}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ImportListExclusionsConnector.propTypes = {
|
||||
fetchImportListExclusions: PropTypes.func.isRequired,
|
||||
deleteImportListExclusion: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(ImportListExclusionsConnector);
|
||||
@@ -7,7 +7,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector';
|
||||
import ImportListsExclusions from './ImportListExclusions/ImportListExclusions';
|
||||
import ImportListsConnector from './ImportLists/ImportListsConnector';
|
||||
import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal';
|
||||
import ImportListOptions from './Options/ImportListOptions';
|
||||
@@ -113,7 +113,7 @@ class ImportListSettings extends Component {
|
||||
onChildStateChange={this.onChildStateChange}
|
||||
/>
|
||||
|
||||
<ImportListsExclusionsConnector />
|
||||
<ImportListsExclusions />
|
||||
<ManageImportListsModal
|
||||
isOpen={isManageImportListsOpen}
|
||||
onModalClose={this.onManageImportListsModalClose}
|
||||
|
||||
@@ -4,6 +4,7 @@ import translate from 'Utilities/String/translate';
|
||||
import styles from './TheTvdb.css';
|
||||
|
||||
function TheTvdb(props) {
|
||||
debugger;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<img
|
||||
@@ -15,8 +16,7 @@ function TheTvdb(props) {
|
||||
<div className={styles.title}>
|
||||
{translate('TheTvdb')}
|
||||
</div>
|
||||
|
||||
<InlineMarkdown data={translate('SeriesAndEpisodeInformationIsProvidedByTheTVDB')} />
|
||||
<InlineMarkdown data={translate('SeriesAndEpisodeInformationIsProvidedByTheTVDB', { url: 'https://www.thetvdb.com/subscribe' })} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -86,10 +86,10 @@ function EditSpecificationModalContent(props) {
|
||||
<InlineMarkdown data={translate('ConditionUsingRegularExpressions')} />
|
||||
</div>
|
||||
<div>
|
||||
<InlineMarkdown data={translate('RegularExpressionsTutorialLink')} />
|
||||
<InlineMarkdown data={translate('RegularExpressionsTutorialLink', { url: 'https://www.regular-expressions.info/tutorial.html' })} />
|
||||
</div>
|
||||
<div>
|
||||
<InlineMarkdown data={translate('RegularExpressionsCanBeTested')} />
|
||||
<InlineMarkdown data={translate('RegularExpressionsCanBeTested', { url: 'http://regexstorm.net/tester' })} />
|
||||
</div>
|
||||
</Alert>
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchDelayProfiles, fetchDownloadClients, fetchImportLists, fetchIndexers, fetchNotifications, fetchReleaseProfiles } from 'Store/Actions/settingsActions';
|
||||
import { fetchTagDetails } from 'Store/Actions/tagActions';
|
||||
import { fetchTagDetails, fetchTags } from 'Store/Actions/tagActions';
|
||||
import Tags from './Tags';
|
||||
|
||||
function createMapStateToProps() {
|
||||
@@ -25,6 +25,7 @@ function createMapStateToProps() {
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchFetchTags: fetchTags,
|
||||
dispatchFetchTagDetails: fetchTagDetails,
|
||||
dispatchFetchDelayProfiles: fetchDelayProfiles,
|
||||
dispatchFetchImportLists: fetchImportLists,
|
||||
@@ -41,6 +42,7 @@ class MetadatasConnector extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
dispatchFetchTags,
|
||||
dispatchFetchTagDetails,
|
||||
dispatchFetchDelayProfiles,
|
||||
dispatchFetchImportLists,
|
||||
@@ -50,6 +52,7 @@ class MetadatasConnector extends Component {
|
||||
dispatchFetchDownloadClients
|
||||
} = this.props;
|
||||
|
||||
dispatchFetchTags();
|
||||
dispatchFetchTagDetails();
|
||||
dispatchFetchDelayProfiles();
|
||||
dispatchFetchImportLists();
|
||||
@@ -72,6 +75,7 @@ class MetadatasConnector extends Component {
|
||||
}
|
||||
|
||||
MetadatasConnector.propTypes = {
|
||||
dispatchFetchTags: PropTypes.func.isRequired,
|
||||
dispatchFetchTagDetails: PropTypes.func.isRequired,
|
||||
dispatchFetchDelayProfiles: PropTypes.func.isRequired,
|
||||
dispatchFetchImportLists: PropTypes.func.isRequired,
|
||||
|
||||
@@ -31,19 +31,19 @@ export const firstDayOfWeekOptions = [
|
||||
];
|
||||
|
||||
export const weekColumnOptions = [
|
||||
{ key: 'ddd M/D', value: 'Tue 3/25' },
|
||||
{ key: 'ddd MM/DD', value: 'Tue 03/25' },
|
||||
{ key: 'ddd D/M', value: 'Tue 25/3' },
|
||||
{ key: 'ddd DD/MM', value: 'Tue 25/03' }
|
||||
{ key: 'ddd M/D', value: 'Tue 3/25', hint: 'ddd M/D' },
|
||||
{ key: 'ddd MM/DD', value: 'Tue 03/25', hint: 'ddd MM/DD' },
|
||||
{ key: 'ddd D/M', value: 'Tue 25/3', hint: 'ddd D/M' },
|
||||
{ key: 'ddd DD/MM', value: 'Tue 25/03', hint: 'ddd DD/MM' }
|
||||
];
|
||||
|
||||
const shortDateFormatOptions = [
|
||||
{ key: 'MMM D YYYY', value: 'Mar 25 2014' },
|
||||
{ key: 'DD MMM YYYY', value: '25 Mar 2014' },
|
||||
{ key: 'MM/D/YYYY', value: '03/25/2014' },
|
||||
{ key: 'MM/DD/YYYY', value: '03/25/2014' },
|
||||
{ key: 'DD/MM/YYYY', value: '25/03/2014' },
|
||||
{ key: 'YYYY-MM-DD', value: '2014-03-25' }
|
||||
{ key: 'MMM D YYYY', value: 'Mar 25 2014', hint: 'MMM D YYYY' },
|
||||
{ key: 'DD MMM YYYY', value: '25 Mar 2014', hint: 'DD MMM YYYY' },
|
||||
{ key: 'MM/D/YYYY', value: '03/25/2014', hint: 'MM/D/YYYY' },
|
||||
{ key: 'MM/DD/YYYY', value: '03/25/2014', hint: 'MM/DD/YYYY' },
|
||||
{ key: 'DD/MM/YYYY', value: '25/03/2014', hint: 'DD/MM/YYYY' },
|
||||
{ key: 'YYYY-MM-DD', value: '2014-03-25', hint: 'YYYY-MM-DD' }
|
||||
];
|
||||
|
||||
const longDateFormatOptions = [
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createAction } from 'redux-actions';
|
||||
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
|
||||
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
|
||||
import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
|
||||
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
|
||||
import { createThunk } from 'Store/thunks';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
|
||||
import createServerSideCollectionHandlers from '../Creators/createServerSideCollectionHandlers';
|
||||
import createSetTableOptionReducer from '../Creators/Reducers/createSetTableOptionReducer';
|
||||
|
||||
//
|
||||
// Variables
|
||||
@@ -14,6 +16,13 @@ const section = 'settings.importListExclusions';
|
||||
// Actions Types
|
||||
|
||||
export const FETCH_IMPORT_LIST_EXCLUSIONS = 'settings/importListExclusions/fetchImportListExclusions';
|
||||
export const GOTO_FIRST_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionFirstPage';
|
||||
export const GOTO_PREVIOUS_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionPreviousPage';
|
||||
export const GOTO_NEXT_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionNextPage';
|
||||
export const GOTO_LAST_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionLastPage';
|
||||
export const GOTO_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionPage';
|
||||
export const SET_IMPORT_LIST_EXCLUSION_SORT = 'settings/importListExclusions/setImportListExclusionSort';
|
||||
export const SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION = 'settings/importListExclusions/setImportListExclusionTableOption';
|
||||
export const SAVE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/saveImportListExclusion';
|
||||
export const DELETE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/deleteImportListExclusion';
|
||||
export const SET_IMPORT_LIST_EXCLUSION_VALUE = 'settings/importListExclusions/setImportListExclusionValue';
|
||||
@@ -22,9 +31,16 @@ export const SET_IMPORT_LIST_EXCLUSION_VALUE = 'settings/importListExclusions/se
|
||||
// Action Creators
|
||||
|
||||
export const fetchImportListExclusions = createThunk(FETCH_IMPORT_LIST_EXCLUSIONS);
|
||||
export const gotoImportListExclusionFirstPage = createThunk(GOTO_FIRST_IMPORT_LIST_EXCLUSION_PAGE);
|
||||
export const gotoImportListExclusionPreviousPage = createThunk(GOTO_PREVIOUS_IMPORT_LIST_EXCLUSION_PAGE);
|
||||
export const gotoImportListExclusionNextPage = createThunk(GOTO_NEXT_IMPORT_LIST_EXCLUSION_PAGE);
|
||||
export const gotoImportListExclusionLastPage = createThunk(GOTO_LAST_IMPORT_LIST_EXCLUSION_PAGE);
|
||||
export const gotoImportListExclusionPage = createThunk(GOTO_IMPORT_LIST_EXCLUSION_PAGE);
|
||||
export const setImportListExclusionSort = createThunk(SET_IMPORT_LIST_EXCLUSION_SORT);
|
||||
export const saveImportListExclusion = createThunk(SAVE_IMPORT_LIST_EXCLUSION);
|
||||
export const deleteImportListExclusion = createThunk(DELETE_IMPORT_LIST_EXCLUSION);
|
||||
|
||||
export const setImportListExclusionTableOption = createAction(SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION);
|
||||
export const setImportListExclusionValue = createAction(SET_IMPORT_LIST_EXCLUSION_VALUE, (payload) => {
|
||||
return {
|
||||
section,
|
||||
@@ -44,6 +60,7 @@ export default {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
pageSize: 20,
|
||||
items: [],
|
||||
isSaving: false,
|
||||
saveError: null,
|
||||
@@ -53,17 +70,31 @@ export default {
|
||||
//
|
||||
// Action Handlers
|
||||
|
||||
actionHandlers: {
|
||||
[FETCH_IMPORT_LIST_EXCLUSIONS]: createFetchHandler(section, '/importlistexclusion'),
|
||||
actionHandlers: handleThunks({
|
||||
...createServerSideCollectionHandlers(
|
||||
section,
|
||||
'/importlistexclusion/paged',
|
||||
fetchImportListExclusions,
|
||||
{
|
||||
[serverSideCollectionHandlers.FETCH]: FETCH_IMPORT_LIST_EXCLUSIONS,
|
||||
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_IMPORT_LIST_EXCLUSION_PAGE,
|
||||
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_IMPORT_LIST_EXCLUSION_PAGE,
|
||||
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_IMPORT_LIST_EXCLUSION_PAGE,
|
||||
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_IMPORT_LIST_EXCLUSION_PAGE,
|
||||
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_IMPORT_LIST_EXCLUSION_PAGE,
|
||||
[serverSideCollectionHandlers.SORT]: SET_IMPORT_LIST_EXCLUSION_SORT
|
||||
}
|
||||
),
|
||||
[SAVE_IMPORT_LIST_EXCLUSION]: createSaveProviderHandler(section, '/importlistexclusion'),
|
||||
[DELETE_IMPORT_LIST_EXCLUSION]: createRemoveItemHandler(section, '/importlistexclusion')
|
||||
},
|
||||
}),
|
||||
|
||||
//
|
||||
// Reducers
|
||||
|
||||
reducers: {
|
||||
[SET_IMPORT_LIST_EXCLUSION_VALUE]: createSetSettingValueReducer(section)
|
||||
[SET_IMPORT_LIST_EXCLUSION_VALUE]: createSetSettingValueReducer(section),
|
||||
[SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION]: createSetTableOptionReducer(section)
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
48
frontend/src/Store/Actions/Settings/indexerFlags.js
Normal file
48
frontend/src/Store/Actions/Settings/indexerFlags.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
|
||||
import { createThunk } from 'Store/thunks';
|
||||
|
||||
//
|
||||
// Variables
|
||||
|
||||
const section = 'settings.indexerFlags';
|
||||
|
||||
//
|
||||
// Actions Types
|
||||
|
||||
export const FETCH_INDEXER_FLAGS = 'settings/indexerFlags/fetchIndexerFlags';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
||||
export const fetchIndexerFlags = createThunk(FETCH_INDEXER_FLAGS);
|
||||
|
||||
//
|
||||
// Details
|
||||
|
||||
export default {
|
||||
|
||||
//
|
||||
// State
|
||||
|
||||
defaultState: {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
items: []
|
||||
},
|
||||
|
||||
//
|
||||
// Action Handlers
|
||||
|
||||
actionHandlers: {
|
||||
[FETCH_INDEXER_FLAGS]: createFetchHandler(section, '/indexerFlag')
|
||||
},
|
||||
|
||||
//
|
||||
// Reducers
|
||||
|
||||
reducers: {
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
@@ -97,7 +97,7 @@ export const persistState = [
|
||||
'calendar.view',
|
||||
'calendar.selectedFilterKey',
|
||||
'calendar.options',
|
||||
'seriesIndex.customFilters'
|
||||
'calendar.customFilters'
|
||||
];
|
||||
|
||||
//
|
||||
|
||||
@@ -129,6 +129,15 @@ export const defaultState = {
|
||||
isVisible: false,
|
||||
isSortable: true
|
||||
},
|
||||
{
|
||||
name: 'indexerFlags',
|
||||
columnLabel: () => translate('IndexerFlags'),
|
||||
label: React.createElement(Icon, {
|
||||
name: icons.FLAG,
|
||||
title: () => translate('IndexerFlags')
|
||||
}),
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: () => translate('Status'),
|
||||
|
||||
@@ -161,9 +161,12 @@ export const actionHandlers = handleThunks({
|
||||
const episodeFile = data.find((f) => f.id === id);
|
||||
|
||||
props.qualityCutoffNotMet = episodeFile.qualityCutoffNotMet;
|
||||
props.customFormats = episodeFile.customFormats;
|
||||
props.customFormatScore = episodeFile.customFormatScore;
|
||||
props.languages = file.languages;
|
||||
props.quality = file.quality;
|
||||
props.releaseGroup = file.releaseGroup;
|
||||
props.indexerFlags = file.indexerFlags;
|
||||
|
||||
return updateItem({
|
||||
section,
|
||||
|
||||
@@ -162,6 +162,7 @@ export const actionHandlers = handleThunks({
|
||||
quality: item.quality,
|
||||
languages: item.languages,
|
||||
releaseGroup: item.releaseGroup,
|
||||
indexerFlags: item.indexerFlags,
|
||||
downloadId: item.downloadId
|
||||
};
|
||||
});
|
||||
|
||||
@@ -250,6 +250,12 @@ export const defaultState = {
|
||||
label: () => translate('SeasonPack'),
|
||||
type: filterBuilderTypes.EXACT,
|
||||
valueType: filterBuilderValueTypes.BOOL
|
||||
},
|
||||
{
|
||||
name: 'episodeRequested',
|
||||
label: () => translate('EpisodeRequested'),
|
||||
type: filterBuilderTypes.EXACT,
|
||||
valueType: filterBuilderValueTypes.BOOL
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createAction } from 'redux-actions';
|
||||
import indexerFlags from 'Store/Actions/Settings/indexerFlags';
|
||||
import { handleThunks } from 'Store/thunks';
|
||||
import createHandleActions from './Creators/createHandleActions';
|
||||
import autoTaggings from './Settings/autoTaggings';
|
||||
@@ -37,6 +38,7 @@ export * from './Settings/general';
|
||||
export * from './Settings/importListOptions';
|
||||
export * from './Settings/importLists';
|
||||
export * from './Settings/importListExclusions';
|
||||
export * from './Settings/indexerFlags';
|
||||
export * from './Settings/indexerOptions';
|
||||
export * from './Settings/indexers';
|
||||
export * from './Settings/languages';
|
||||
@@ -72,6 +74,7 @@ export const defaultState = {
|
||||
importLists: importLists.defaultState,
|
||||
importListExclusions: importListExclusions.defaultState,
|
||||
importListOptions: importListOptions.defaultState,
|
||||
indexerFlags: indexerFlags.defaultState,
|
||||
indexerOptions: indexerOptions.defaultState,
|
||||
indexers: indexers.defaultState,
|
||||
languages: languages.defaultState,
|
||||
@@ -116,6 +119,7 @@ export const actionHandlers = handleThunks({
|
||||
...importLists.actionHandlers,
|
||||
...importListExclusions.actionHandlers,
|
||||
...importListOptions.actionHandlers,
|
||||
...indexerFlags.actionHandlers,
|
||||
...indexerOptions.actionHandlers,
|
||||
...indexers.actionHandlers,
|
||||
...languages.actionHandlers,
|
||||
@@ -151,6 +155,7 @@ export const reducers = createHandleActions({
|
||||
...importLists.reducers,
|
||||
...importListExclusions.reducers,
|
||||
...importListOptions.reducers,
|
||||
...indexerFlags.reducers,
|
||||
...indexerOptions.reducers,
|
||||
...indexers.reducers,
|
||||
...languages.reducers,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
|
||||
const createIndexerFlagsSelector = createSelector(
|
||||
(state: AppState) => state.settings.indexerFlags,
|
||||
(indexerFlags) => indexerFlags
|
||||
);
|
||||
|
||||
export default createIndexerFlagsSelector;
|
||||
@@ -1,45 +1,44 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import AppSectionState, {
|
||||
AppSectionItemState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import { AppSectionItemState } from 'App/State/AppSectionState';
|
||||
import AppState from 'App/State/AppState';
|
||||
import SettingsAppState from 'App/State/SettingsAppState';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import { PendingSection } from 'typings/pending';
|
||||
|
||||
type SettingNames = keyof Omit<AppState['settings'], 'advancedSettings'>;
|
||||
type GetSectionState<Name extends SettingNames> = AppState['settings'][Name];
|
||||
type GetSettingsSectionItemType<Name extends SettingNames> =
|
||||
GetSectionState<Name> extends AppSectionItemState<infer R>
|
||||
? R
|
||||
: GetSectionState<Name> extends AppSectionState<infer R>
|
||||
? R
|
||||
type SectionsWithItemNames = {
|
||||
[K in keyof SettingsAppState]: SettingsAppState[K] extends AppSectionItemState<unknown>
|
||||
? K
|
||||
: never;
|
||||
}[keyof SettingsAppState];
|
||||
|
||||
type AppStateWithPending<Name extends SettingNames> = {
|
||||
item?: GetSettingsSectionItemType<Name>;
|
||||
pendingChanges?: Partial<GetSettingsSectionItemType<Name>>;
|
||||
saveError?: Error;
|
||||
} & GetSectionState<Name>;
|
||||
type GetSectionState<Name extends SectionsWithItemNames> =
|
||||
SettingsAppState[Name];
|
||||
type GetSettingsSectionItemType<Name extends SectionsWithItemNames> =
|
||||
GetSectionState<Name> extends AppSectionItemState<infer R> ? R : never;
|
||||
|
||||
function createSettingsSectionSelector<Name extends SettingNames>(
|
||||
section: Name
|
||||
) {
|
||||
function createSettingsSectionSelector<
|
||||
Name extends SectionsWithItemNames,
|
||||
T extends GetSettingsSectionItemType<Name>
|
||||
>(section: Name) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings[section],
|
||||
(sectionSettings) => {
|
||||
const { item, pendingChanges, saveError, ...other } =
|
||||
sectionSettings as AppStateWithPending<Name>;
|
||||
const { item, pendingChanges, ...other } = sectionSettings;
|
||||
|
||||
const { settings, ...rest } = selectSettings(
|
||||
item,
|
||||
pendingChanges,
|
||||
saveError
|
||||
);
|
||||
const saveError =
|
||||
'saveError' in sectionSettings ? sectionSettings.saveError : undefined;
|
||||
|
||||
const {
|
||||
settings,
|
||||
pendingChanges: selectedPendingChanges,
|
||||
...rest
|
||||
} = selectSettings(item, pendingChanges, saveError);
|
||||
|
||||
return {
|
||||
...other,
|
||||
saveError,
|
||||
settings: settings as PendingSection<GetSettingsSectionItemType<Name>>,
|
||||
settings: settings as PendingSection<T>,
|
||||
pendingChanges: selectedPendingChanges as Partial<T>,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
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 { align, icons, kinds } from 'Helpers/Props';
|
||||
import getFilterValue from 'Utilities/Filter/getFilterValue';
|
||||
@@ -180,6 +181,16 @@ class CutoffUnmet extends Component {
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<TableOptionsModalWrapper
|
||||
{...otherProps}
|
||||
columns={columns}
|
||||
>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.TABLE}
|
||||
/>
|
||||
</TableOptionsModalWrapper>
|
||||
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
|
||||
@@ -12,6 +12,7 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
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 { align, icons, kinds } from 'Helpers/Props';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
@@ -193,6 +194,16 @@ class Missing extends Component {
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<TableOptionsModalWrapper
|
||||
{...otherProps}
|
||||
columns={columns}
|
||||
>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.TABLE}
|
||||
/>
|
||||
</TableOptionsModalWrapper>
|
||||
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
|
||||
6
frontend/src/typings/ImportListExclusion.ts
Normal file
6
frontend/src/typings/ImportListExclusion.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
|
||||
export default interface ImportListExclusion extends ModelBase {
|
||||
tvdbId: number;
|
||||
title: string;
|
||||
}
|
||||
6
frontend/src/typings/IndexerFlag.ts
Normal file
6
frontend/src/typings/IndexerFlag.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
interface IndexerFlag {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default IndexerFlag;
|
||||
@@ -1,7 +1,21 @@
|
||||
export interface ValidationFailure {
|
||||
propertyName: string;
|
||||
errorMessage: string;
|
||||
severity: 'error' | 'warning';
|
||||
}
|
||||
|
||||
export interface ValidationError extends ValidationFailure {
|
||||
isWarning: false;
|
||||
}
|
||||
|
||||
export interface ValidationWarning extends ValidationFailure {
|
||||
isWarning: true;
|
||||
}
|
||||
|
||||
export interface Pending<T> {
|
||||
value: T;
|
||||
errors: any[];
|
||||
warnings: any[];
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
}
|
||||
|
||||
export type PendingSection<T> = {
|
||||
|
||||
@@ -28,8 +28,7 @@
|
||||
"@fortawesome/react-fontawesome": "0.2.0",
|
||||
"@juggle/resize-observer": "3.4.0",
|
||||
"@microsoft/signalr": "6.0.21",
|
||||
"@sentry/browser": "7.51.2",
|
||||
"@sentry/integrations": "7.51.2",
|
||||
"@sentry/browser": "7.100.0",
|
||||
"@types/node": "18.16.8",
|
||||
"@types/react": "18.2.6",
|
||||
"@types/react-dom": "18.2.4",
|
||||
@@ -146,7 +145,7 @@
|
||||
"worker-loader": "3.0.8"
|
||||
},
|
||||
"volta": {
|
||||
"node": "16.17.0",
|
||||
"node": "20.11.1",
|
||||
"yarn": "1.22.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,35 @@
|
||||
<RootNamespace Condition="'$(SonarrProject)'=='true'">$(MSBuildProjectName.Replace('Sonarr','NzbDrone'))</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TestProject)'!='true'">
|
||||
<!-- Annotates .NET assemblies with repository information including SHA -->
|
||||
<!-- Sentry uses this to link directly to GitHub at the exact version/file/line -->
|
||||
<!-- This is built-in on .NET 8 and can be removed once the project is updated -->
|
||||
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Sentry specific configuration: Only in Release mode -->
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
|
||||
<!-- https://docs.sentry.io/platforms/dotnet/configuration/msbuild/ -->
|
||||
<!-- OrgSlug, ProjectSlug and AuthToken are required.
|
||||
They can be set below, via argument to 'msbuild -p:' or environment variable -->
|
||||
<SentryOrg></SentryOrg>
|
||||
<SentryProject></SentryProject>
|
||||
<SentryUrl></SentryUrl> <!-- If empty, assumed to be sentry.io -->
|
||||
<SentryAuthToken></SentryAuthToken> <!-- Use env var instead: SENTRY_AUTH_TOKEN -->
|
||||
|
||||
<!-- Upload PDBs to Sentry, enabling stack traces with line numbers and file paths
|
||||
without the need to deploy the application with PDBs -->
|
||||
<SentryUploadSymbols>true</SentryUploadSymbols>
|
||||
|
||||
<!-- Source Link settings -->
|
||||
<!-- https://github.com/dotnet/sourcelink/blob/main/docs/README.md#publishrepositoryurl -->
|
||||
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||
<!-- Embeds all source code in the respective PDB. This can make it a bit bigger but since it'll be uploaded
|
||||
to Sentry and not distributed to run on the server, it helps debug crashes while making releases smaller -->
|
||||
<EmbedAllSources>true</EmbedAllSources>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Standard testing packages -->
|
||||
<ItemGroup Condition="'$(TestProject)'=='true'">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<TargetFrameworks>net6.0</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" />
|
||||
<PackageReference Include="NBuilder" Version="6.1.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<TargetFrameworks>net6.0</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" />
|
||||
<PackageReference Include="Selenium.Support" Version="3.141.0" />
|
||||
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="111.0.5563.6400" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using FluentAssertions;
|
||||
using NLog;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Instrumentation.Sentry;
|
||||
using NzbDrone.Test.Common;
|
||||
|
||||
@@ -26,7 +27,7 @@ namespace NzbDrone.Common.Test.InstrumentationTests
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
_subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111");
|
||||
_subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111", Mocker.GetMock<IAppFolderInfo>().Object);
|
||||
}
|
||||
|
||||
private LogEventInfo GivenLogEvent(LogLevel level, Exception ex, string message)
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net6.0</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NzbDrone.Host\Sonarr.Host.csproj" />
|
||||
<ProjectReference Include="..\NzbDrone.Test.Common\Sonarr.Test.Common.csproj" />
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
@@ -247,6 +249,18 @@ namespace NzbDrone.Common.Http.Dispatchers
|
||||
return _credentialCache.Get("credentialCache", () => new CredentialCache());
|
||||
}
|
||||
|
||||
private static bool HasRoutableIPv4Address()
|
||||
{
|
||||
// Get all IPv4 addresses from all interfaces and return true if there are any with non-loopback addresses
|
||||
var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces();
|
||||
|
||||
return networkInterfaces.Any(ni =>
|
||||
ni.OperationalStatus == OperationalStatus.Up &&
|
||||
ni.GetIPProperties().UnicastAddresses.Any(ip =>
|
||||
ip.Address.AddressFamily == AddressFamily.InterNetwork &&
|
||||
!IPAddress.IsLoopback(ip.Address)));
|
||||
}
|
||||
|
||||
private static async ValueTask<Stream> onConnect(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
// Until .NET supports an implementation of Happy Eyeballs (https://tools.ietf.org/html/rfc8305#section-2), let's make IPv4 fallback work in a simple way.
|
||||
@@ -270,10 +284,8 @@ namespace NzbDrone.Common.Http.Dispatchers
|
||||
}
|
||||
catch
|
||||
{
|
||||
// very naively fallback to ipv4 permanently for this execution based on the response of the first connection attempt.
|
||||
// note that this may cause users to eventually get switched to ipv4 (on a random failure when they are switching networks, for instance)
|
||||
// but in the interest of keeping this implementation simple, this is acceptable.
|
||||
useIPv6 = false;
|
||||
// Do not retry IPv6 if a routable IPv4 address is available, otherwise continue to attempt IPv6 connections.
|
||||
useIPv6 = !HasRoutableIPv4Address();
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -39,7 +39,7 @@ namespace NzbDrone.Common.Instrumentation
|
||||
RegisterDebugger();
|
||||
}
|
||||
|
||||
RegisterSentry(updateApp);
|
||||
RegisterSentry(updateApp, appFolderInfo);
|
||||
|
||||
if (updateApp)
|
||||
{
|
||||
@@ -60,7 +60,7 @@ namespace NzbDrone.Common.Instrumentation
|
||||
LogManager.ReconfigExistingLoggers();
|
||||
}
|
||||
|
||||
private static void RegisterSentry(bool updateClient)
|
||||
private static void RegisterSentry(bool updateClient, IAppFolderInfo appFolderInfo)
|
||||
{
|
||||
string dsn;
|
||||
|
||||
@@ -80,7 +80,7 @@ namespace NzbDrone.Common.Instrumentation
|
||||
Target target;
|
||||
try
|
||||
{
|
||||
target = new SentryTarget(dsn)
|
||||
target = new SentryTarget(dsn, appFolderInfo)
|
||||
{
|
||||
Name = "sentryTarget",
|
||||
Layout = "${message}"
|
||||
|
||||
@@ -9,6 +9,7 @@ using NLog;
|
||||
using NLog.Common;
|
||||
using NLog.Targets;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using Sentry;
|
||||
|
||||
namespace NzbDrone.Common.Instrumentation.Sentry
|
||||
@@ -96,7 +97,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
|
||||
public bool FilterEvents { get; set; }
|
||||
public bool SentryEnabled { get; set; }
|
||||
|
||||
public SentryTarget(string dsn)
|
||||
public SentryTarget(string dsn, IAppFolderInfo appFolderInfo)
|
||||
{
|
||||
_sdk = SentrySdk.Init(o =>
|
||||
{
|
||||
@@ -104,9 +105,33 @@ namespace NzbDrone.Common.Instrumentation.Sentry
|
||||
o.AttachStacktrace = true;
|
||||
o.MaxBreadcrumbs = 200;
|
||||
o.Release = BuildInfo.Release;
|
||||
o.BeforeSend = x => SentryCleanser.CleanseEvent(x);
|
||||
o.BeforeBreadcrumb = x => SentryCleanser.CleanseBreadcrumb(x);
|
||||
o.SetBeforeSend(x => SentryCleanser.CleanseEvent(x));
|
||||
o.SetBeforeBreadcrumb(x => SentryCleanser.CleanseBreadcrumb(x));
|
||||
o.Environment = BuildInfo.Branch;
|
||||
|
||||
// Crash free run statistics (sends a ping for healthy and for crashes sessions)
|
||||
o.AutoSessionTracking = true;
|
||||
|
||||
// Caches files in the event device is offline
|
||||
// Sentry creates a 'sentry' sub directory, no need to concat here
|
||||
o.CacheDirectoryPath = appFolderInfo.GetAppDataPath();
|
||||
|
||||
// default environment is production
|
||||
if (!RuntimeInfo.IsProduction)
|
||||
{
|
||||
if (RuntimeInfo.IsDevelopment)
|
||||
{
|
||||
o.Environment = "development";
|
||||
}
|
||||
else if (RuntimeInfo.IsTesting)
|
||||
{
|
||||
o.Environment = "testing";
|
||||
}
|
||||
else
|
||||
{
|
||||
o.Environment = "other";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
InitializeScope();
|
||||
@@ -125,7 +150,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
|
||||
{
|
||||
SentrySdk.ConfigureScope(scope =>
|
||||
{
|
||||
scope.User = new User
|
||||
scope.User = new SentryUser
|
||||
{
|
||||
Id = HashUtil.AnonymousToken()
|
||||
};
|
||||
@@ -285,13 +310,21 @@ namespace NzbDrone.Common.Instrumentation.Sentry
|
||||
}
|
||||
}
|
||||
|
||||
var level = LoggingLevelMap[logEvent.Level];
|
||||
var sentryEvent = new SentryEvent(logEvent.Exception)
|
||||
{
|
||||
Level = LoggingLevelMap[logEvent.Level],
|
||||
Level = level,
|
||||
Logger = logEvent.LoggerName,
|
||||
Message = logEvent.FormattedMessage
|
||||
};
|
||||
|
||||
if (level is SentryLevel.Fatal && logEvent.Exception is not null)
|
||||
{
|
||||
// Usages of 'fatal' here indicates the process will crash. In Sentry this is represented with
|
||||
// the 'unhandled' exception flag
|
||||
logEvent.Exception.SetSentryMechanism("Logger.Fatal", "Logger.Fatal was called", false);
|
||||
}
|
||||
|
||||
sentryEvent.SetExtras(extras);
|
||||
sentryEvent.SetFingerprint(fingerPrint);
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<PackageReference Include="NLog" Version="4.7.14" />
|
||||
<PackageReference Include="NLog.Targets.Syslog" Version="6.0.3" />
|
||||
<PackageReference Include="NLog.Extensions.Logging" Version="1.7.4" />
|
||||
<PackageReference Include="Sentry" Version="3.23.1" />
|
||||
<PackageReference Include="Sentry" Version="4.0.2" />
|
||||
<PackageReference Include="SharpZipLib" Version="1.4.2" />
|
||||
<PackageReference Include="System.Text.Json" Version="6.0.8" />
|
||||
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Serializer;
|
||||
using NzbDrone.Core.Datastore.Migration;
|
||||
using NzbDrone.Core.MediaFiles.MediaInfo;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Qualities;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Test.Datastore.Migration
|
||||
{
|
||||
[TestFixture]
|
||||
public class release_typeFixture : MigrationTest<release_type>
|
||||
{
|
||||
[Test]
|
||||
public void should_convert_single_episode_without_folder()
|
||||
{
|
||||
var db = WithMigrationTestDb(c =>
|
||||
{
|
||||
c.Insert.IntoTable("EpisodeFiles").Row(new
|
||||
{
|
||||
SeriesId = 1,
|
||||
SeasonNumber = 1,
|
||||
RelativePath = "Season 01/S01E05.mkv",
|
||||
Size = 125.Megabytes(),
|
||||
DateAdded = DateTime.UtcNow.AddDays(-5),
|
||||
OriginalFilePath = "Series.Title.S01E05.720p.HDTV.x265-Sonarr.mkv",
|
||||
ReleaseGroup = "Sonarr",
|
||||
Quality = new QualityModel(Quality.HDTV720p).ToJson(),
|
||||
Languages = "[1]"
|
||||
});
|
||||
});
|
||||
|
||||
var items = db.Query<EpisodeFile203>("SELECT * FROM \"EpisodeFiles\"");
|
||||
|
||||
items.Should().HaveCount(1);
|
||||
|
||||
items.First().ReleaseType.Should().Be((int)ReleaseType.SingleEpisode);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_convert_single_episode_with_folder()
|
||||
{
|
||||
var db = WithMigrationTestDb(c =>
|
||||
{
|
||||
c.Insert.IntoTable("EpisodeFiles").Row(new
|
||||
{
|
||||
SeriesId = 1,
|
||||
SeasonNumber = 1,
|
||||
RelativePath = "Season 01/S01E05.mkv",
|
||||
Size = 125.Megabytes(),
|
||||
DateAdded = DateTime.UtcNow.AddDays(-5),
|
||||
OriginalFilePath = "Series.Title.S01E05.720p.HDTV.x265-Sonarr/S01E05.mkv",
|
||||
ReleaseGroup = "Sonarr",
|
||||
Quality = new QualityModel(Quality.HDTV720p).ToJson(),
|
||||
Languages = "[1]"
|
||||
});
|
||||
});
|
||||
|
||||
var items = db.Query<EpisodeFile203>("SELECT * FROM \"EpisodeFiles\"");
|
||||
|
||||
items.Should().HaveCount(1);
|
||||
|
||||
items.First().ReleaseType.Should().Be((int)ReleaseType.SingleEpisode);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_convert_multi_episode_without_folder()
|
||||
{
|
||||
var db = WithMigrationTestDb(c =>
|
||||
{
|
||||
c.Insert.IntoTable("EpisodeFiles").Row(new
|
||||
{
|
||||
SeriesId = 1,
|
||||
SeasonNumber = 1,
|
||||
RelativePath = "Season 01/S01E05.mkv",
|
||||
Size = 125.Megabytes(),
|
||||
DateAdded = DateTime.UtcNow.AddDays(-5),
|
||||
OriginalFilePath = "Series.Title.S01E05E06.720p.HDTV.x265-Sonarr.mkv",
|
||||
ReleaseGroup = "Sonarr",
|
||||
Quality = new QualityModel(Quality.HDTV720p).ToJson(),
|
||||
Languages = "[1]"
|
||||
});
|
||||
});
|
||||
|
||||
var items = db.Query<EpisodeFile203>("SELECT * FROM \"EpisodeFiles\"");
|
||||
|
||||
items.Should().HaveCount(1);
|
||||
|
||||
items.First().ReleaseType.Should().Be((int)ReleaseType.MultiEpisode);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_convert_multi_episode_with_folder()
|
||||
{
|
||||
var db = WithMigrationTestDb(c =>
|
||||
{
|
||||
c.Insert.IntoTable("EpisodeFiles").Row(new
|
||||
{
|
||||
SeriesId = 1,
|
||||
SeasonNumber = 1,
|
||||
RelativePath = "Season 01/S01E05.mkv",
|
||||
Size = 125.Megabytes(),
|
||||
DateAdded = DateTime.UtcNow.AddDays(-5),
|
||||
OriginalFilePath = "Series.Title.S01E05E06.720p.HDTV.x265-Sonarr/S01E05E06.mkv",
|
||||
ReleaseGroup = "Sonarr",
|
||||
Quality = new QualityModel(Quality.HDTV720p).ToJson(),
|
||||
Languages = "[1]"
|
||||
});
|
||||
});
|
||||
|
||||
var items = db.Query<EpisodeFile203>("SELECT * FROM \"EpisodeFiles\"");
|
||||
|
||||
items.Should().HaveCount(1);
|
||||
|
||||
items.First().ReleaseType.Should().Be((int)ReleaseType.MultiEpisode);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_convert_season_pack_with_folder()
|
||||
{
|
||||
var db = WithMigrationTestDb(c =>
|
||||
{
|
||||
c.Insert.IntoTable("EpisodeFiles").Row(new
|
||||
{
|
||||
SeriesId = 1,
|
||||
SeasonNumber = 1,
|
||||
RelativePath = "Season 01/S01E05.mkv",
|
||||
Size = 125.Megabytes(),
|
||||
DateAdded = DateTime.UtcNow.AddDays(-5),
|
||||
OriginalFilePath = "Series.Title.S01.720p.HDTV.x265-Sonarr/S01E05.mkv",
|
||||
ReleaseGroup = "Sonarr",
|
||||
Quality = new QualityModel(Quality.HDTV720p).ToJson(),
|
||||
Languages = "[1]"
|
||||
});
|
||||
});
|
||||
|
||||
var items = db.Query<EpisodeFile203>("SELECT * FROM \"EpisodeFiles\"");
|
||||
|
||||
items.Should().HaveCount(1);
|
||||
|
||||
items.First().ReleaseType.Should().Be((int)ReleaseType.SeasonPack);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_convert_episode_without_original_file_path()
|
||||
{
|
||||
var db = WithMigrationTestDb(c =>
|
||||
{
|
||||
c.Insert.IntoTable("EpisodeFiles").Row(new
|
||||
{
|
||||
SeriesId = 1,
|
||||
SeasonNumber = 1,
|
||||
RelativePath = "Season 01/S01E05.mkv",
|
||||
Size = 125.Megabytes(),
|
||||
DateAdded = DateTime.UtcNow.AddDays(-5),
|
||||
ReleaseGroup = "Sonarr",
|
||||
Quality = new QualityModel(Quality.HDTV720p).ToJson(),
|
||||
Languages = "[1]"
|
||||
});
|
||||
});
|
||||
|
||||
var items = db.Query<EpisodeFile203>("SELECT * FROM \"EpisodeFiles\"");
|
||||
|
||||
items.Should().HaveCount(1);
|
||||
|
||||
items.First().ReleaseType.Should().Be((int)ReleaseType.Unknown);
|
||||
}
|
||||
|
||||
public class EpisodeFile203
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int SeriesId { get; set; }
|
||||
public int SeasonNumber { get; set; }
|
||||
public string RelativePath { get; set; }
|
||||
public long Size { get; set; }
|
||||
public DateTime DateAdded { get; set; }
|
||||
public string OriginalFilePath { get; set; }
|
||||
public string SceneName { get; set; }
|
||||
public string ReleaseGroup { get; set; }
|
||||
public QualityModel Quality { get; set; }
|
||||
public long IndexerFlags { get; set; }
|
||||
public MediaInfoModel MediaInfo { get; set; }
|
||||
public List<int> Languages { get; set; }
|
||||
public long ReleaseType { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using NUnit.Framework;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Core.DecisionEngine;
|
||||
using NzbDrone.Core.Download;
|
||||
using NzbDrone.Core.History;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
using NzbDrone.Core.MediaFiles.EpisodeImport;
|
||||
using NzbDrone.Core.MediaFiles.Events;
|
||||
@@ -48,6 +49,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
|
||||
_rejectedDecisions.Add(new ImportDecision(new LocalEpisode(), new Rejection("Rejected!")));
|
||||
_rejectedDecisions.Add(new ImportDecision(new LocalEpisode(), new Rejection("Rejected!")));
|
||||
_rejectedDecisions.Add(new ImportDecision(new LocalEpisode(), new Rejection("Rejected!")));
|
||||
_rejectedDecisions.ForEach(r => r.LocalEpisode.FileEpisodeInfo = new ParsedEpisodeInfo());
|
||||
|
||||
foreach (var episode in episodes)
|
||||
{
|
||||
@@ -58,7 +60,8 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
|
||||
Episodes = new List<Episode> { episode },
|
||||
Path = Path.Combine(series.Path, "30 Rock - S01E01 - Pilot.avi"),
|
||||
Quality = new QualityModel(Quality.Bluray720p),
|
||||
ReleaseGroup = "DRONE"
|
||||
ReleaseGroup = "DRONE",
|
||||
FileEpisodeInfo = new ParsedEpisodeInfo()
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -66,6 +69,10 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
|
||||
.Setup(s => s.UpgradeEpisodeFile(It.IsAny<EpisodeFile>(), It.IsAny<LocalEpisode>(), It.IsAny<bool>()))
|
||||
.Returns(new EpisodeFileMoveResult());
|
||||
|
||||
Mocker.GetMock<IHistoryService>()
|
||||
.Setup(x => x.FindByDownloadId(It.IsAny<string>()))
|
||||
.Returns(new List<EpisodeHistory>());
|
||||
|
||||
_downloadClientItem = Builder<DownloadClientItem>.CreateNew()
|
||||
.With(d => d.OutputPath = new OsPath(outputPath))
|
||||
.Build();
|
||||
|
||||
@@ -441,6 +441,10 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].testtitle.forced.fra.ass", new[] { "forced" }, "testtitle", "French")]
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].fra.forced.testtitle.ass", new[] { "forced" }, "testtitle", "French")]
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].forced.fra.testtitle.ass", new[] { "forced" }, "testtitle", "French")]
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].ru-something-else.srt", new string[0], "something-else", "Russian")]
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].Full Subtitles.eng.ass", new string[0], "Full Subtitles", "English")]
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].mytitle - 1.en.ass", new string[0], "mytitle", "English")]
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].mytitle.en.ass", new string[0], "mytitle", "English")]
|
||||
public void should_parse_title_and_tags(string postTitle, string[] expectedTags, string expectedTitle, string expectedLanguage)
|
||||
{
|
||||
var subtitleTitleInfo = LanguageParser.ParseSubtitleLanguageInformation(postTitle);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Test.ParserTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class NormalizeEpisodeTitleFixture : CoreTest
|
||||
{
|
||||
[TestCase("Episode Title", "episode title")]
|
||||
[TestCase("A.B,C;", "a b c")]
|
||||
[TestCase("Episode Title", "episode title")]
|
||||
[TestCase("French Title (1)", "french title")]
|
||||
[TestCase("Series.Title.S01.Special.Episode.Title.720p.HDTV.x264-Sonarr", "episode title")]
|
||||
[TestCase("Series.Title.S01E00.Episode.Title.720p.HDTV.x264-Sonarr", "episode title")]
|
||||
public void should_normalize_episode_title(string input, string expected)
|
||||
{
|
||||
var result = Parser.Parser.NormalizeEpisodeTitle(input);
|
||||
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ using NzbDrone.Core.Test.Framework;
|
||||
namespace NzbDrone.Core.Test.ParserTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class NormalizeTitleFixture : CoreTest
|
||||
public class NormalizeSeriesTitleFixture : CoreTest
|
||||
{
|
||||
[TestCase("Series", "series")]
|
||||
[TestCase("Series (2009)", "series2009")]
|
||||
@@ -191,12 +191,23 @@ namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests
|
||||
{
|
||||
GivenParseResultSeriesDoesntMatchSearchCriteria();
|
||||
|
||||
Subject.Map(_parsedEpisodeInfo, 10, 10, _singleEpisodeSearchCriteria);
|
||||
Subject.Map(_parsedEpisodeInfo, 0, 10, _singleEpisodeSearchCriteria);
|
||||
|
||||
Mocker.GetMock<ISeriesService>()
|
||||
.Verify(v => v.FindByTvRageId(It.IsAny<int>()), Times.Once());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_FindByTvRageId_when_search_criteria_and_FindByTitle_matching_fails_and_tvdb_id_is_specified()
|
||||
{
|
||||
GivenParseResultSeriesDoesntMatchSearchCriteria();
|
||||
|
||||
Subject.Map(_parsedEpisodeInfo, 10, 10, _singleEpisodeSearchCriteria);
|
||||
|
||||
Mocker.GetMock<ISeriesService>()
|
||||
.Verify(v => v.FindByTvRageId(It.IsAny<int>()), Times.Never());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_use_tvdbid_matching_when_alias_is_found()
|
||||
{
|
||||
|
||||
@@ -166,6 +166,8 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[TestCase("Series Title [HDTV 720p][Cap.101](website.com).mkv", "Series Title", 1, 1)]
|
||||
[TestCase("Босх: Спадок (S2E1) / Series: Legacy (S2E1) (2023) WEB-DL 1080p Ukr/Eng | sub Eng", "Series: Legacy", 2, 1)]
|
||||
[TestCase("Босх: Спадок / Series: Legacy / S2E1 of 10 (2023) WEB-DL 1080p Ukr/Eng | sub Eng", "Series: Legacy", 2, 1)]
|
||||
[TestCase("Titles.s06e01.1999.BDRip.1080p.Ukr.Eng.AC3.Hurtom.TNU.Tenax555", "Titles", 6, 1)]
|
||||
[TestCase("Titles.s06.01.1999.BDRip.1080p.Ukr.Eng.AC3.Hurtom.TNU.Tenax555", "Titles", 6, 1)]
|
||||
|
||||
// [TestCase("", "", 0, 0)]
|
||||
public void should_parse_single_episode(string postTitle, string title, int seasonNumber, int episodeNumber)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.0.123" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" />
|
||||
<PackageReference Include="NBuilder" Version="6.1.0" />
|
||||
<PackageReference Include="System.Data.SQLite.Core.Servarr" Version="1.0.115.5-18" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FizzWare.NBuilder;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
using NzbDrone.Core.MediaFiles.Events;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
@@ -14,14 +16,20 @@ namespace NzbDrone.Core.Test.TvTests.EpisodeServiceTests
|
||||
[TestFixture]
|
||||
public class HandleEpisodeFileDeletedFixture : CoreTest<EpisodeService>
|
||||
{
|
||||
private Series _series;
|
||||
private EpisodeFile _episodeFile;
|
||||
private List<Episode> _episodes;
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
_series = Builder<Series>
|
||||
.CreateNew()
|
||||
.Build();
|
||||
|
||||
_episodeFile = Builder<EpisodeFile>
|
||||
.CreateNew()
|
||||
.With(e => e.SeriesId = _series.Id)
|
||||
.Build();
|
||||
}
|
||||
|
||||
@@ -30,6 +38,7 @@ namespace NzbDrone.Core.Test.TvTests.EpisodeServiceTests
|
||||
_episodes = Builder<Episode>
|
||||
.CreateListOfSize(1)
|
||||
.All()
|
||||
.With(e => e.SeriesId = _series.Id)
|
||||
.With(e => e.Monitored = true)
|
||||
.Build()
|
||||
.ToList();
|
||||
@@ -44,6 +53,7 @@ namespace NzbDrone.Core.Test.TvTests.EpisodeServiceTests
|
||||
_episodes = Builder<Episode>
|
||||
.CreateListOfSize(2)
|
||||
.All()
|
||||
.With(e => e.SeriesId = _series.Id)
|
||||
.With(e => e.Monitored = true)
|
||||
.Build()
|
||||
.ToList();
|
||||
@@ -85,9 +95,31 @@ namespace NzbDrone.Core.Test.TvTests.EpisodeServiceTests
|
||||
.Returns(true);
|
||||
|
||||
Subject.Handle(new EpisodeFileDeletedEvent(_episodeFile, DeleteMediaFileReason.MissingFromDisk));
|
||||
Subject.HandleAsync(new SeriesScannedEvent(_series, new List<string>()));
|
||||
|
||||
Mocker.GetMock<IEpisodeRepository>()
|
||||
.Verify(v => v.ClearFileId(It.IsAny<Episode>(), true), Times.Once());
|
||||
.Verify(v => v.SetMonitored(It.IsAny<IEnumerable<int>>(), false), Times.Once());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_leave_monitored_if_autoUnmonitor_is_true_and_missing_episode_is_replaced()
|
||||
{
|
||||
GivenSingleEpisodeFile();
|
||||
|
||||
var newEpisodeFile = _episodeFile.JsonClone();
|
||||
newEpisodeFile.Id = 123;
|
||||
newEpisodeFile.Episodes = new LazyLoaded<List<Episode>>(_episodes);
|
||||
|
||||
Mocker.GetMock<IConfigService>()
|
||||
.SetupGet(s => s.AutoUnmonitorPreviouslyDownloadedEpisodes)
|
||||
.Returns(true);
|
||||
|
||||
Subject.Handle(new EpisodeFileDeletedEvent(_episodeFile, DeleteMediaFileReason.MissingFromDisk));
|
||||
Subject.Handle(new EpisodeFileAddedEvent(newEpisodeFile));
|
||||
Subject.HandleAsync(new SeriesScannedEvent(_series, new List<string>()));
|
||||
|
||||
Mocker.GetMock<IEpisodeRepository>()
|
||||
.Verify(v => v.SetMonitored(It.IsAny<IEnumerable<int>>(), false), Times.Never());
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -40,7 +40,6 @@ namespace NzbDrone.Core.Test.TvTests
|
||||
private Series GetSeries()
|
||||
{
|
||||
var series = _gameOfThrones.Item1.JsonClone();
|
||||
series.Seasons = new List<Season>();
|
||||
|
||||
return series;
|
||||
}
|
||||
@@ -49,7 +48,6 @@ namespace NzbDrone.Core.Test.TvTests
|
||||
{
|
||||
var series = Builder<Series>.CreateNew().Build();
|
||||
series.SeriesType = SeriesTypes.Anime;
|
||||
series.Seasons = new List<Season>();
|
||||
|
||||
return series;
|
||||
}
|
||||
@@ -178,34 +176,6 @@ namespace NzbDrone.Core.Test.TvTests
|
||||
ExceptionVerification.ExpectedWarns(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_set_monitored_status_for_old_episodes_to_false_if_no_episodes_existed()
|
||||
{
|
||||
var series = GetSeries();
|
||||
series.Seasons = new List<Season>();
|
||||
|
||||
var episodes = GetEpisodes().OrderBy(v => v.SeasonNumber).ThenBy(v => v.EpisodeNumber).Take(4).ToList();
|
||||
|
||||
episodes[1].AirDateUtc = DateTime.UtcNow.AddDays(-15);
|
||||
episodes[2].AirDateUtc = DateTime.UtcNow.AddDays(-10);
|
||||
episodes[3].AirDateUtc = DateTime.UtcNow.AddDays(1);
|
||||
|
||||
Mocker.GetMock<IEpisodeService>().Setup(c => c.GetEpisodeBySeries(It.IsAny<int>()))
|
||||
.Returns(new List<Episode>());
|
||||
|
||||
Subject.RefreshEpisodeInfo(series, episodes);
|
||||
|
||||
_insertedEpisodes = _insertedEpisodes.OrderBy(v => v.EpisodeNumber).ToList();
|
||||
|
||||
_insertedEpisodes.Should().HaveSameCount(episodes);
|
||||
_insertedEpisodes[0].Monitored.Should().Be(false);
|
||||
_insertedEpisodes[1].Monitored.Should().Be(false);
|
||||
_insertedEpisodes[2].Monitored.Should().Be(false);
|
||||
_insertedEpisodes[3].Monitored.Should().Be(true);
|
||||
|
||||
ExceptionVerification.ExpectedWarns(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_remove_duplicate_remote_episodes_before_processing()
|
||||
{
|
||||
@@ -259,65 +229,6 @@ namespace NzbDrone.Core.Test.TvTests
|
||||
_deletedEpisodes.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_get_new_season_and_episode_numbers_when_absolute_episode_number_match_found()
|
||||
{
|
||||
const int expectedSeasonNumber = 10;
|
||||
const int expectedEpisodeNumber = 5;
|
||||
const int expectedAbsoluteNumber = 3;
|
||||
|
||||
var episode = Builder<Episode>.CreateNew()
|
||||
.With(e => e.SeasonNumber = expectedSeasonNumber)
|
||||
.With(e => e.EpisodeNumber = expectedEpisodeNumber)
|
||||
.With(e => e.AbsoluteEpisodeNumber = expectedAbsoluteNumber)
|
||||
.Build();
|
||||
|
||||
var existingEpisode = episode.JsonClone();
|
||||
existingEpisode.SeasonNumber = 1;
|
||||
existingEpisode.EpisodeNumber = 1;
|
||||
existingEpisode.AbsoluteEpisodeNumber = expectedAbsoluteNumber;
|
||||
|
||||
Mocker.GetMock<IEpisodeService>().Setup(c => c.GetEpisodeBySeries(It.IsAny<int>()))
|
||||
.Returns(new List<Episode> { existingEpisode });
|
||||
|
||||
Subject.RefreshEpisodeInfo(GetAnimeSeries(), new List<Episode> { episode });
|
||||
|
||||
_insertedEpisodes.Should().BeEmpty();
|
||||
_deletedEpisodes.Should().BeEmpty();
|
||||
|
||||
_updatedEpisodes.First().SeasonNumber.Should().Be(expectedSeasonNumber);
|
||||
_updatedEpisodes.First().EpisodeNumber.Should().Be(expectedEpisodeNumber);
|
||||
_updatedEpisodes.First().AbsoluteEpisodeNumber.Should().Be(expectedAbsoluteNumber);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_prefer_absolute_match_over_season_and_epsiode_match()
|
||||
{
|
||||
var episodes = Builder<Episode>.CreateListOfSize(2)
|
||||
.Build()
|
||||
.ToList();
|
||||
|
||||
episodes[0].AbsoluteEpisodeNumber = null;
|
||||
episodes[0].SeasonNumber.Should().NotBe(episodes[1].SeasonNumber);
|
||||
episodes[0].EpisodeNumber.Should().NotBe(episodes[1].EpisodeNumber);
|
||||
|
||||
var existingEpisode = new Episode
|
||||
{
|
||||
SeasonNumber = episodes[0].SeasonNumber,
|
||||
EpisodeNumber = episodes[0].EpisodeNumber,
|
||||
AbsoluteEpisodeNumber = episodes[1].AbsoluteEpisodeNumber
|
||||
};
|
||||
|
||||
Mocker.GetMock<IEpisodeService>().Setup(c => c.GetEpisodeBySeries(It.IsAny<int>()))
|
||||
.Returns(new List<Episode> { existingEpisode });
|
||||
|
||||
Subject.RefreshEpisodeInfo(GetAnimeSeries(), episodes);
|
||||
|
||||
_updatedEpisodes.First().SeasonNumber.Should().Be(episodes[1].SeasonNumber);
|
||||
_updatedEpisodes.First().EpisodeNumber.Should().Be(episodes[1].EpisodeNumber);
|
||||
_updatedEpisodes.First().AbsoluteEpisodeNumber.Should().Be(episodes[1].AbsoluteEpisodeNumber);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_ignore_episodes_with_no_absolute_episode_in_distinct_by_absolute()
|
||||
{
|
||||
@@ -427,14 +338,14 @@ namespace NzbDrone.Core.Test.TvTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_prefer_regular_season_when_absolute_numbers_conflict()
|
||||
public void should_match_anime_episodes_by_season_and_episode_numbers()
|
||||
{
|
||||
var episodes = Builder<Episode>.CreateListOfSize(2)
|
||||
.Build()
|
||||
.ToList();
|
||||
.Build()
|
||||
.ToList();
|
||||
|
||||
episodes[0].AbsoluteEpisodeNumber = episodes[1].AbsoluteEpisodeNumber;
|
||||
episodes[0].SeasonNumber = 0;
|
||||
episodes[0].AbsoluteEpisodeNumber = null;
|
||||
episodes[0].SeasonNumber.Should().NotBe(episodes[1].SeasonNumber);
|
||||
episodes[0].EpisodeNumber.Should().NotBe(episodes[1].EpisodeNumber);
|
||||
|
||||
var existingEpisode = new Episode
|
||||
@@ -449,9 +360,41 @@ namespace NzbDrone.Core.Test.TvTests
|
||||
|
||||
Subject.RefreshEpisodeInfo(GetAnimeSeries(), episodes);
|
||||
|
||||
_updatedEpisodes.First().SeasonNumber.Should().Be(episodes[0].SeasonNumber);
|
||||
_updatedEpisodes.First().EpisodeNumber.Should().Be(episodes[0].EpisodeNumber);
|
||||
_updatedEpisodes.First().AbsoluteEpisodeNumber.Should().Be(episodes[0].AbsoluteEpisodeNumber);
|
||||
|
||||
_insertedEpisodes.First().SeasonNumber.Should().Be(episodes[1].SeasonNumber);
|
||||
_insertedEpisodes.First().EpisodeNumber.Should().Be(episodes[1].EpisodeNumber);
|
||||
_insertedEpisodes.First().AbsoluteEpisodeNumber.Should().Be(episodes[1].AbsoluteEpisodeNumber);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_mark_updated_episodes_that_have_newly_added_absolute_episode_number()
|
||||
{
|
||||
var episodes = Builder<Episode>.CreateListOfSize(3)
|
||||
.Build()
|
||||
.ToList();
|
||||
|
||||
var existingEpisodes = new List<Episode>
|
||||
{
|
||||
episodes[0],
|
||||
episodes[1]
|
||||
};
|
||||
|
||||
existingEpisodes[0].AbsoluteEpisodeNumber = null;
|
||||
|
||||
Mocker.GetMock<IEpisodeService>().Setup(c => c.GetEpisodeBySeries(It.IsAny<int>()))
|
||||
.Returns(existingEpisodes);
|
||||
|
||||
Subject.RefreshEpisodeInfo(GetAnimeSeries(), episodes);
|
||||
|
||||
_updatedEpisodes.First().SeasonNumber.Should().Be(episodes[1].SeasonNumber);
|
||||
_updatedEpisodes.First().EpisodeNumber.Should().Be(episodes[1].EpisodeNumber);
|
||||
_updatedEpisodes.First().AbsoluteEpisodeNumber.Should().Be(episodes[1].AbsoluteEpisodeNumber);
|
||||
_updatedEpisodes.First().AbsoluteEpisodeNumber.Should().NotBeNull();
|
||||
_updatedEpisodes.First().AbsoluteEpisodeNumberAdded.Should().BeTrue();
|
||||
|
||||
_insertedEpisodes.Any(e => e.AbsoluteEpisodeNumberAdded).Should().BeFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.Indexers;
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Qualities;
|
||||
using NzbDrone.Core.Tv;
|
||||
|
||||
@@ -20,6 +21,8 @@ namespace NzbDrone.Core.Blocklisting
|
||||
public long? Size { get; set; }
|
||||
public DownloadProtocol Protocol { get; set; }
|
||||
public string Indexer { get; set; }
|
||||
public IndexerFlags IndexerFlags { get; set; }
|
||||
public ReleaseType ReleaseType { get; set; }
|
||||
public string Message { get; set; }
|
||||
public string TorrentInfoHash { get; set; }
|
||||
public List<Language> Languages { get; set; }
|
||||
|
||||
@@ -174,20 +174,30 @@ namespace NzbDrone.Core.Blocklisting
|
||||
public void Handle(DownloadFailedEvent message)
|
||||
{
|
||||
var blocklist = new Blocklist
|
||||
{
|
||||
SeriesId = message.SeriesId,
|
||||
EpisodeIds = message.EpisodeIds,
|
||||
SourceTitle = message.SourceTitle,
|
||||
Quality = message.Quality,
|
||||
Date = DateTime.UtcNow,
|
||||
PublishedDate = DateTime.Parse(message.Data.GetValueOrDefault("publishedDate")),
|
||||
Size = long.Parse(message.Data.GetValueOrDefault("size", "0")),
|
||||
Indexer = message.Data.GetValueOrDefault("indexer"),
|
||||
Protocol = (DownloadProtocol)Convert.ToInt32(message.Data.GetValueOrDefault("protocol")),
|
||||
Message = message.Message,
|
||||
TorrentInfoHash = message.Data.GetValueOrDefault("torrentInfoHash"),
|
||||
Languages = message.Languages
|
||||
};
|
||||
{
|
||||
SeriesId = message.SeriesId,
|
||||
EpisodeIds = message.EpisodeIds,
|
||||
SourceTitle = message.SourceTitle,
|
||||
Quality = message.Quality,
|
||||
Date = DateTime.UtcNow,
|
||||
PublishedDate = DateTime.Parse(message.Data.GetValueOrDefault("publishedDate")),
|
||||
Size = long.Parse(message.Data.GetValueOrDefault("size", "0")),
|
||||
Indexer = message.Data.GetValueOrDefault("indexer"),
|
||||
Protocol = (DownloadProtocol)Convert.ToInt32(message.Data.GetValueOrDefault("protocol")),
|
||||
Message = message.Message,
|
||||
TorrentInfoHash = message.Data.GetValueOrDefault("torrentInfoHash"),
|
||||
Languages = message.Languages
|
||||
};
|
||||
|
||||
if (Enum.TryParse(message.Data.GetValueOrDefault("indexerFlags"), true, out IndexerFlags flags))
|
||||
{
|
||||
blocklist.IndexerFlags = flags;
|
||||
}
|
||||
|
||||
if (Enum.TryParse(message.Data.GetValueOrDefault("releaseType"), true, out ReleaseType releaseType))
|
||||
{
|
||||
blocklist.ReleaseType = releaseType;
|
||||
}
|
||||
|
||||
_blocklistRepository.Insert(blocklist);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
@@ -39,7 +40,9 @@ namespace NzbDrone.Core.CustomFormats
|
||||
EpisodeInfo = remoteEpisode.ParsedEpisodeInfo,
|
||||
Series = remoteEpisode.Series,
|
||||
Size = size,
|
||||
Languages = remoteEpisode.Languages
|
||||
Languages = remoteEpisode.Languages,
|
||||
IndexerFlags = remoteEpisode.Release?.IndexerFlags ?? 0,
|
||||
ReleaseType = remoteEpisode.ParsedEpisodeInfo.ReleaseType
|
||||
};
|
||||
|
||||
return ParseCustomFormat(input);
|
||||
@@ -73,7 +76,9 @@ namespace NzbDrone.Core.CustomFormats
|
||||
EpisodeInfo = episodeInfo,
|
||||
Series = series,
|
||||
Size = blocklist.Size ?? 0,
|
||||
Languages = blocklist.Languages
|
||||
Languages = blocklist.Languages,
|
||||
IndexerFlags = blocklist.IndexerFlags,
|
||||
ReleaseType = blocklist.ReleaseType
|
||||
};
|
||||
|
||||
return ParseCustomFormat(input);
|
||||
@@ -84,6 +89,8 @@ namespace NzbDrone.Core.CustomFormats
|
||||
var parsed = Parser.Parser.ParseTitle(history.SourceTitle);
|
||||
|
||||
long.TryParse(history.Data.GetValueOrDefault("size"), out var size);
|
||||
Enum.TryParse(history.Data.GetValueOrDefault("indexerFlags"), true, out IndexerFlags indexerFlags);
|
||||
Enum.TryParse(history.Data.GetValueOrDefault("releaseType"), out ReleaseType releaseType);
|
||||
|
||||
var episodeInfo = new ParsedEpisodeInfo
|
||||
{
|
||||
@@ -99,7 +106,9 @@ namespace NzbDrone.Core.CustomFormats
|
||||
EpisodeInfo = episodeInfo,
|
||||
Series = series,
|
||||
Size = size,
|
||||
Languages = history.Languages
|
||||
Languages = history.Languages,
|
||||
IndexerFlags = indexerFlags,
|
||||
ReleaseType = releaseType
|
||||
};
|
||||
|
||||
return ParseCustomFormat(input);
|
||||
@@ -122,6 +131,8 @@ namespace NzbDrone.Core.CustomFormats
|
||||
Series = localEpisode.Series,
|
||||
Size = localEpisode.Size,
|
||||
Languages = localEpisode.Languages,
|
||||
IndexerFlags = localEpisode.IndexerFlags,
|
||||
ReleaseType = localEpisode.ReleaseType,
|
||||
Filename = Path.GetFileName(localEpisode.Path)
|
||||
};
|
||||
|
||||
@@ -182,7 +193,7 @@ namespace NzbDrone.Core.CustomFormats
|
||||
ReleaseTitle = releaseTitle,
|
||||
Quality = episodeFile.Quality,
|
||||
Languages = episodeFile.Languages,
|
||||
ReleaseGroup = episodeFile.ReleaseGroup
|
||||
ReleaseGroup = episodeFile.ReleaseGroup,
|
||||
};
|
||||
|
||||
var input = new CustomFormatInput
|
||||
@@ -191,7 +202,9 @@ namespace NzbDrone.Core.CustomFormats
|
||||
Series = series,
|
||||
Size = episodeFile.Size,
|
||||
Languages = episodeFile.Languages,
|
||||
Filename = Path.GetFileName(episodeFile.RelativePath)
|
||||
IndexerFlags = episodeFile.IndexerFlags,
|
||||
ReleaseType = episodeFile.ReleaseType,
|
||||
Filename = Path.GetFileName(episodeFile.RelativePath),
|
||||
};
|
||||
|
||||
return ParseCustomFormat(input, allCustomFormats);
|
||||
|
||||
@@ -10,8 +10,10 @@ namespace NzbDrone.Core.CustomFormats
|
||||
public ParsedEpisodeInfo EpisodeInfo { get; set; }
|
||||
public Series Series { get; set; }
|
||||
public long Size { get; set; }
|
||||
public IndexerFlags IndexerFlags { get; set; }
|
||||
public List<Language> Languages { get; set; }
|
||||
public string Filename { get; set; }
|
||||
public ReleaseType ReleaseType { get; set; }
|
||||
|
||||
public CustomFormatInput()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using FluentValidation;
|
||||
using NzbDrone.Core.Annotations;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Validation;
|
||||
|
||||
namespace NzbDrone.Core.CustomFormats
|
||||
{
|
||||
public class IndexerFlagSpecificationValidator : AbstractValidator<IndexerFlagSpecification>
|
||||
{
|
||||
public IndexerFlagSpecificationValidator()
|
||||
{
|
||||
RuleFor(c => c.Value).NotEmpty();
|
||||
RuleFor(c => c.Value).Custom((flag, context) =>
|
||||
{
|
||||
if (!Enum.IsDefined(typeof(IndexerFlags), flag))
|
||||
{
|
||||
context.AddFailure($"Invalid indexer flag condition value: {flag}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class IndexerFlagSpecification : CustomFormatSpecificationBase
|
||||
{
|
||||
private static readonly IndexerFlagSpecificationValidator Validator = new ();
|
||||
|
||||
public override int Order => 4;
|
||||
public override string ImplementationName => "Indexer Flag";
|
||||
|
||||
[FieldDefinition(1, Label = "CustomFormatsSpecificationFlag", Type = FieldType.Select, SelectOptions = typeof(IndexerFlags))]
|
||||
public int Value { get; set; }
|
||||
|
||||
protected override bool IsSatisfiedByWithoutNegate(CustomFormatInput input)
|
||||
{
|
||||
return input.IndexerFlags.HasFlag((IndexerFlags)Value);
|
||||
}
|
||||
|
||||
public override NzbDroneValidationResult Validate()
|
||||
{
|
||||
return new NzbDroneValidationResult(Validator.Validate(this));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using FluentValidation;
|
||||
using NzbDrone.Core.Annotations;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Validation;
|
||||
|
||||
namespace NzbDrone.Core.CustomFormats
|
||||
{
|
||||
public class SeasonPackSpecificationValidator : AbstractValidator<ReleaseTypeSpecification>
|
||||
{
|
||||
public SeasonPackSpecificationValidator()
|
||||
{
|
||||
RuleFor(c => c.Value).Custom((releaseType, context) =>
|
||||
{
|
||||
if (!Enum.IsDefined(typeof(ReleaseType), releaseType))
|
||||
{
|
||||
context.AddFailure($"Invalid release type condition value: {releaseType}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class ReleaseTypeSpecification : CustomFormatSpecificationBase
|
||||
{
|
||||
private static readonly SeasonPackSpecificationValidator Validator = new ();
|
||||
|
||||
public override int Order => 10;
|
||||
public override string ImplementationName => "Release Type";
|
||||
|
||||
[FieldDefinition(1, Label = "ReleaseType", Type = FieldType.Select, SelectOptions = typeof(ReleaseType))]
|
||||
public int Value { get; set; }
|
||||
|
||||
protected override bool IsSatisfiedByWithoutNegate(CustomFormatInput input)
|
||||
{
|
||||
return input.ReleaseType == (ReleaseType)Value;
|
||||
}
|
||||
|
||||
public override NzbDroneValidationResult Validate()
|
||||
{
|
||||
return new NzbDroneValidationResult(Validator.Validate(this));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using FluentMigrator;
|
||||
using NzbDrone.Core.Datastore.Migration.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Datastore.Migration
|
||||
{
|
||||
[Migration(202)]
|
||||
public class add_indexer_flags : NzbDroneMigrationBase
|
||||
{
|
||||
protected override void MainDbUpgrade()
|
||||
{
|
||||
Alter.Table("Blocklist").AddColumn("IndexerFlags").AsInt32().WithDefaultValue(0);
|
||||
Alter.Table("EpisodeFiles").AddColumn("IndexerFlags").AsInt32().WithDefaultValue(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user