parent
46367d2023
commit
68c326ae27
@ -0,0 +1,148 @@
|
|||||||
|
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 Form from 'Components/Form/Form';
|
||||||
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
|
import FormLabel from 'Components/Form/FormLabel';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import { inputTypes } from 'Helpers/Props';
|
||||||
|
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||||
|
import {
|
||||||
|
fetchImportListOptions,
|
||||||
|
saveImportListOptions,
|
||||||
|
setImportListOptionsValue,
|
||||||
|
} from 'Store/Actions/settingsActions';
|
||||||
|
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
||||||
|
const SECTION = 'importListOptions';
|
||||||
|
const cleanLibraryLevelOptions = [
|
||||||
|
{ key: 'disabled', value: () => translate('Disabled') },
|
||||||
|
{ key: 'logOnly', value: () => translate('LogOnly') },
|
||||||
|
{ key: 'keepAndUnmonitor', value: () => translate('KeepAndUnmonitorSeries') },
|
||||||
|
{ key: 'keepAndTag', value: () => translate('KeepAndTagSeries') },
|
||||||
|
];
|
||||||
|
|
||||||
|
function createImportListOptionsSelector() {
|
||||||
|
return createSelector(
|
||||||
|
(state: AppState) => state.settings.advancedSettings,
|
||||||
|
createSettingsSectionSelector(SECTION),
|
||||||
|
(advancedSettings, sectionSettings) => {
|
||||||
|
return {
|
||||||
|
advancedSettings,
|
||||||
|
save: sectionSettings.isSaving,
|
||||||
|
...sectionSettings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportListOptionsPageProps {
|
||||||
|
setChildSave(saveCallback: () => void): void;
|
||||||
|
onChildStateChange(payload: unknown): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportListOptions(props: ImportListOptionsPageProps) {
|
||||||
|
const { setChildSave, onChildStateChange } = props;
|
||||||
|
const selected = useSelector(createImportListOptionsSelector());
|
||||||
|
|
||||||
|
const {
|
||||||
|
isSaving,
|
||||||
|
hasPendingChanges,
|
||||||
|
advancedSettings,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
settings,
|
||||||
|
hasSettings,
|
||||||
|
} = selected;
|
||||||
|
|
||||||
|
const { listSyncLevel, listSyncTag } = settings;
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const onInputChange = useCallback(
|
||||||
|
({ name, value }: { name: string; value: unknown }) => {
|
||||||
|
// @ts-expect-error 'setImportListOptionsValue' isn't typed yet
|
||||||
|
dispatch(setImportListOptionsValue({ name, value }));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onTagChange = useCallback(
|
||||||
|
({ name, value }: { name: string; value: number[] }) => {
|
||||||
|
const id = value.length === 0 ? 0 : value.pop();
|
||||||
|
// @ts-expect-error 'setImportListOptionsValue' isn't typed yet
|
||||||
|
dispatch(setImportListOptionsValue({ name, value: id }));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchImportListOptions());
|
||||||
|
setChildSave(() => dispatch(saveImportListOptions()));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dispatch(clearPendingChanges({ section: SECTION }));
|
||||||
|
};
|
||||||
|
}, [dispatch, setChildSave]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChildStateChange({
|
||||||
|
isSaving,
|
||||||
|
hasPendingChanges,
|
||||||
|
});
|
||||||
|
}, [onChildStateChange, isSaving, hasPendingChanges]);
|
||||||
|
|
||||||
|
const translatedLevelOptions = cleanLibraryLevelOptions.map(
|
||||||
|
({ key, value }) => {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
value: value(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return advancedSettings ? (
|
||||||
|
<FieldSet legend={translate('Options')}>
|
||||||
|
{isFetching ? <LoadingIndicator /> : null}
|
||||||
|
|
||||||
|
{!isFetching && error ? (
|
||||||
|
<div>{translate('UnableToLoadListOptions')}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasSettings && !isFetching && !error ? (
|
||||||
|
<Form>
|
||||||
|
<FormGroup advancedSettings={advancedSettings} isAdvanced={true}>
|
||||||
|
<FormLabel>{translate('CleanLibraryLevel')}</FormLabel>
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.SELECT}
|
||||||
|
name="listSyncLevel"
|
||||||
|
values={translatedLevelOptions}
|
||||||
|
helpText={translate('ListSyncLevelHelpText')}
|
||||||
|
onChange={onInputChange}
|
||||||
|
{...listSyncLevel}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
{listSyncLevel.value === 'keepAndTag' ? (
|
||||||
|
<FormGroup advancedSettings={advancedSettings} isAdvanced={true}>
|
||||||
|
<FormLabel>{translate('ListSyncTag')}</FormLabel>
|
||||||
|
<FormInputGroup
|
||||||
|
{...listSyncTag}
|
||||||
|
type={inputTypes.TAG}
|
||||||
|
name="listSyncTag"
|
||||||
|
value={listSyncTag.value === 0 ? [] : [listSyncTag.value]}
|
||||||
|
helpText={translate('ListSyncTagHelpText')}
|
||||||
|
onChange={onTagChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
) : null}
|
||||||
|
</Form>
|
||||||
|
) : null}
|
||||||
|
</FieldSet>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImportListOptions;
|
@ -0,0 +1,64 @@
|
|||||||
|
import { createAction } from 'redux-actions';
|
||||||
|
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
|
||||||
|
import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
|
||||||
|
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
|
||||||
|
import { createThunk } from 'Store/thunks';
|
||||||
|
|
||||||
|
//
|
||||||
|
// Variables
|
||||||
|
|
||||||
|
const section = 'settings.importListOptions';
|
||||||
|
|
||||||
|
//
|
||||||
|
// Actions Types
|
||||||
|
|
||||||
|
export const FETCH_IMPORT_LIST_OPTIONS = 'settings/importListOptions/fetchImportListOptions';
|
||||||
|
export const SAVE_IMPORT_LIST_OPTIONS = 'settings/importListOptions/saveImportListOptions';
|
||||||
|
export const SET_IMPORT_LIST_OPTIONS_VALUE = 'settings/importListOptions/setImportListOptionsValue';
|
||||||
|
|
||||||
|
//
|
||||||
|
// Action Creators
|
||||||
|
|
||||||
|
export const fetchImportListOptions = createThunk(FETCH_IMPORT_LIST_OPTIONS);
|
||||||
|
export const saveImportListOptions = createThunk(SAVE_IMPORT_LIST_OPTIONS);
|
||||||
|
export const setImportListOptionsValue = createAction(SET_IMPORT_LIST_OPTIONS_VALUE, (payload) => {
|
||||||
|
return {
|
||||||
|
section,
|
||||||
|
...payload
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
//
|
||||||
|
// Details
|
||||||
|
|
||||||
|
export default {
|
||||||
|
|
||||||
|
//
|
||||||
|
// State
|
||||||
|
|
||||||
|
defaultState: {
|
||||||
|
isFetching: false,
|
||||||
|
isPopulated: false,
|
||||||
|
error: null,
|
||||||
|
pendingChanges: {},
|
||||||
|
isSaving: false,
|
||||||
|
saveError: null,
|
||||||
|
item: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// Action Handlers
|
||||||
|
|
||||||
|
actionHandlers: {
|
||||||
|
[FETCH_IMPORT_LIST_OPTIONS]: createFetchHandler(section, '/config/importlist'),
|
||||||
|
[SAVE_IMPORT_LIST_OPTIONS]: createSaveHandler(section, '/config/importlist')
|
||||||
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// Reducers
|
||||||
|
|
||||||
|
reducers: {
|
||||||
|
[SET_IMPORT_LIST_OPTIONS_VALUE]: createSetSettingValueReducer(section)
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
@ -1,32 +0,0 @@
|
|||||||
import { createSelector } from 'reselect';
|
|
||||||
import selectSettings from 'Store/Selectors/selectSettings';
|
|
||||||
|
|
||||||
function createSettingsSectionSelector(section) {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.settings[section],
|
|
||||||
(sectionSettings) => {
|
|
||||||
const {
|
|
||||||
isFetching,
|
|
||||||
isPopulated,
|
|
||||||
error,
|
|
||||||
item,
|
|
||||||
pendingChanges,
|
|
||||||
isSaving,
|
|
||||||
saveError
|
|
||||||
} = sectionSettings;
|
|
||||||
|
|
||||||
const settings = selectSettings(item, pendingChanges, saveError);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isFetching,
|
|
||||||
isPopulated,
|
|
||||||
error,
|
|
||||||
isSaving,
|
|
||||||
saveError,
|
|
||||||
...settings
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default createSettingsSectionSelector;
|
|
@ -0,0 +1,49 @@
|
|||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import AppSectionState, {
|
||||||
|
AppSectionItemState,
|
||||||
|
} from 'App/State/AppSectionState';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
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
|
||||||
|
: never;
|
||||||
|
|
||||||
|
type AppStateWithPending<Name extends SettingNames> = {
|
||||||
|
item?: GetSettingsSectionItemType<Name>;
|
||||||
|
pendingChanges?: Partial<GetSettingsSectionItemType<Name>>;
|
||||||
|
saveError?: Error;
|
||||||
|
} & GetSectionState<Name>;
|
||||||
|
|
||||||
|
function createSettingsSectionSelector<Name extends SettingNames>(
|
||||||
|
section: Name
|
||||||
|
) {
|
||||||
|
return createSelector(
|
||||||
|
(state: AppState) => state.settings[section],
|
||||||
|
(sectionSettings) => {
|
||||||
|
const { item, pendingChanges, saveError, ...other } =
|
||||||
|
sectionSettings as AppStateWithPending<Name>;
|
||||||
|
|
||||||
|
const { settings, ...rest } = selectSettings(
|
||||||
|
item,
|
||||||
|
pendingChanges,
|
||||||
|
saveError
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...other,
|
||||||
|
saveError,
|
||||||
|
settings: settings as PendingSection<GetSettingsSectionItemType<Name>>,
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createSettingsSectionSelector;
|
@ -0,0 +1,10 @@
|
|||||||
|
export type ListSyncLevel =
|
||||||
|
| 'disabled'
|
||||||
|
| 'logOnly'
|
||||||
|
| 'keepAndUnmonitor'
|
||||||
|
| 'keepAndTag';
|
||||||
|
|
||||||
|
export default interface ImportListOptionsSettings {
|
||||||
|
listSyncLevel: ListSyncLevel;
|
||||||
|
listSyncTag: number;
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
export interface Pending<T> {
|
||||||
|
value: T;
|
||||||
|
errors: any[];
|
||||||
|
warnings: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PendingSection<T> = {
|
||||||
|
[K in keyof T]: Pending<T[K]>;
|
||||||
|
};
|
@ -0,0 +1,219 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using FizzWare.NBuilder;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Moq;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using NzbDrone.Core.ImportLists;
|
||||||
|
using NzbDrone.Core.ImportLists.ImportListItems;
|
||||||
|
using NzbDrone.Core.MetadataSource;
|
||||||
|
using NzbDrone.Core.Parser.Model;
|
||||||
|
using NzbDrone.Core.Test.Framework;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Test.ImportListTests
|
||||||
|
{
|
||||||
|
[TestFixture]
|
||||||
|
public class FetchAndParseImportListServiceFixture : CoreTest<FetchAndParseImportListService>
|
||||||
|
{
|
||||||
|
private List<IImportList> _importLists;
|
||||||
|
private List<ImportListItemInfo> _listSeries;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void Setup()
|
||||||
|
{
|
||||||
|
_importLists = new List<IImportList>();
|
||||||
|
|
||||||
|
Mocker.GetMock<IImportListFactory>()
|
||||||
|
.Setup(v => v.AutomaticAddEnabled(It.IsAny<bool>()))
|
||||||
|
.Returns(_importLists);
|
||||||
|
|
||||||
|
_listSeries = Builder<ImportListItemInfo>.CreateListOfSize(5)
|
||||||
|
.Build().ToList();
|
||||||
|
|
||||||
|
Mocker.GetMock<ISearchForNewSeries>()
|
||||||
|
.Setup(v => v.SearchForNewSeriesByImdbId(It.IsAny<string>()))
|
||||||
|
.Returns((string value) => new List<Tv.Series>() { new Tv.Series() { ImdbId = value } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mock<IImportList> WithList(int id, bool enabled, bool enabledAuto, ImportListFetchResult fetchResult, TimeSpan? minRefresh = null, int? lastSyncOffset = null, int? syncDeletedCount = null)
|
||||||
|
{
|
||||||
|
return CreateListResult(id, enabled, enabledAuto, fetchResult, minRefresh, lastSyncOffset, syncDeletedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mock<IImportList> CreateListResult(int id, bool enabled, bool enabledAuto, ImportListFetchResult fetchResult, TimeSpan? minRefresh = null, int? lastSyncOffset = null, int? syncDeletedCount = null)
|
||||||
|
{
|
||||||
|
var refreshInterval = minRefresh ?? TimeSpan.FromHours(12);
|
||||||
|
var importListDefinition = new ImportListDefinition { Id = id, Enable = enabled, EnableAutomaticAdd = enabledAuto, MinRefreshInterval = refreshInterval };
|
||||||
|
|
||||||
|
var mockImportList = new Mock<IImportList>();
|
||||||
|
mockImportList.SetupGet(s => s.Definition).Returns(importListDefinition);
|
||||||
|
mockImportList.Setup(s => s.Fetch()).Returns(fetchResult);
|
||||||
|
mockImportList.SetupGet(s => s.MinRefreshInterval).Returns(refreshInterval);
|
||||||
|
|
||||||
|
DateTime? lastSync = lastSyncOffset.HasValue ? DateTime.UtcNow.AddHours(lastSyncOffset.Value) : null;
|
||||||
|
Mocker.GetMock<IImportListStatusService>()
|
||||||
|
.Setup(v => v.GetListStatus(id))
|
||||||
|
.Returns(new ImportListStatus() { LastInfoSync = lastSync });
|
||||||
|
|
||||||
|
if (syncDeletedCount.HasValue)
|
||||||
|
{
|
||||||
|
Mocker.GetMock<IImportListItemService>()
|
||||||
|
.Setup(v => v.SyncSeriesForList(It.IsAny<List<ImportListItemInfo>>(), id))
|
||||||
|
.Returns(syncDeletedCount.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
_importLists.Add(mockImportList.Object);
|
||||||
|
|
||||||
|
return mockImportList;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_skip_recently_fetched_list()
|
||||||
|
{
|
||||||
|
var fetchResult = new ImportListFetchResult();
|
||||||
|
var list = WithList(1, true, true, fetchResult, lastSyncOffset: 0);
|
||||||
|
|
||||||
|
var result = Subject.Fetch();
|
||||||
|
|
||||||
|
list.Verify(f => f.Fetch(), Times.Never());
|
||||||
|
result.Series.Count.Should().Be(0);
|
||||||
|
result.AnyFailure.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_skip_recent_and_fetch_good()
|
||||||
|
{
|
||||||
|
var fetchResult = new ImportListFetchResult();
|
||||||
|
var recent = WithList(1, true, true, fetchResult, lastSyncOffset: 0);
|
||||||
|
var old = WithList(2, true, true, fetchResult);
|
||||||
|
|
||||||
|
var result = Subject.Fetch();
|
||||||
|
|
||||||
|
recent.Verify(f => f.Fetch(), Times.Never());
|
||||||
|
old.Verify(f => f.Fetch(), Times.Once());
|
||||||
|
result.AnyFailure.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_return_failure_if_single_list_fails()
|
||||||
|
{
|
||||||
|
var fetchResult = new ImportListFetchResult { Series = _listSeries, AnyFailure = true };
|
||||||
|
WithList(1, true, true, fetchResult);
|
||||||
|
|
||||||
|
var listResult = Subject.Fetch();
|
||||||
|
listResult.AnyFailure.Should().BeTrue();
|
||||||
|
|
||||||
|
Mocker.GetMock<IImportListStatusService>()
|
||||||
|
.Verify(v => v.UpdateListSyncStatus(It.IsAny<int>(), It.IsAny<bool>()), Times.Never());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_return_failure_if_any_list_fails()
|
||||||
|
{
|
||||||
|
var fetchResult1 = new ImportListFetchResult { Series = _listSeries, AnyFailure = true };
|
||||||
|
WithList(1, true, true, fetchResult1);
|
||||||
|
var fetchResult2 = new ImportListFetchResult { Series = _listSeries, AnyFailure = false };
|
||||||
|
WithList(2, true, true, fetchResult2);
|
||||||
|
|
||||||
|
var listResult = Subject.Fetch();
|
||||||
|
listResult.AnyFailure.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_return_early_if_no_available_lists()
|
||||||
|
{
|
||||||
|
var listResult = Subject.Fetch();
|
||||||
|
|
||||||
|
Mocker.GetMock<IImportListStatusService>()
|
||||||
|
.Verify(v => v.GetListStatus(It.IsAny<int>()), Times.Never());
|
||||||
|
|
||||||
|
listResult.Series.Count.Should().Be(0);
|
||||||
|
listResult.AnyFailure.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_store_series_if_list_doesnt_fail()
|
||||||
|
{
|
||||||
|
var listId = 1;
|
||||||
|
var fetchResult = new ImportListFetchResult { Series = _listSeries, AnyFailure = false };
|
||||||
|
WithList(listId, true, true, fetchResult);
|
||||||
|
|
||||||
|
var listResult = Subject.Fetch();
|
||||||
|
listResult.AnyFailure.Should().BeFalse();
|
||||||
|
|
||||||
|
Mocker.GetMock<IImportListStatusService>()
|
||||||
|
.Verify(v => v.UpdateListSyncStatus(listId, false), Times.Once());
|
||||||
|
Mocker.GetMock<IImportListItemService>()
|
||||||
|
.Verify(v => v.SyncSeriesForList(_listSeries, listId), Times.Once());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_not_store_series_if_list_fails()
|
||||||
|
{
|
||||||
|
var listId = 1;
|
||||||
|
var fetchResult = new ImportListFetchResult { Series = _listSeries, AnyFailure = true };
|
||||||
|
WithList(listId, true, true, fetchResult);
|
||||||
|
|
||||||
|
var listResult = Subject.Fetch();
|
||||||
|
listResult.AnyFailure.Should().BeTrue();
|
||||||
|
|
||||||
|
Mocker.GetMock<IImportListStatusService>()
|
||||||
|
.Verify(v => v.UpdateListSyncStatus(listId, false), Times.Never());
|
||||||
|
Mocker.GetMock<IImportListItemService>()
|
||||||
|
.Verify(v => v.SyncSeriesForList(It.IsAny<List<ImportListItemInfo>>(), listId), Times.Never());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_only_store_series_for_lists_that_dont_fail()
|
||||||
|
{
|
||||||
|
var passedListId = 1;
|
||||||
|
var fetchResult1 = new ImportListFetchResult { Series = _listSeries, AnyFailure = false };
|
||||||
|
WithList(passedListId, true, true, fetchResult1);
|
||||||
|
var failedListId = 2;
|
||||||
|
var fetchResult2 = new ImportListFetchResult { Series = _listSeries, AnyFailure = true };
|
||||||
|
WithList(failedListId, true, true, fetchResult2);
|
||||||
|
|
||||||
|
var listResult = Subject.Fetch();
|
||||||
|
listResult.AnyFailure.Should().BeTrue();
|
||||||
|
|
||||||
|
Mocker.GetMock<IImportListStatusService>()
|
||||||
|
.Verify(v => v.UpdateListSyncStatus(passedListId, false), Times.Once());
|
||||||
|
Mocker.GetMock<IImportListItemService>()
|
||||||
|
.Verify(v => v.SyncSeriesForList(_listSeries, passedListId), Times.Once());
|
||||||
|
Mocker.GetMock<IImportListStatusService>()
|
||||||
|
.Verify(v => v.UpdateListSyncStatus(failedListId, false), Times.Never());
|
||||||
|
Mocker.GetMock<IImportListItemService>()
|
||||||
|
.Verify(v => v.SyncSeriesForList(It.IsAny<List<ImportListItemInfo>>(), failedListId), Times.Never());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_return_all_results_for_all_lists()
|
||||||
|
{
|
||||||
|
var passedListId = 1;
|
||||||
|
var fetchResult1 = new ImportListFetchResult { Series = _listSeries, AnyFailure = false };
|
||||||
|
WithList(passedListId, true, true, fetchResult1);
|
||||||
|
var secondListId = 2;
|
||||||
|
var fetchResult2 = new ImportListFetchResult { Series = _listSeries, AnyFailure = false };
|
||||||
|
WithList(secondListId, true, true, fetchResult2);
|
||||||
|
|
||||||
|
var listResult = Subject.Fetch();
|
||||||
|
listResult.AnyFailure.Should().BeFalse();
|
||||||
|
listResult.Series.Count.Should().Be(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_set_removed_flag_if_list_has_removed_items()
|
||||||
|
{
|
||||||
|
var listId = 1;
|
||||||
|
var fetchResult = new ImportListFetchResult { Series = _listSeries, AnyFailure = false };
|
||||||
|
WithList(listId, true, true, fetchResult, syncDeletedCount: 500);
|
||||||
|
|
||||||
|
var result = Subject.Fetch();
|
||||||
|
result.AnyFailure.Should().BeFalse();
|
||||||
|
|
||||||
|
Mocker.GetMock<IImportListStatusService>()
|
||||||
|
.Verify(v => v.UpdateListSyncStatus(listId, true), Times.Once());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,57 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using FizzWare.NBuilder;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Moq;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using NzbDrone.Core.ImportLists.ImportListItems;
|
||||||
|
using NzbDrone.Core.Parser.Model;
|
||||||
|
using NzbDrone.Core.Test.Framework;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Test.ImportListTests
|
||||||
|
{
|
||||||
|
public class ImportListItemServiceFixture : CoreTest<ImportListItemService>
|
||||||
|
{
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
var existing = Builder<ImportListItemInfo>.CreateListOfSize(3)
|
||||||
|
.TheFirst(1)
|
||||||
|
.With(s => s.TvdbId = 6)
|
||||||
|
.With(s => s.ImdbId = "6")
|
||||||
|
.TheNext(1)
|
||||||
|
.With(s => s.TvdbId = 7)
|
||||||
|
.With(s => s.ImdbId = "7")
|
||||||
|
.TheNext(1)
|
||||||
|
.With(s => s.TvdbId = 8)
|
||||||
|
.With(s => s.ImdbId = "8")
|
||||||
|
.Build().ToList();
|
||||||
|
Mocker.GetMock<IImportListItemInfoRepository>()
|
||||||
|
.Setup(v => v.GetAllForLists(It.IsAny<List<int>>()))
|
||||||
|
.Returns(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_insert_new_update_existing_and_delete_missing()
|
||||||
|
{
|
||||||
|
var newItems = Builder<ImportListItemInfo>.CreateListOfSize(3)
|
||||||
|
.TheFirst(1)
|
||||||
|
.With(s => s.TvdbId = 5)
|
||||||
|
.TheNext(1)
|
||||||
|
.With(s => s.TvdbId = 6)
|
||||||
|
.TheNext(1)
|
||||||
|
.With(s => s.TvdbId = 7)
|
||||||
|
.Build().ToList();
|
||||||
|
|
||||||
|
var numDeleted = Subject.SyncSeriesForList(newItems, 1);
|
||||||
|
|
||||||
|
numDeleted.Should().Be(1);
|
||||||
|
Mocker.GetMock<IImportListItemInfoRepository>()
|
||||||
|
.Verify(v => v.InsertMany(It.Is<List<ImportListItemInfo>>(s => s.Count == 1 && s[0].TvdbId == 5)), Times.Once());
|
||||||
|
Mocker.GetMock<IImportListItemInfoRepository>()
|
||||||
|
.Verify(v => v.UpdateMany(It.Is<List<ImportListItemInfo>>(s => s.Count == 2 && s[0].TvdbId == 6 && s[1].TvdbId == 7)), Times.Once());
|
||||||
|
Mocker.GetMock<IImportListItemInfoRepository>()
|
||||||
|
.Verify(v => v.DeleteMany(It.Is<List<ImportListItemInfo>>(s => s.Count == 1 && s[0].TvdbId == 8)), Times.Once());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
using FluentMigrator;
|
||||||
|
using NzbDrone.Core.Datastore.Migration.Framework;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Datastore.Migration
|
||||||
|
{
|
||||||
|
[Migration(193)]
|
||||||
|
public class add_import_list_items : NzbDroneMigrationBase
|
||||||
|
{
|
||||||
|
protected override void MainDbUpgrade()
|
||||||
|
{
|
||||||
|
Create.TableForModel("ImportListItems")
|
||||||
|
.WithColumn("ImportListId").AsInt32()
|
||||||
|
.WithColumn("Title").AsString()
|
||||||
|
.WithColumn("TvdbId").AsInt32()
|
||||||
|
.WithColumn("Year").AsInt32().Nullable()
|
||||||
|
.WithColumn("TmdbId").AsInt32().Nullable()
|
||||||
|
.WithColumn("ImdbId").AsString().Nullable()
|
||||||
|
.WithColumn("MalId").AsInt32().Nullable()
|
||||||
|
.WithColumn("AniListId").AsInt32().Nullable()
|
||||||
|
.WithColumn("ReleaseDate").AsDateTimeOffset().Nullable();
|
||||||
|
|
||||||
|
Alter.Table("ImportListStatus")
|
||||||
|
.AddColumn("HasRemovedItemSinceLastClean").AsBoolean().WithDefaultValue(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,43 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using NzbDrone.Core.Datastore;
|
||||||
|
using NzbDrone.Core.Messaging.Events;
|
||||||
|
using NzbDrone.Core.Parser.Model;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.ImportLists.ImportListItems
|
||||||
|
{
|
||||||
|
public interface IImportListItemInfoRepository : IBasicRepository<ImportListItemInfo>
|
||||||
|
{
|
||||||
|
List<ImportListItemInfo> GetAllForLists(List<int> listIds);
|
||||||
|
bool Exists(int tvdbId, string imdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ImportListItemRepository : BasicRepository<ImportListItemInfo>, IImportListItemInfoRepository
|
||||||
|
{
|
||||||
|
public ImportListItemRepository(IMainDatabase database, IEventAggregator eventAggregator)
|
||||||
|
: base(database, eventAggregator)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ImportListItemInfo> GetAllForLists(List<int> listIds)
|
||||||
|
{
|
||||||
|
return Query(x => listIds.Contains(x.ImportListId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Exists(int tvdbId, string imdbId)
|
||||||
|
{
|
||||||
|
List<ImportListItemInfo> items;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(imdbId))
|
||||||
|
{
|
||||||
|
items = Query(x => x.TvdbId == tvdbId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
items = Query(x => x.TvdbId == tvdbId || x.ImdbId == imdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.Any();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using NLog;
|
||||||
|
using NzbDrone.Core.Messaging.Events;
|
||||||
|
using NzbDrone.Core.Parser.Model;
|
||||||
|
using NzbDrone.Core.ThingiProvider.Events;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.ImportLists.ImportListItems
|
||||||
|
{
|
||||||
|
public interface IImportListItemService
|
||||||
|
{
|
||||||
|
List<ImportListItemInfo> GetAllForLists(List<int> listIds);
|
||||||
|
int SyncSeriesForList(List<ImportListItemInfo> listSeries, int listId);
|
||||||
|
bool Exists(int tvdbId, string imdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ImportListItemService : IImportListItemService, IHandleAsync<ProviderDeletedEvent<IImportList>>
|
||||||
|
{
|
||||||
|
private readonly IImportListItemInfoRepository _importListSeriesRepository;
|
||||||
|
private readonly Logger _logger;
|
||||||
|
|
||||||
|
public ImportListItemService(IImportListItemInfoRepository importListSeriesRepository,
|
||||||
|
Logger logger)
|
||||||
|
{
|
||||||
|
_importListSeriesRepository = importListSeriesRepository;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int SyncSeriesForList(List<ImportListItemInfo> listSeries, int listId)
|
||||||
|
{
|
||||||
|
var existingListSeries = GetAllForLists(new List<int> { listId });
|
||||||
|
|
||||||
|
listSeries.ForEach(l => l.Id = existingListSeries.FirstOrDefault(e => e.TvdbId == l.TvdbId)?.Id ?? 0);
|
||||||
|
|
||||||
|
_importListSeriesRepository.InsertMany(listSeries.Where(l => l.Id == 0).ToList());
|
||||||
|
_importListSeriesRepository.UpdateMany(listSeries.Where(l => l.Id > 0).ToList());
|
||||||
|
var toDelete = existingListSeries.Where(l => !listSeries.Any(x => x.TvdbId == l.TvdbId)).ToList();
|
||||||
|
_importListSeriesRepository.DeleteMany(toDelete);
|
||||||
|
|
||||||
|
return toDelete.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ImportListItemInfo> GetAllForLists(List<int> listIds)
|
||||||
|
{
|
||||||
|
return _importListSeriesRepository.GetAllForLists(listIds).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void HandleAsync(ProviderDeletedEvent<IImportList> message)
|
||||||
|
{
|
||||||
|
var seriesOnList = _importListSeriesRepository.GetAllForLists(new List<int> { message.ProviderId });
|
||||||
|
_importListSeriesRepository.DeleteMany(seriesOnList);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Exists(int tvdbId, string imdbId)
|
||||||
|
{
|
||||||
|
return _importListSeriesRepository.Exists(tvdbId, imdbId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
namespace NzbDrone.Core.ImportLists
|
||||||
|
{
|
||||||
|
public enum ListSyncLevelType
|
||||||
|
{
|
||||||
|
Disabled,
|
||||||
|
LogOnly,
|
||||||
|
KeepAndUnmonitor,
|
||||||
|
KeepAndTag
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using NzbDrone.Core.Configuration;
|
||||||
|
using NzbDrone.Core.ImportLists;
|
||||||
|
using NzbDrone.Core.Validation;
|
||||||
|
using Sonarr.Http;
|
||||||
|
|
||||||
|
namespace Sonarr.Api.V3.Config
|
||||||
|
{
|
||||||
|
[V3ApiController("config/importlist")]
|
||||||
|
|
||||||
|
public class ImportListConfigController : ConfigController<ImportListConfigResource>
|
||||||
|
{
|
||||||
|
public ImportListConfigController(IConfigService configService)
|
||||||
|
: base(configService)
|
||||||
|
{
|
||||||
|
SharedValidator.RuleFor(x => x.ListSyncTag)
|
||||||
|
.ValidId()
|
||||||
|
.WithMessage("Tag must be specified")
|
||||||
|
.When(x => x.ListSyncLevel == ListSyncLevelType.KeepAndTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override ImportListConfigResource ToResource(IConfigService model)
|
||||||
|
{
|
||||||
|
return ImportListConfigResourceMapper.ToResource(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
using NzbDrone.Core.Configuration;
|
||||||
|
using NzbDrone.Core.ImportLists;
|
||||||
|
using Sonarr.Http.REST;
|
||||||
|
|
||||||
|
namespace Sonarr.Api.V3.Config
|
||||||
|
{
|
||||||
|
public class ImportListConfigResource : RestResource
|
||||||
|
{
|
||||||
|
public ListSyncLevelType ListSyncLevel { get; set; }
|
||||||
|
public int ListSyncTag { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ImportListConfigResourceMapper
|
||||||
|
{
|
||||||
|
public static ImportListConfigResource ToResource(IConfigService model)
|
||||||
|
{
|
||||||
|
return new ImportListConfigResource
|
||||||
|
{
|
||||||
|
ListSyncLevel = model.ListSyncLevel,
|
||||||
|
ListSyncTag = model.ListSyncTag,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in new issue