New: Import list clean library option

Closes #5201
pull/6433/head
The Dark 4 months ago committed by GitHub
parent 46367d2023
commit 68c326ae27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -7,6 +7,7 @@ import AppSectionState, {
import Language from 'Language/Language'; import Language from 'Language/Language';
import DownloadClient from 'typings/DownloadClient'; import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList'; import ImportList from 'typings/ImportList';
import ImportListOptionsSettings from 'typings/ImportListOptionsSettings';
import Indexer from 'typings/Indexer'; import Indexer from 'typings/Indexer';
import Notification from 'typings/Notification'; import Notification from 'typings/Notification';
import QualityProfile from 'typings/QualityProfile'; import QualityProfile from 'typings/QualityProfile';
@ -35,10 +36,15 @@ export interface QualityProfilesAppState
extends AppSectionState<QualityProfile>, extends AppSectionState<QualityProfile>,
AppSectionSchemaState<QualityProfile> {} AppSectionSchemaState<QualityProfile> {}
export interface ImportListOptionsSettingsAppState
extends AppSectionItemState<ImportListOptionsSettings>,
AppSectionSaveState {}
export type LanguageSettingsAppState = AppSectionState<Language>; export type LanguageSettingsAppState = AppSectionState<Language>;
export type UiSettingsAppState = AppSectionItemState<UiSettings>; export type UiSettingsAppState = AppSectionItemState<UiSettings>;
interface SettingsAppState { interface SettingsAppState {
advancedSettings: boolean;
downloadClients: DownloadClientAppState; downloadClients: DownloadClientAppState;
importLists: ImportListAppState; importLists: ImportListAppState;
indexers: IndexerAppState; indexers: IndexerAppState;
@ -46,6 +52,7 @@ interface SettingsAppState {
notifications: NotificationAppState; notifications: NotificationAppState;
qualityProfiles: QualityProfilesAppState; qualityProfiles: QualityProfilesAppState;
ui: UiSettingsAppState; ui: UiSettingsAppState;
importListOptions: ImportListOptionsSettingsAppState;
} }
export default SettingsAppState; export default SettingsAppState;

@ -10,6 +10,7 @@ import translate from 'Utilities/String/translate';
import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector'; import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector';
import ImportListsConnector from './ImportLists/ImportListsConnector'; import ImportListsConnector from './ImportLists/ImportListsConnector';
import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal'; import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal';
import ImportListOptions from './Options/ImportListOptions';
class ImportListSettings extends Component { class ImportListSettings extends Component {
@ -19,7 +20,10 @@ class ImportListSettings extends Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this._saveCallback = null;
this.state = { this.state = {
isSaving: false,
hasPendingChanges: false, hasPendingChanges: false,
isManageImportListsOpen: false isManageImportListsOpen: false
}; };
@ -28,6 +32,14 @@ class ImportListSettings extends Component {
// //
// Listeners // Listeners
setChildSave = (saveCallback) => {
this._saveCallback = saveCallback;
};
onChildStateChange = (payload) => {
this.setState(payload);
};
setListOptionsRef = (ref) => { setListOptionsRef = (ref) => {
this._listOptions = ref; this._listOptions = ref;
}; };
@ -47,7 +59,9 @@ class ImportListSettings extends Component {
}; };
onSavePress = () => { onSavePress = () => {
this._listOptions.getWrappedInstance().save(); if (this._saveCallback) {
this._saveCallback();
}
}; };
// //
@ -93,6 +107,12 @@ class ImportListSettings extends Component {
<PageContentBody> <PageContentBody>
<ImportListsConnector /> <ImportListsConnector />
<ImportListOptions
setChildSave={this.setChildSave}
onChildStateChange={this.onChildStateChange}
/>
<ImportListsExclusionsConnector /> <ImportListsExclusionsConnector />
<ManageImportListsModal <ManageImportListsModal
isOpen={isManageImportListsOpen} isOpen={isManageImportListsOpen}

@ -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)
}
};

@ -10,6 +10,7 @@ import downloadClientOptions from './Settings/downloadClientOptions';
import downloadClients from './Settings/downloadClients'; import downloadClients from './Settings/downloadClients';
import general from './Settings/general'; import general from './Settings/general';
import importListExclusions from './Settings/importListExclusions'; import importListExclusions from './Settings/importListExclusions';
import importListOptions from './Settings/importListOptions';
import importLists from './Settings/importLists'; import importLists from './Settings/importLists';
import indexerOptions from './Settings/indexerOptions'; import indexerOptions from './Settings/indexerOptions';
import indexers from './Settings/indexers'; import indexers from './Settings/indexers';
@ -33,6 +34,7 @@ export * from './Settings/delayProfiles';
export * from './Settings/downloadClients'; export * from './Settings/downloadClients';
export * from './Settings/downloadClientOptions'; export * from './Settings/downloadClientOptions';
export * from './Settings/general'; export * from './Settings/general';
export * from './Settings/importListOptions';
export * from './Settings/importLists'; export * from './Settings/importLists';
export * from './Settings/importListExclusions'; export * from './Settings/importListExclusions';
export * from './Settings/indexerOptions'; export * from './Settings/indexerOptions';
@ -69,6 +71,7 @@ export const defaultState = {
general: general.defaultState, general: general.defaultState,
importLists: importLists.defaultState, importLists: importLists.defaultState,
importListExclusions: importListExclusions.defaultState, importListExclusions: importListExclusions.defaultState,
importListOptions: importListOptions.defaultState,
indexerOptions: indexerOptions.defaultState, indexerOptions: indexerOptions.defaultState,
indexers: indexers.defaultState, indexers: indexers.defaultState,
languages: languages.defaultState, languages: languages.defaultState,
@ -112,6 +115,7 @@ export const actionHandlers = handleThunks({
...general.actionHandlers, ...general.actionHandlers,
...importLists.actionHandlers, ...importLists.actionHandlers,
...importListExclusions.actionHandlers, ...importListExclusions.actionHandlers,
...importListOptions.actionHandlers,
...indexerOptions.actionHandlers, ...indexerOptions.actionHandlers,
...indexers.actionHandlers, ...indexers.actionHandlers,
...languages.actionHandlers, ...languages.actionHandlers,
@ -146,6 +150,7 @@ export const reducers = createHandleActions({
...general.reducers, ...general.reducers,
...importLists.reducers, ...importLists.reducers,
...importListExclusions.reducers, ...importListExclusions.reducers,
...importListOptions.reducers,
...indexerOptions.reducers, ...indexerOptions.reducers,
...indexers.reducers, ...indexers.reducers,
...languages.reducers, ...languages.reducers,

@ -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());
}
}
}

@ -1,9 +1,13 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using FizzWare.NBuilder;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.ImportLists; using NzbDrone.Core.ImportLists;
using NzbDrone.Core.ImportLists.Exclusions; using NzbDrone.Core.ImportLists.Exclusions;
using NzbDrone.Core.ImportLists.ImportListItems;
using NzbDrone.Core.MetadataSource; using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Test.Framework;
@ -13,17 +17,61 @@ namespace NzbDrone.Core.Test.ImportListTests
{ {
public class ImportListSyncServiceFixture : CoreTest<ImportListSyncService> public class ImportListSyncServiceFixture : CoreTest<ImportListSyncService>
{ {
private List<ImportListItemInfo> _importListReports; private ImportListFetchResult _importListFetch;
private List<ImportListItemInfo> _list1Series;
private List<ImportListItemInfo> _list2Series;
private List<Series> _existingSeries;
private List<IImportList> _importLists;
private ImportListSyncCommand _commandAll;
private ImportListSyncCommand _commandSingle;
[SetUp] [SetUp]
public void SetUp() public void SetUp()
{ {
var importListItem1 = new ImportListItemInfo _importLists = new List<IImportList>();
var item1 = new ImportListItemInfo()
{ {
Title = "Breaking Bad" Title = "Breaking Bad"
}; };
_importListReports = new List<ImportListItemInfo> { importListItem1 }; _list1Series = new List<ImportListItemInfo>() { item1 };
_existingSeries = Builder<Series>.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();
_list2Series = 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();
_importListFetch = new ImportListFetchResult(_list1Series, false);
_commandAll = new ImportListSyncCommand
{
};
_commandSingle = new ImportListSyncCommand
{
DefinitionId = 1
};
var mockImportList = new Mock<IImportList>(); var mockImportList = new Mock<IImportList>();
@ -31,6 +79,10 @@ namespace NzbDrone.Core.Test.ImportListTests
.Setup(v => v.AllSeriesTvdbIds()) .Setup(v => v.AllSeriesTvdbIds())
.Returns(new List<int>()); .Returns(new List<int>());
Mocker.GetMock<ISeriesService>()
.Setup(v => v.GetAllSeries())
.Returns(_existingSeries);
Mocker.GetMock<ISearchForNewSeries>() Mocker.GetMock<ISearchForNewSeries>()
.Setup(v => v.SearchForNewSeries(It.IsAny<string>())) .Setup(v => v.SearchForNewSeries(It.IsAny<string>()))
.Returns(new List<Series>()); .Returns(new List<Series>());
@ -41,15 +93,19 @@ namespace NzbDrone.Core.Test.ImportListTests
Mocker.GetMock<IImportListFactory>() Mocker.GetMock<IImportListFactory>()
.Setup(v => v.All()) .Setup(v => v.All())
.Returns(new List<ImportListDefinition> { new ImportListDefinition { ShouldMonitor = MonitorTypes.All } }); .Returns(() => _importLists.Select(x => x.Definition as ImportListDefinition).ToList());
Mocker.GetMock<IImportListFactory>()
.Setup(v => v.GetAvailableProviders())
.Returns(_importLists);
Mocker.GetMock<IImportListFactory>() Mocker.GetMock<IImportListFactory>()
.Setup(v => v.AutomaticAddEnabled(It.IsAny<bool>())) .Setup(v => v.AutomaticAddEnabled(It.IsAny<bool>()))
.Returns(new List<IImportList> { mockImportList.Object }); .Returns(() => _importLists.Where(x => (x.Definition as ImportListDefinition).EnableAutomaticAdd).ToList());
Mocker.GetMock<IFetchAndParseImportList>() Mocker.GetMock<IFetchAndParseImportList>()
.Setup(v => v.Fetch()) .Setup(v => v.Fetch())
.Returns(_importListReports); .Returns(_importListFetch);
Mocker.GetMock<IImportListExclusionService>() Mocker.GetMock<IImportListExclusionService>()
.Setup(v => v.All()) .Setup(v => v.All())
@ -58,19 +114,19 @@ namespace NzbDrone.Core.Test.ImportListTests
private void WithTvdbId() private void WithTvdbId()
{ {
_importListReports.First().TvdbId = 81189; _list1Series.First().TvdbId = 81189;
} }
private void WithImdbId() private void WithImdbId()
{ {
_importListReports.First().ImdbId = "tt0496424"; _list1Series.First().ImdbId = "tt0496424";
} }
private void WithExistingSeries() private void WithExistingSeries()
{ {
Mocker.GetMock<ISeriesService>() Mocker.GetMock<ISeriesService>()
.Setup(v => v.AllSeriesTvdbIds()) .Setup(v => v.AllSeriesTvdbIds())
.Returns(new List<int> { _importListReports.First().TvdbId }); .Returns(new List<int> { _list1Series.First().TvdbId });
} }
private void WithExcludedSeries() private void WithExcludedSeries()
@ -81,22 +137,281 @@ namespace NzbDrone.Core.Test.ImportListTests
{ {
new ImportListExclusion new ImportListExclusion
{ {
TvdbId = 81189 TvdbId = _list1Series.First().TvdbId
} }
}); });
} }
private void WithMonitorType(MonitorTypes monitor) private void WithMonitorType(MonitorTypes monitor)
{ {
_importLists.ForEach(li => (li.Definition as ImportListDefinition).ShouldMonitor = monitor);
}
private void WithCleanLevel(ListSyncLevelType cleanLevel, int? tagId = null)
{
Mocker.GetMock<IConfigService>()
.SetupGet(v => v.ListSyncLevel)
.Returns(cleanLevel);
if (tagId.HasValue)
{
Mocker.GetMock<IConfigService>()
.SetupGet(v => v.ListSyncTag)
.Returns(tagId.Value);
}
}
private void WithList(int id, bool enabledAuto, int lastSyncHoursOffset = 0, bool pendingRemovals = true, DateTime? disabledTill = null)
{
var importListDefinition = new ImportListDefinition { Id = id, EnableAutomaticAdd = enabledAuto };
Mocker.GetMock<IImportListFactory>() Mocker.GetMock<IImportListFactory>()
.Setup(v => v.All()) .Setup(v => v.Get(id))
.Returns(new List<ImportListDefinition> { new ImportListDefinition { ShouldMonitor = monitor } }); .Returns(importListDefinition);
var mockImportList = new Mock<IImportList>();
mockImportList.SetupGet(s => s.Definition).Returns(importListDefinition);
mockImportList.SetupGet(s => s.MinRefreshInterval).Returns(TimeSpan.FromHours(12));
var status = new ImportListStatus()
{
LastInfoSync = DateTime.UtcNow.AddHours(lastSyncHoursOffset),
HasRemovedItemSinceLastClean = pendingRemovals,
DisabledTill = disabledTill
};
if (disabledTill.HasValue)
{
_importListFetch.AnyFailure = true;
}
Mocker.GetMock<IImportListStatusService>()
.Setup(v => v.GetListStatus(id))
.Returns(status);
_importLists.Add(mockImportList.Object);
}
private void VerifyDidAddTag(int expectedSeriesCount, int expectedTagId)
{
Mocker.GetMock<ISeriesService>()
.Verify(v => v.UpdateSeries(It.Is<List<Series>>(x => x.Count == expectedSeriesCount && x.All(series => series.Tags.Contains(expectedTagId))), true), Times.Once());
}
[Test]
public void should_not_clean_library_if_lists_have_not_removed_any_items()
{
_importListFetch.Series = _existingSeries.Select(x => new ImportListItemInfo() { TvdbId = x.TvdbId }).ToList();
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true, pendingRemovals: false);
WithCleanLevel(ListSyncLevelType.KeepAndUnmonitor);
Subject.Execute(_commandAll);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.GetAllSeries(), Times.Never());
Mocker.GetMock<ISeriesService>()
.Verify(v => v.UpdateSeries(It.IsAny<List<Series>>(), true), Times.Never());
}
[Test]
public void should_not_clean_library_if_config_value_disable()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithCleanLevel(ListSyncLevelType.Disabled);
Subject.Execute(_commandAll);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.GetAllSeries(), Times.Never());
Mocker.GetMock<ISeriesService>()
.Verify(v => v.UpdateSeries(new List<Series>(), true), Times.Never());
}
[Test]
public void should_log_only_on_clean_library_if_config_value_logonly()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithCleanLevel(ListSyncLevelType.LogOnly);
Subject.Execute(_commandAll);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.GetAllSeries(), Times.Once());
Mocker.GetMock<ISeriesService>()
.Verify(v => v.DeleteSeries(It.IsAny<List<int>>(), It.IsAny<bool>(), It.IsAny<bool>()), Times.Never());
Mocker.GetMock<ISeriesService>()
.Verify(v => v.UpdateSeries(new List<Series>(), true), Times.Once());
}
[Test]
public void should_unmonitor_on_clean_library_if_config_value_keepAndUnmonitor()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithCleanLevel(ListSyncLevelType.KeepAndUnmonitor);
var monitored = _existingSeries.Count(x => x.Monitored);
Subject.Execute(_commandAll);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.GetAllSeries(), Times.Once());
Mocker.GetMock<ISeriesService>()
.Verify(v => v.DeleteSeries(It.IsAny<List<int>>(), It.IsAny<bool>(), It.IsAny<bool>()), Times.Never());
Mocker.GetMock<ISeriesService>()
.Verify(v => v.UpdateSeries(It.Is<List<Series>>(s => s.Count == monitored && s.All(m => !m.Monitored)), true), Times.Once());
}
[Test]
public void should_not_clean_on_clean_library_if_tvdb_match()
{
WithList(1, true);
WithCleanLevel(ListSyncLevelType.KeepAndUnmonitor);
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
Mocker.GetMock<IImportListItemService>()
.Setup(v => v.Exists(6, It.IsAny<string>()))
.Returns(true);
Subject.Execute(_commandAll);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.UpdateSeries(It.Is<List<Series>>(s => s.Count > 0 && s.All(m => !m.Monitored)), true), Times.Once());
}
[Test]
public void should_not_clean_on_clean_library_if_imdb_match()
{
WithList(1, true);
WithCleanLevel(ListSyncLevelType.KeepAndUnmonitor);
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
var x = _importLists;
Mocker.GetMock<IImportListItemService>()
.Setup(v => v.Exists(It.IsAny<int>(), "6"))
.Returns(true);
Subject.Execute(_commandAll);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.UpdateSeries(It.Is<List<Series>>(s => s.Count > 0 && s.All(m => !m.Monitored)), true), Times.Once());
}
[Test]
public void should_tag_series_on_clean_library_if_config_value_keepAndTag()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithCleanLevel(ListSyncLevelType.KeepAndTag, 1);
Subject.Execute(_commandAll);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.GetAllSeries(), Times.Once());
VerifyDidAddTag(_existingSeries.Count, 1);
}
[Test]
public void should_not_clean_if_list_failures()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true, disabledTill: DateTime.UtcNow.AddHours(1));
WithCleanLevel(ListSyncLevelType.LogOnly);
Subject.Execute(_commandAll);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.GetAllSeries(), Times.Never());
Mocker.GetMock<ISeriesService>()
.Verify(v => v.UpdateSeries(It.IsAny<List<Series>>(), It.IsAny<bool>()), Times.Never());
Mocker.GetMock<ISeriesService>()
.Verify(v => v.DeleteSeries(It.IsAny<List<int>>(), It.IsAny<bool>(), It.IsAny<bool>()), Times.Never());
}
[Test]
public void should_add_new_series_from_single_list_to_library()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithCleanLevel(ListSyncLevelType.Disabled);
Subject.Execute(_commandAll);
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(s => s.Count == 1), true), Times.Once());
}
[Test]
public void should_add_new_series_from_multiple_list_to_library()
{
_list2Series.ForEach(m => m.ImportListId = 2);
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
_importListFetch.Series.AddRange(_list2Series);
WithList(1, true);
WithList(2, true);
WithCleanLevel(ListSyncLevelType.Disabled);
Subject.Execute(_commandAll);
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(s => s.Count == 4), true), Times.Once());
}
[Test]
public void should_add_new_series_to_library_only_from_enabled_lists()
{
_list2Series.ForEach(m => m.ImportListId = 2);
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
_importListFetch.Series.AddRange(_list2Series);
WithList(1, true);
WithList(2, false);
WithCleanLevel(ListSyncLevelType.Disabled);
Subject.Execute(_commandAll);
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(s => s.Count == 1), true), Times.Once());
}
[Test]
public void should_not_add_duplicate_series_from_seperate_lists()
{
_list2Series.ForEach(m => m.ImportListId = 2);
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
_importListFetch.Series.AddRange(_list2Series);
_importListFetch.Series[0].TvdbId = 6;
WithList(1, true);
WithList(2, true);
WithCleanLevel(ListSyncLevelType.Disabled);
Subject.Execute(_commandAll);
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(s => s.Count == 3), true), Times.Once());
} }
[Test] [Test]
public void should_search_if_series_title_and_no_series_id() public void should_search_if_series_title_and_no_series_id()
{ {
Subject.Execute(new ImportListSyncCommand()); _importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
Subject.Execute(_commandAll);
Mocker.GetMock<ISearchForNewSeries>() Mocker.GetMock<ISearchForNewSeries>()
.Verify(v => v.SearchForNewSeries(It.IsAny<string>()), Times.Once()); .Verify(v => v.SearchForNewSeries(It.IsAny<string>()), Times.Once());
@ -105,8 +420,10 @@ namespace NzbDrone.Core.Test.ImportListTests
[Test] [Test]
public void should_not_search_if_series_title_and_series_id() public void should_not_search_if_series_title_and_series_id()
{ {
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithTvdbId(); WithTvdbId();
Subject.Execute(new ImportListSyncCommand()); Subject.Execute(_commandAll);
Mocker.GetMock<ISearchForNewSeries>() Mocker.GetMock<ISearchForNewSeries>()
.Verify(v => v.SearchForNewSeries(It.IsAny<string>()), Times.Never()); .Verify(v => v.SearchForNewSeries(It.IsAny<string>()), Times.Never());
@ -115,8 +432,10 @@ namespace NzbDrone.Core.Test.ImportListTests
[Test] [Test]
public void should_search_by_imdb_if_series_title_and_series_imdb() public void should_search_by_imdb_if_series_title_and_series_imdb()
{ {
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithImdbId(); WithImdbId();
Subject.Execute(new ImportListSyncCommand()); Subject.Execute(_commandAll);
Mocker.GetMock<ISearchForNewSeries>() Mocker.GetMock<ISearchForNewSeries>()
.Verify(v => v.SearchForNewSeriesByImdbId(It.IsAny<string>()), Times.Once()); .Verify(v => v.SearchForNewSeriesByImdbId(It.IsAny<string>()), Times.Once());
@ -125,10 +444,12 @@ namespace NzbDrone.Core.Test.ImportListTests
[Test] [Test]
public void should_not_add_if_existing_series() public void should_not_add_if_existing_series()
{ {
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithTvdbId(); WithTvdbId();
WithExistingSeries(); WithExistingSeries();
Subject.Execute(new ImportListSyncCommand()); Subject.Execute(_commandAll);
Mocker.GetMock<IAddSeriesService>() Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(t => t.Count == 0), It.IsAny<bool>())); .Verify(v => v.AddSeries(It.Is<List<Series>>(t => t.Count == 0), It.IsAny<bool>()));
@ -138,10 +459,12 @@ namespace NzbDrone.Core.Test.ImportListTests
[TestCase(MonitorTypes.All, true)] [TestCase(MonitorTypes.All, true)]
public void should_add_if_not_existing_series(MonitorTypes monitor, bool expectedSeriesMonitored) public void should_add_if_not_existing_series(MonitorTypes monitor, bool expectedSeriesMonitored)
{ {
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithTvdbId(); WithTvdbId();
WithMonitorType(monitor); WithMonitorType(monitor);
Subject.Execute(new ImportListSyncCommand()); Subject.Execute(_commandAll);
Mocker.GetMock<IAddSeriesService>() Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(t => t.Count == 1 && t.First().Monitored == expectedSeriesMonitored), It.IsAny<bool>())); .Verify(v => v.AddSeries(It.Is<List<Series>>(t => t.Count == 1 && t.First().Monitored == expectedSeriesMonitored), It.IsAny<bool>()));
@ -150,10 +473,12 @@ namespace NzbDrone.Core.Test.ImportListTests
[Test] [Test]
public void should_not_add_if_excluded_series() public void should_not_add_if_excluded_series()
{ {
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithTvdbId(); WithTvdbId();
WithExcludedSeries(); WithExcludedSeries();
Subject.Execute(new ImportListSyncCommand()); Subject.Execute(_commandAll);
Mocker.GetMock<IAddSeriesService>() Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(t => t.Count == 0), It.IsAny<bool>())); .Verify(v => v.AddSeries(It.Is<List<Series>>(t => t.Count == 0), It.IsAny<bool>()));
@ -177,7 +502,7 @@ namespace NzbDrone.Core.Test.ImportListTests
{ {
Mocker.GetMock<IFetchAndParseImportList>() Mocker.GetMock<IFetchAndParseImportList>()
.Setup(v => v.Fetch()) .Setup(v => v.Fetch())
.Returns(new List<ImportListItemInfo>()); .Returns(new ImportListFetchResult());
Subject.Execute(new ImportListSyncCommand()); Subject.Execute(new ImportListSyncCommand());

@ -6,6 +6,7 @@ using NLog;
using NzbDrone.Common.EnsureThat; using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Http.Proxy; using NzbDrone.Common.Http.Proxy;
using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.Configuration.Events;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.Languages; using NzbDrone.Core.Languages;
using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Core.MediaFiles.EpisodeImport;
@ -276,6 +277,18 @@ namespace NzbDrone.Core.Configuration
set { SetValue("ChownGroup", value); } set { SetValue("ChownGroup", value); }
} }
public ListSyncLevelType ListSyncLevel
{
get { return GetValueEnum("ListSyncLevel", ListSyncLevelType.Disabled); }
set { SetValue("ListSyncLevel", value); }
}
public int ListSyncTag
{
get { return GetValueInt("ListSyncTag"); }
set { SetValue("ListSyncTag", value); }
}
public int FirstDayOfWeek public int FirstDayOfWeek
{ {
get { return GetValueInt("FirstDayOfWeek", (int)CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek); } get { return GetValueInt("FirstDayOfWeek", (int)CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek); }

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using NzbDrone.Common.Http.Proxy; using NzbDrone.Common.Http.Proxy;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Core.MediaFiles.EpisodeImport;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
@ -52,6 +53,9 @@ namespace NzbDrone.Core.Configuration
int MaximumSize { get; set; } int MaximumSize { get; set; }
int MinimumAge { get; set; } int MinimumAge { get; set; }
ListSyncLevelType ListSyncLevel { get; set; }
int ListSyncTag { get; set; }
// UI // UI
int FirstDayOfWeek { get; set; } int FirstDayOfWeek { get; set; }
string CalendarWeekColumnHeader { get; set; } string CalendarWeekColumnHeader { get; set; }

@ -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);
}
}
}

@ -81,6 +81,9 @@ namespace NzbDrone.Core.Datastore
.Ignore(i => i.MinRefreshInterval) .Ignore(i => i.MinRefreshInterval)
.Ignore(i => i.Enable); .Ignore(i => i.Enable);
Mapper.Entity<ImportListItemInfo>("ImportListItems").RegisterModel()
.Ignore(i => i.ImportList);
Mapper.Entity<NotificationDefinition>("Notifications").RegisterModel() Mapper.Entity<NotificationDefinition>("Notifications").RegisterModel()
.Ignore(x => x.ImplementationName) .Ignore(x => x.ImplementationName)
.Ignore(i => i.SupportsOnGrab) .Ignore(i => i.SupportsOnGrab)

@ -5,7 +5,6 @@ using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Core.Localization; using NzbDrone.Core.Localization;
using NzbDrone.Core.Parser; using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Validation; using NzbDrone.Core.Validation;
namespace NzbDrone.Core.ImportLists.AniList namespace NzbDrone.Core.ImportLists.AniList
@ -65,7 +64,7 @@ namespace NzbDrone.Core.ImportLists.AniList
return new { }; return new { };
} }
public override IList<ImportListItemInfo> Fetch() public override ImportListFetchResult Fetch()
{ {
CheckToken(); CheckToken();
return base.Fetch(); return base.Fetch();

@ -44,10 +44,11 @@ namespace NzbDrone.Core.ImportLists.AniList.List
return new AniListParser(Settings); return new AniListParser(Settings);
} }
protected override IList<ImportListItemInfo> FetchItems(Func<IImportListRequestGenerator, ImportListPageableRequestChain> pageableRequestChainSelector, bool isRecent = false) protected override ImportListFetchResult FetchItems(Func<IImportListRequestGenerator, ImportListPageableRequestChain> pageableRequestChainSelector, bool isRecent = false)
{ {
var releases = new List<ImportListItemInfo>(); var releases = new List<ImportListItemInfo>();
var url = string.Empty; var url = string.Empty;
var anyFailure = true;
try try
{ {
@ -77,6 +78,7 @@ namespace NzbDrone.Core.ImportLists.AniList.List
while (hasNextPage); while (hasNextPage);
_importListStatusService.RecordSuccess(Definition.Id); _importListStatusService.RecordSuccess(Definition.Id);
anyFailure = false;
} }
catch (WebException webException) catch (WebException webException)
{ {
@ -149,7 +151,7 @@ namespace NzbDrone.Core.ImportLists.AniList.List
_logger.Error(ex, "An error occurred while processing feed. {0}", url); _logger.Error(ex, "An error occurred while processing feed. {0}", url);
} }
return CleanupListItems(releases); return new ImportListFetchResult(CleanupListItems(releases), anyFailure);
} }
} }
} }

@ -30,9 +30,10 @@ namespace NzbDrone.Core.ImportLists.Custom
_customProxy = customProxy; _customProxy = customProxy;
} }
public override IList<ImportListItemInfo> Fetch() public override ImportListFetchResult Fetch()
{ {
var series = new List<ImportListItemInfo>(); var series = new List<ImportListItemInfo>();
var anyFailure = false;
try try
{ {
@ -50,12 +51,13 @@ namespace NzbDrone.Core.ImportLists.Custom
} }
catch (Exception ex) catch (Exception ex)
{ {
anyFailure = true;
_logger.Debug(ex, "Failed to fetch data for list {0} ({1})", Definition.Name, Name); _logger.Debug(ex, "Failed to fetch data for list {0} ({1})", Definition.Name, Name);
_importListStatusService.RecordFailure(Definition.Id); _importListStatusService.RecordFailure(Definition.Id);
} }
return CleanupListItems(series); return new ImportListFetchResult(CleanupListItems(series), anyFailure);
} }
public override object RequestAction(string action, IDictionary<string, string> query) public override object RequestAction(string action, IDictionary<string, string> query)

@ -4,32 +4,34 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using NLog; using NLog;
using NzbDrone.Common.TPL; using NzbDrone.Common.TPL;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.ImportLists.ImportListItems;
namespace NzbDrone.Core.ImportLists namespace NzbDrone.Core.ImportLists
{ {
public interface IFetchAndParseImportList public interface IFetchAndParseImportList
{ {
List<ImportListItemInfo> Fetch(); ImportListFetchResult Fetch();
List<ImportListItemInfo> FetchSingleList(ImportListDefinition definition); ImportListFetchResult FetchSingleList(ImportListDefinition definition);
} }
public class FetchAndParseImportListService : IFetchAndParseImportList public class FetchAndParseImportListService : IFetchAndParseImportList
{ {
private readonly IImportListFactory _importListFactory; private readonly IImportListFactory _importListFactory;
private readonly IImportListStatusService _importListStatusService; private readonly IImportListStatusService _importListStatusService;
private readonly IImportListItemService _importListItemService;
private readonly Logger _logger; private readonly Logger _logger;
public FetchAndParseImportListService(IImportListFactory importListFactory, IImportListStatusService importListStatusService, Logger logger) public FetchAndParseImportListService(IImportListFactory importListFactory, IImportListStatusService importListStatusService, IImportListItemService importListItemService, Logger logger)
{ {
_importListFactory = importListFactory; _importListFactory = importListFactory;
_importListStatusService = importListStatusService; _importListStatusService = importListStatusService;
_importListItemService = importListItemService;
_logger = logger; _logger = logger;
} }
public List<ImportListItemInfo> Fetch() public ImportListFetchResult Fetch()
{ {
var result = new List<ImportListItemInfo>(); var result = new ImportListFetchResult();
var importLists = _importListFactory.AutomaticAddEnabled(); var importLists = _importListFactory.AutomaticAddEnabled();
@ -47,7 +49,7 @@ namespace NzbDrone.Core.ImportLists
foreach (var importList in importLists) foreach (var importList in importLists)
{ {
var importListLocal = importList; var importListLocal = importList;
var importListStatus = _importListStatusService.GetLastSyncListInfo(importListLocal.Definition.Id); var importListStatus = _importListStatusService.GetListStatus(importListLocal.Definition.Id).LastInfoSync;
if (importListStatus.HasValue) if (importListStatus.HasValue)
{ {
@ -64,16 +66,23 @@ namespace NzbDrone.Core.ImportLists
{ {
try try
{ {
var importListReports = importListLocal.Fetch(); var fetchResult = importListLocal.Fetch();
var importListReports = fetchResult.Series;
lock (result) lock (result)
{ {
_logger.Debug("Found {0} reports from {1} ({2})", importListReports.Count, importList.Name, importListLocal.Definition.Name); _logger.Debug("Found {0} reports from {1} ({2})", importListReports.Count, importList.Name, importListLocal.Definition.Name);
result.AddRange(importListReports); if (!fetchResult.AnyFailure)
} {
importListReports.ForEach(s => s.ImportListId = importList.Definition.Id);
result.Series.AddRange(importListReports);
var removed = _importListItemService.SyncSeriesForList(importListReports, importList.Definition.Id);
_importListStatusService.UpdateListSyncStatus(importList.Definition.Id, removed > 0);
}
_importListStatusService.UpdateListSyncStatus(importList.Definition.Id); result.AnyFailure |= fetchResult.AnyFailure;
}
} }
catch (Exception e) catch (Exception e)
{ {
@ -86,16 +95,16 @@ namespace NzbDrone.Core.ImportLists
Task.WaitAll(taskList.ToArray()); Task.WaitAll(taskList.ToArray());
result = result.DistinctBy(r => new { r.TvdbId, r.ImdbId, r.Title }).ToList(); result.Series = result.Series.DistinctBy(r => new { r.TvdbId, r.ImdbId, r.Title }).ToList();
_logger.Debug("Found {0} total reports from {1} lists", result.Count, importLists.Count); _logger.Debug("Found {0} total reports from {1} lists", result.Series.Count, importLists.Count);
return result; return result;
} }
public List<ImportListItemInfo> FetchSingleList(ImportListDefinition definition) public ImportListFetchResult FetchSingleList(ImportListDefinition definition)
{ {
var result = new List<ImportListItemInfo>(); var result = new ImportListFetchResult();
var importList = _importListFactory.GetInstance(definition); var importList = _importListFactory.GetInstance(definition);
@ -114,16 +123,25 @@ namespace NzbDrone.Core.ImportLists
{ {
try try
{ {
var importListReports = importListLocal.Fetch(); var fetchResult = importListLocal.Fetch();
var importListReports = fetchResult.Series;
lock (result) lock (result)
{ {
_logger.Debug("Found {0} reports from {1} ({2})", importListReports.Count, importList.Name, importListLocal.Definition.Name); _logger.Debug("Found {0} reports from {1} ({2})", importListReports.Count, importList.Name, importListLocal.Definition.Name);
result.AddRange(importListReports); if (!fetchResult.AnyFailure)
{
importListReports.ForEach(s => s.ImportListId = importList.Definition.Id);
result.Series.AddRange(importListReports);
var removed = _importListItemService.SyncSeriesForList(importListReports, importList.Definition.Id);
_importListStatusService.UpdateListSyncStatus(importList.Definition.Id, removed > 0);
}
result.AnyFailure |= fetchResult.AnyFailure;
} }
_importListStatusService.UpdateListSyncStatus(importList.Definition.Id); result.AnyFailure |= fetchResult.AnyFailure;
} }
catch (Exception e) catch (Exception e)
{ {
@ -135,8 +153,6 @@ namespace NzbDrone.Core.ImportLists
Task.WaitAll(taskList.ToArray()); Task.WaitAll(taskList.ToArray());
result = result.DistinctBy(r => new { r.TvdbId, r.ImdbId, r.Title }).ToList();
return result; return result;
} }
} }

@ -38,15 +38,16 @@ namespace NzbDrone.Core.ImportLists
_httpClient = httpClient; _httpClient = httpClient;
} }
public override IList<ImportListItemInfo> Fetch() public override ImportListFetchResult Fetch()
{ {
return FetchItems(g => g.GetListItems(), true); return FetchItems(g => g.GetListItems(), true);
} }
protected virtual IList<ImportListItemInfo> FetchItems(Func<IImportListRequestGenerator, ImportListPageableRequestChain> pageableRequestChainSelector, bool isRecent = false) protected virtual ImportListFetchResult FetchItems(Func<IImportListRequestGenerator, ImportListPageableRequestChain> pageableRequestChainSelector, bool isRecent = false)
{ {
var releases = new List<ImportListItemInfo>(); var releases = new List<ImportListItemInfo>();
var url = string.Empty; var url = string.Empty;
var anyFailure = true;
try try
{ {
@ -92,6 +93,7 @@ namespace NzbDrone.Core.ImportLists
} }
_importListStatusService.RecordSuccess(Definition.Id); _importListStatusService.RecordSuccess(Definition.Id);
anyFailure = false;
} }
catch (WebException webException) catch (WebException webException)
{ {
@ -163,7 +165,7 @@ namespace NzbDrone.Core.ImportLists
_logger.Error(ex, "An error occurred while processing feed. {0}", url); _logger.Error(ex, "An error occurred while processing feed. {0}", url);
} }
return CleanupListItems(releases); return new ImportListFetchResult(CleanupListItems(releases), anyFailure);
} }
protected virtual bool IsValidItem(ImportListItemInfo listItem) protected virtual bool IsValidItem(ImportListItemInfo listItem)

@ -1,6 +1,4 @@
using System; using System;
using System.Collections.Generic;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Core.ImportLists namespace NzbDrone.Core.ImportLists
@ -9,6 +7,6 @@ namespace NzbDrone.Core.ImportLists
{ {
ImportListType ListType { get; } ImportListType ListType { get; }
TimeSpan MinRefreshInterval { get; } TimeSpan MinRefreshInterval { get; }
IList<ImportListItemInfo> Fetch(); ImportListFetchResult Fetch();
} }
} }

@ -11,6 +11,23 @@ using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Core.ImportLists namespace NzbDrone.Core.ImportLists
{ {
public class ImportListFetchResult
{
public ImportListFetchResult()
{
Series = new List<ImportListItemInfo>();
}
public ImportListFetchResult(IEnumerable<ImportListItemInfo> series, bool anyFailure)
{
Series = series.ToList();
AnyFailure = anyFailure;
}
public List<ImportListItemInfo> Series { get; set; }
public bool AnyFailure { get; set; }
}
public abstract class ImportListBase<TSettings> : IImportList public abstract class ImportListBase<TSettings> : IImportList
where TSettings : IImportListSettings, new() where TSettings : IImportListSettings, new()
{ {
@ -63,7 +80,7 @@ namespace NzbDrone.Core.ImportLists
protected TSettings Settings => (TSettings)Definition.Settings; protected TSettings Settings => (TSettings)Definition.Settings;
public abstract IList<ImportListItemInfo> Fetch(); public abstract ImportListFetchResult Fetch();
protected virtual IList<ImportListItemInfo> CleanupListItems(IEnumerable<ImportListItemInfo> releases) protected virtual IList<ImportListItemInfo> CleanupListItems(IEnumerable<ImportListItemInfo> releases)
{ {

@ -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);
}
}
}

@ -6,5 +6,6 @@ namespace NzbDrone.Core.ImportLists
public class ImportListStatus : ProviderStatusBase public class ImportListStatus : ProviderStatusBase
{ {
public DateTime? LastInfoSync { get; set; } public DateTime? LastInfoSync { get; set; }
public bool HasRemovedItemSinceLastClean { get; set; }
} }
} }

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using NLog; using NLog;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Messaging.Events;
@ -8,9 +9,10 @@ namespace NzbDrone.Core.ImportLists
{ {
public interface IImportListStatusService : IProviderStatusServiceBase<ImportListStatus> public interface IImportListStatusService : IProviderStatusServiceBase<ImportListStatus>
{ {
DateTime? GetLastSyncListInfo(int importListId); ImportListStatus GetListStatus(int importListId);
void UpdateListSyncStatus(int importListId); void UpdateListSyncStatus(int importListId, bool removedItems);
void MarkListsAsCleaned();
} }
public class ImportListStatusService : ProviderStatusServiceBase<IImportList, ImportListStatus>, IImportListStatusService public class ImportListStatusService : ProviderStatusServiceBase<IImportList, ImportListStatus>, IImportListStatusService
@ -20,21 +22,38 @@ namespace NzbDrone.Core.ImportLists
{ {
} }
public DateTime? GetLastSyncListInfo(int importListId) public ImportListStatus GetListStatus(int importListId)
{ {
return GetProviderStatus(importListId).LastInfoSync; return GetProviderStatus(importListId);
} }
public void UpdateListSyncStatus(int importListId) public void UpdateListSyncStatus(int importListId, bool removedItems)
{ {
lock (_syncRoot) lock (_syncRoot)
{ {
var status = GetProviderStatus(importListId); var status = GetProviderStatus(importListId);
status.LastInfoSync = DateTime.UtcNow; status.LastInfoSync = DateTime.UtcNow;
status.HasRemovedItemSinceLastClean |= removedItems;
_providerStatusRepository.Upsert(status); _providerStatusRepository.Upsert(status);
} }
} }
public void MarkListsAsCleaned()
{
lock (_syncRoot)
{
var toUpdate = new List<ImportListStatus>();
foreach (var status in _providerStatusRepository.All())
{
status.HasRemovedItemSinceLastClean = false;
toUpdate.Add(status);
}
_providerStatusRepository.UpdateMany(toUpdate);
}
}
} }
} }

@ -3,41 +3,85 @@ using System.Linq;
using NLog; using NLog;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.ImportLists.Exclusions; using NzbDrone.Core.ImportLists.Exclusions;
using NzbDrone.Core.ImportLists.ImportListItems;
using NzbDrone.Core.Jobs;
using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.MetadataSource; using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.ThingiProvider.Events;
using NzbDrone.Core.Tv; using NzbDrone.Core.Tv;
namespace NzbDrone.Core.ImportLists namespace NzbDrone.Core.ImportLists
{ {
public class ImportListSyncService : IExecute<ImportListSyncCommand> public class ImportListSyncService : IExecute<ImportListSyncCommand>, IHandleAsync<ProviderDeletedEvent<IImportList>>
{ {
private readonly IImportListFactory _importListFactory; private readonly IImportListFactory _importListFactory;
private readonly IImportListStatusService _importListStatusService;
private readonly IImportListExclusionService _importListExclusionService; private readonly IImportListExclusionService _importListExclusionService;
private readonly IImportListItemService _importListItemService;
private readonly IFetchAndParseImportList _listFetcherAndParser; private readonly IFetchAndParseImportList _listFetcherAndParser;
private readonly ISearchForNewSeries _seriesSearchService; private readonly ISearchForNewSeries _seriesSearchService;
private readonly ISeriesService _seriesService; private readonly ISeriesService _seriesService;
private readonly IAddSeriesService _addSeriesService; private readonly IAddSeriesService _addSeriesService;
private readonly IConfigService _configService;
private readonly ITaskManager _taskManager;
private readonly Logger _logger; private readonly Logger _logger;
public ImportListSyncService(IImportListFactory importListFactory, public ImportListSyncService(IImportListFactory importListFactory,
IImportListStatusService importListStatusService,
IImportListExclusionService importListExclusionService, IImportListExclusionService importListExclusionService,
IImportListItemService importListItemService,
IFetchAndParseImportList listFetcherAndParser, IFetchAndParseImportList listFetcherAndParser,
ISearchForNewSeries seriesSearchService, ISearchForNewSeries seriesSearchService,
ISeriesService seriesService, ISeriesService seriesService,
IAddSeriesService addSeriesService, IAddSeriesService addSeriesService,
IConfigService configService,
ITaskManager taskManager,
Logger logger) Logger logger)
{ {
_importListFactory = importListFactory; _importListFactory = importListFactory;
_importListStatusService = importListStatusService;
_importListExclusionService = importListExclusionService; _importListExclusionService = importListExclusionService;
_importListItemService = importListItemService;
_listFetcherAndParser = listFetcherAndParser; _listFetcherAndParser = listFetcherAndParser;
_seriesSearchService = seriesSearchService; _seriesSearchService = seriesSearchService;
_seriesService = seriesService; _seriesService = seriesService;
_addSeriesService = addSeriesService; _addSeriesService = addSeriesService;
_configService = configService;
_taskManager = taskManager;
_logger = logger; _logger = logger;
} }
private bool AllListsSuccessfulWithAPendingClean()
{
var lists = _importListFactory.AutomaticAddEnabled(false);
var anyRemoved = false;
foreach (var list in lists)
{
var status = _importListStatusService.GetListStatus(list.Definition.Id);
if (status.DisabledTill.HasValue)
{
// list failed the last time it was synced.
return false;
}
if (!status.LastInfoSync.HasValue)
{
// list has never been synced.
return false;
}
anyRemoved |= status.HasRemovedItemSinceLastClean;
}
return anyRemoved;
}
private void SyncAll() private void SyncAll()
{ {
if (_importListFactory.AutomaticAddEnabled().Empty()) if (_importListFactory.AutomaticAddEnabled().Empty())
@ -49,18 +93,26 @@ namespace NzbDrone.Core.ImportLists
_logger.ProgressInfo("Starting Import List Sync"); _logger.ProgressInfo("Starting Import List Sync");
var listItems = _listFetcherAndParser.Fetch().ToList(); var result = _listFetcherAndParser.Fetch();
var listItems = result.Series.ToList();
ProcessListItems(listItems); ProcessListItems(listItems);
TryCleanLibrary();
} }
private void SyncList(ImportListDefinition definition) private void SyncList(ImportListDefinition definition)
{ {
_logger.ProgressInfo(string.Format("Starting Import List Refresh for List {0}", definition.Name)); _logger.ProgressInfo(string.Format("Starting Import List Refresh for List {0}", definition.Name));
var listItems = _listFetcherAndParser.FetchSingleList(definition).ToList(); var result = _listFetcherAndParser.FetchSingleList(definition);
var listItems = result.Series.ToList();
ProcessListItems(listItems); ProcessListItems(listItems);
TryCleanLibrary();
} }
private void ProcessListItems(List<ImportListItemInfo> items) private void ProcessListItems(List<ImportListItemInfo> items)
@ -90,6 +142,11 @@ namespace NzbDrone.Core.ImportLists
var importList = importLists.Single(x => x.Id == item.ImportListId); var importList = importLists.Single(x => x.Id == item.ImportListId);
if (!importList.EnableAutomaticAdd)
{
continue;
}
// Map by IMDb ID if we have it // Map by IMDb ID if we have it
if (item.TvdbId <= 0 && item.ImdbId.IsNotNullOrWhiteSpace()) if (item.TvdbId <= 0 && item.ImdbId.IsNotNullOrWhiteSpace())
{ {
@ -180,10 +237,10 @@ namespace NzbDrone.Core.ImportLists
SeasonFolder = importList.SeasonFolder, SeasonFolder = importList.SeasonFolder,
Tags = importList.Tags, Tags = importList.Tags,
AddOptions = new AddSeriesOptions AddOptions = new AddSeriesOptions
{ {
SearchForMissingEpisodes = importList.SearchForMissingEpisodes, SearchForMissingEpisodes = importList.SearchForMissingEpisodes,
Monitor = importList.ShouldMonitor Monitor = importList.ShouldMonitor
} }
}); });
} }
} }
@ -206,5 +263,64 @@ namespace NzbDrone.Core.ImportLists
SyncAll(); SyncAll();
} }
} }
private void TryCleanLibrary()
{
if (_configService.ListSyncLevel == ListSyncLevelType.Disabled)
{
return;
}
if (AllListsSuccessfulWithAPendingClean())
{
CleanLibrary();
}
}
private void CleanLibrary()
{
if (_configService.ListSyncLevel == ListSyncLevelType.Disabled)
{
return;
}
var seriesToUpdate = new List<Series>();
var seriesInLibrary = _seriesService.GetAllSeries();
foreach (var series in seriesInLibrary)
{
var seriesExists = _importListItemService.Exists(series.TvdbId, series.ImdbId);
if (!seriesExists)
{
switch (_configService.ListSyncLevel)
{
case ListSyncLevelType.LogOnly:
_logger.Info("{0} was in your library, but not found in your lists --> You might want to unmonitor or remove it", series);
break;
case ListSyncLevelType.KeepAndUnmonitor when series.Monitored:
_logger.Info("{0} was in your library, but not found in your lists --> Keeping in library but unmonitoring it", series);
series.Monitored = false;
seriesToUpdate.Add(series);
break;
case ListSyncLevelType.KeepAndTag when !series.Tags.Contains(_configService.ListSyncTag):
_logger.Info("{0} was in your library, but not found in your lists --> Keeping in library but tagging it", series);
series.Tags.Add(_configService.ListSyncTag);
seriesToUpdate.Add(series);
break;
default:
break;
}
}
}
_seriesService.UpdateSeries(seriesToUpdate, true);
_importListStatusService.MarkListsAsCleaned();
}
public void HandleAsync(ProviderDeletedEvent<IImportList> message)
{
TryCleanLibrary();
}
} }
} }

@ -0,0 +1,10 @@
namespace NzbDrone.Core.ImportLists
{
public enum ListSyncLevelType
{
Disabled,
LogOnly,
KeepAndUnmonitor,
KeepAndTag
}
}

@ -8,7 +8,6 @@ using NzbDrone.Core.Exceptions;
using NzbDrone.Core.Localization; using NzbDrone.Core.Localization;
using NzbDrone.Core.Notifications.Plex.PlexTv; using NzbDrone.Core.Notifications.Plex.PlexTv;
using NzbDrone.Core.Parser; using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Validation; using NzbDrone.Core.Validation;
namespace NzbDrone.Core.ImportLists.Plex namespace NzbDrone.Core.ImportLists.Plex
@ -35,7 +34,7 @@ namespace NzbDrone.Core.ImportLists.Plex
public override string Name => _localizationService.GetLocalizedString("ImportListsPlexSettingsWatchlistName"); public override string Name => _localizationService.GetLocalizedString("ImportListsPlexSettingsWatchlistName");
public override int PageSize => 50; public override int PageSize => 50;
public override IList<ImportListItemInfo> Fetch() public override ImportListFetchResult Fetch()
{ {
Settings.Validate().Filter("AccessToken").ThrowOnError(); Settings.Validate().Filter("AccessToken").ThrowOnError();

@ -1,11 +1,9 @@
using System; using System;
using System.Collections.Generic;
using NLog; using NLog;
using NzbDrone.Common.Http; using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Core.Localization; using NzbDrone.Core.Localization;
using NzbDrone.Core.Parser; using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.ImportLists.Rss namespace NzbDrone.Core.ImportLists.Rss
{ {
@ -26,7 +24,7 @@ namespace NzbDrone.Core.ImportLists.Rss
{ {
} }
public override IList<ImportListItemInfo> Fetch() public override ImportListFetchResult Fetch()
{ {
return FetchItems(g => g.GetListItems()); return FetchItems(g => g.GetListItems());
} }

@ -36,7 +36,7 @@ namespace NzbDrone.Core.ImportLists.Simkl
_importListRepository = netImportRepository; _importListRepository = netImportRepository;
} }
public override IList<ImportListItemInfo> Fetch() public override ImportListFetchResult Fetch()
{ {
Settings.Validate().Filter("AccessToken", "RefreshToken").ThrowOnError(); Settings.Validate().Filter("AccessToken", "RefreshToken").ThrowOnError();
_logger.Trace($"Access token expires at {Settings.Expires}"); _logger.Trace($"Access token expires at {Settings.Expires}");
@ -47,13 +47,14 @@ namespace NzbDrone.Core.ImportLists.Simkl
RefreshToken(); RefreshToken();
} }
var lastFetch = _importListStatusService.GetLastSyncListInfo(Definition.Id); var lastFetch = _importListStatusService.GetListStatus(Definition.Id).LastInfoSync;
var lastActivity = GetLastActivity(); var lastActivity = GetLastActivity();
// Check to see if user has any activity since last sync, if not return empty to avoid work // Check to see if user has any activity since last sync, if not return empty to avoid work
if (lastFetch.HasValue && lastActivity < lastFetch.Value.AddHours(-2)) if (lastFetch.HasValue && lastActivity < lastFetch.Value.AddHours(-2))
{ {
return Array.Empty<ImportListItemInfo>(); // mark failure to avoid deleting series due to emptyness
return new ImportListFetchResult(new List<ImportListItemInfo>(), true);
} }
var generator = GetRequestGenerator(); var generator = GetRequestGenerator();

@ -31,10 +31,10 @@ namespace NzbDrone.Core.ImportLists.Sonarr
_sonarrV3Proxy = sonarrV3Proxy; _sonarrV3Proxy = sonarrV3Proxy;
} }
public override IList<ImportListItemInfo> Fetch() public override ImportListFetchResult Fetch()
{ {
var series = new List<ImportListItemInfo>(); var series = new List<ImportListItemInfo>();
var anyFailure = false;
try try
{ {
var remoteSeries = _sonarrV3Proxy.GetSeries(Settings); var remoteSeries = _sonarrV3Proxy.GetSeries(Settings);
@ -75,9 +75,10 @@ namespace NzbDrone.Core.ImportLists.Sonarr
_logger.Debug(ex, "Failed to fetch data for list {0} ({1})", Definition.Name, Name); _logger.Debug(ex, "Failed to fetch data for list {0} ({1})", Definition.Name, Name);
_importListStatusService.RecordFailure(Definition.Id); _importListStatusService.RecordFailure(Definition.Id);
anyFailure = true;
} }
return CleanupListItems(series); return new ImportListFetchResult(CleanupListItems(series), anyFailure);
} }
public override object RequestAction(string action, IDictionary<string, string> query) public override object RequestAction(string action, IDictionary<string, string> query)

@ -6,7 +6,6 @@ using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Core.Localization; using NzbDrone.Core.Localization;
using NzbDrone.Core.Parser; using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Validation; using NzbDrone.Core.Validation;
namespace NzbDrone.Core.ImportLists.Trakt namespace NzbDrone.Core.ImportLists.Trakt
@ -36,7 +35,7 @@ namespace NzbDrone.Core.ImportLists.Trakt
_importListRepository = netImportRepository; _importListRepository = netImportRepository;
} }
public override IList<ImportListItemInfo> Fetch() public override ImportListFetchResult Fetch()
{ {
Settings.Validate().Filter("AccessToken", "RefreshToken").ThrowOnError(); Settings.Validate().Filter("AccessToken", "RefreshToken").ThrowOnError();
_logger.Trace($"Access token expires at {Settings.Expires}"); _logger.Trace($"Access token expires at {Settings.Expires}");

@ -35,7 +35,8 @@ namespace NzbDrone.Core.ImportLists.Trakt
series.AddIfNotNull(new ImportListItemInfo() series.AddIfNotNull(new ImportListItemInfo()
{ {
Title = traktResponse.Show.Title, Title = traktResponse.Show.Title,
TvdbId = traktResponse.Show.Ids.Tvdb.GetValueOrDefault() TvdbId = traktResponse.Show.Ids.Tvdb.GetValueOrDefault(),
ImdbId = traktResponse.Show.Ids.Imdb
}); });
} }

@ -208,6 +208,7 @@
"ChownGroup": "chown Group", "ChownGroup": "chown Group",
"ChownGroupHelpText": "Group name or gid. Use gid for remote file systems.", "ChownGroupHelpText": "Group name or gid. Use gid for remote file systems.",
"ChownGroupHelpTextWarning": "This only works if the user running {appName} is the owner of the file. It's better to ensure the download client uses the same group as {appName}.", "ChownGroupHelpTextWarning": "This only works if the user running {appName} is the owner of the file. It's better to ensure the download client uses the same group as {appName}.",
"CleanLibraryLevel": "Clean Library Level",
"Clear": "Clear", "Clear": "Clear",
"ClearBlocklist": "Clear blocklist", "ClearBlocklist": "Clear blocklist",
"ClearBlocklistMessageText": "Are you sure you want to clear all items from the blocklist?", "ClearBlocklistMessageText": "Are you sure you want to clear all items from the blocklist?",
@ -790,6 +791,7 @@
"ImportListSearchForMissingEpisodes": "Search for Missing Episodes", "ImportListSearchForMissingEpisodes": "Search for Missing Episodes",
"ImportListSearchForMissingEpisodesHelpText": "After series is added to {appName} automatically search for missing episodes", "ImportListSearchForMissingEpisodesHelpText": "After series is added to {appName} automatically search for missing episodes",
"ImportListSettings": "Import List Settings", "ImportListSettings": "Import List Settings",
"ImportListStatusAllPossiblePartialFetchHealthCheckMessage": "All lists require manual interaction due to possible partial fetches",
"ImportListStatusAllUnavailableHealthCheckMessage": "All lists are unavailable due to failures", "ImportListStatusAllUnavailableHealthCheckMessage": "All lists are unavailable due to failures",
"ImportListStatusUnavailableHealthCheckMessage": "Lists unavailable due to failures: {importListNames}", "ImportListStatusUnavailableHealthCheckMessage": "Lists unavailable due to failures: {importListNames}",
"ImportLists": "Import Lists", "ImportLists": "Import Lists",
@ -1010,6 +1012,8 @@
"Interval": "Interval", "Interval": "Interval",
"InvalidFormat": "Invalid Format", "InvalidFormat": "Invalid Format",
"InvalidUILanguage": "Your UI is set to an invalid language, correct it and save your settings", "InvalidUILanguage": "Your UI is set to an invalid language, correct it and save your settings",
"KeepAndTagSeries": "Keep and Tag Series",
"KeepAndUnmonitorSeries": "Keep and Unmonitor Series",
"KeyboardShortcuts": "Keyboard Shortcuts", "KeyboardShortcuts": "Keyboard Shortcuts",
"KeyboardShortcutsCloseModal": "Close Current Modal", "KeyboardShortcutsCloseModal": "Close Current Modal",
"KeyboardShortcutsConfirmModal": "Accept Confirmation Modal", "KeyboardShortcutsConfirmModal": "Accept Confirmation Modal",
@ -1038,6 +1042,9 @@
"ListOptionsLoadError": "Unable to load list options", "ListOptionsLoadError": "Unable to load list options",
"ListQualityProfileHelpText": "Quality Profile list items will be added with", "ListQualityProfileHelpText": "Quality Profile list items will be added with",
"ListRootFolderHelpText": "Root Folder list items will be added to", "ListRootFolderHelpText": "Root Folder list items will be added to",
"ListSyncLevelHelpText": "Series in library will be handled based on your selection if they fall off or do not appear on your list(s)",
"ListSyncTag": "List Sync Tag",
"ListSyncTagHelpText": "This tag will be added when a series falls off or is no longer on your list(s)",
"ListTagsHelpText": "Tags that will be added on import from this list", "ListTagsHelpText": "Tags that will be added on import from this list",
"ListWillRefreshEveryInterval": "List will refresh every {refreshInterval}", "ListWillRefreshEveryInterval": "List will refresh every {refreshInterval}",
"ListsLoadError": "Unable to load Lists", "ListsLoadError": "Unable to load Lists",
@ -1050,6 +1057,7 @@
"LogFilesLocation": "Log files are located in: {location}", "LogFilesLocation": "Log files are located in: {location}",
"LogLevel": "Log Level", "LogLevel": "Log Level",
"LogLevelTraceHelpTextWarning": "Trace logging should only be enabled temporarily", "LogLevelTraceHelpTextWarning": "Trace logging should only be enabled temporarily",
"LogOnly": "Log Only",
"Logging": "Logging", "Logging": "Logging",
"Logout": "Logout", "Logout": "Logout",
"Logs": "Logs", "Logs": "Logs",
@ -1946,6 +1954,7 @@
"Umask777Description": "{octal} - Everyone write", "Umask777Description": "{octal} - Everyone write",
"UnableToLoadAutoTagging": "Unable to load auto tagging", "UnableToLoadAutoTagging": "Unable to load auto tagging",
"UnableToLoadBackups": "Unable to load backups", "UnableToLoadBackups": "Unable to load backups",
"UnableToLoadListOptions": "Unable to load list options",
"UnableToUpdateSonarrDirectly": "Unable to update {appName} directly,", "UnableToUpdateSonarrDirectly": "Unable to update {appName} directly,",
"Unavailable": "Unavailable", "Unavailable": "Unavailable",
"Underscore": "Underscore", "Underscore": "Underscore",

@ -1,8 +1,9 @@
using System; using System;
using NzbDrone.Core.Datastore;
namespace NzbDrone.Core.Parser.Model namespace NzbDrone.Core.Parser.Model
{ {
public class ImportListItemInfo public class ImportListItemInfo : ModelBase
{ {
public int ImportListId { get; set; } public int ImportListId { get; set; }
public string ImportList { get; set; } public string ImportList { get; set; }

@ -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…
Cancel
Save