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