diff --git a/frontend/src/Book/Details/BookDetails.js b/frontend/src/Book/Details/BookDetails.js
index b9521369..da4e4af1 100644
--- a/frontend/src/Book/Details/BookDetails.js
+++ b/frontend/src/Book/Details/BookDetails.js
@@ -1,10 +1,11 @@
import PropTypes from 'prop-types';
-import React, { Component } from 'react';
+import React, { Component, Fragment } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import AuthorHistoryTable from 'Author/History/AuthorHistoryTable';
import DeleteBookModal from 'Book/Delete/DeleteBookModal';
import EditBookModalConnector from 'Book/Edit/EditBookModalConnector';
import BookFileEditorTable from 'BookFile/Editor/BookFileEditorTable';
+import GrimmoryPushModal from 'Grimmory/GrimmoryPushModal';
import IconButton from 'Components/Link/IconButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
@@ -36,6 +37,7 @@ class BookDetails extends Component {
this.state = {
isOrganizeModalOpen: false,
isRetagModalOpen: false,
+ isGrimmoryPushModalOpen: false,
isEditBookModalOpen: false,
isDeleteBookModalOpen: false,
selectedTabIndex: 0
@@ -74,6 +76,19 @@ class BookDetails extends Component {
this.setState({ isRetagModalOpen: false });
};
+ onGrimmoryPushPress = () => {
+ this.setState({ isGrimmoryPushModalOpen: true });
+ };
+
+ onGrimmoryPushModalClose = () => {
+ this.setState({ isGrimmoryPushModalOpen: false });
+ };
+
+ onGrimmoryPushConfirmed = (fields) => {
+ this.setState({ isGrimmoryPushModalOpen: false });
+ this.props.onPushToGrimmoryPress(fields);
+ };
+
onEditBookPress = () => {
this.setState({ isEditBookModalOpen: true });
};
@@ -117,6 +132,9 @@ class BookDetails extends Component {
nextBook,
hasBookNavigation,
isSearching,
+ showPushToGrimmory,
+ isPushingToGrimmory,
+ grimmoryPreview,
onRefreshPress,
onSearchPress,
statistics = {}
@@ -129,6 +147,7 @@ class BookDetails extends Component {
const {
isOrganizeModalOpen,
isRetagModalOpen,
+ isGrimmoryPushModalOpen,
isEditBookModalOpen,
isDeleteBookModalOpen,
selectedTabIndex
@@ -172,6 +191,23 @@ class BookDetails extends Component {
+ {
+ showPushToGrimmory ?
+
+
+
+
+ :
+ null
+ }
+
+
+
);
@@ -411,6 +455,10 @@ BookDetails.propTypes = {
nextBook: PropTypes.object,
hasBookNavigation: PropTypes.bool,
isSmallScreen: PropTypes.bool.isRequired,
+ showPushToGrimmory: PropTypes.bool,
+ isPushingToGrimmory: PropTypes.bool,
+ grimmoryPreview: PropTypes.object,
+ onPushToGrimmoryPress: PropTypes.func,
onMonitorTogglePress: PropTypes.func.isRequired,
onRefreshPress: PropTypes.func,
onSearchPress: PropTypes.func.isRequired
diff --git a/frontend/src/Book/Details/BookDetailsConnector.js b/frontend/src/Book/Details/BookDetailsConnector.js
index fd020ac6..f33b51b2 100644
--- a/frontend/src/Book/Details/BookDetailsConnector.js
+++ b/frontend/src/Book/Details/BookDetailsConnector.js
@@ -11,6 +11,7 @@ import { executeCommand } from 'Store/Actions/commandActions';
import { clearEditions, fetchEditions } from 'Store/Actions/editionActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
+import { fetchNotifications } from 'Store/Actions/settingsActions';
import createAllAuthorSelector from 'Store/Selectors/createAllAuthorsSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
@@ -19,6 +20,34 @@ import { findCommand, isCommandExecuting } from 'Utilities/Command';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import BookDetails from './BookDetails';
+function buildGrimmoryPreview(book, author, edition) {
+ const identifiers = [];
+
+ if (edition?.isbn13) {
+ identifiers.push(`isbn: ${edition.isbn13}`);
+ }
+
+ if (edition?.asin) {
+ identifiers.push(`asin: ${edition.asin}`);
+ }
+
+ if (edition?.foreignEditionId) {
+ identifiers.push(`goodreads: ${edition.foreignEditionId}`);
+ }
+
+ return {
+ title: edition?.title || book.title,
+ authors: author.authorName,
+ series: book.seriesTitle,
+ description: edition?.overview || book.overview,
+ publisher: edition?.publisher,
+ publisheddate: book.releaseDate,
+ language: edition?.language,
+ tags: (book.genres || []).join(', '),
+ identifiers: identifiers.join(', ')
+ };
+}
+
const selectBookFiles = createSelector(
(state) => state.bookFiles,
(bookFiles) => {
@@ -51,7 +80,8 @@ function createMapStateToProps() {
createCommandsSelector(),
createUISettingsSelector(),
createDimensionsSelector(),
- (bookId, bookFiles, books, editions, authors, commands, uiSettings, dimensions) => {
+ (state) => state.settings.notifications.items,
+ (bookId, bookFiles, books, editions, authors, commands, uiSettings, dimensions, notifications) => {
try {
const book = books.items.find((b) => b.id === bookId);
@@ -116,6 +146,15 @@ function createMapStateToProps() {
isRenamingAuthorCommand.body.authorIds.indexOf(author.id) > -1
);
+ const grimmoryPushCommand = findCommand(commands, { name: commandNames.PUSH_GRIMMORY_METADATA });
+ const isPushingToGrimmory = !!(
+ grimmoryPushCommand &&
+ isCommandExecuting(grimmoryPushCommand) &&
+ grimmoryPushCommand.body &&
+ (grimmoryPushCommand.body.bookIds || []).includes(book.id)
+ );
+ const showPushToGrimmory = notifications.some((n) => n.implementation === 'Grimmory');
+
const isFetching = isBookFilesFetching || editions.isFetching;
const isPopulated = isBookFilesPopulated && editions.isPopulated;
const selectedEdition = editions.items
@@ -140,6 +179,9 @@ function createMapStateToProps() {
author,
isRefreshing,
isSearching,
+ showPushToGrimmory,
+ isPushingToGrimmory,
+ grimmoryPreview: buildGrimmoryPreview(book, author, selectedEdition),
isRenamingFiles,
isRenamingAuthor,
isFetching,
@@ -163,6 +205,7 @@ function createMapStateToProps() {
const mapDispatchToProps = {
executeCommand,
+ fetchNotifications,
fetchBookFiles,
clearBookFiles,
fetchEditions,
@@ -228,6 +271,7 @@ class BookDetailsConnector extends Component {
this.props.fetchBookFiles({ bookId });
this.props.fetchEditions({ bookId });
this.props.fetchQueueDetails({ bookIds: [bookId] });
+ this.props.fetchNotifications();
};
unpopulate = () => {
@@ -262,6 +306,14 @@ class BookDetailsConnector extends Component {
});
};
+ onPushToGrimmoryPress = (fields) => {
+ this.props.executeCommand({
+ name: commandNames.PUSH_GRIMMORY_METADATA,
+ bookIds: [this.props.id],
+ fields
+ });
+ };
+
//
// Render
@@ -272,6 +324,7 @@ class BookDetailsConnector extends Component {
onMonitorTogglePress={this.onMonitorTogglePress}
onRefreshPress={this.onRefreshPress}
onSearchPress={this.onSearchPress}
+ onPushToGrimmoryPress={this.onPushToGrimmoryPress}
/>
);
}
@@ -286,6 +339,7 @@ BookDetailsConnector.propTypes = {
isBookFetching: PropTypes.bool,
isBookPopulated: PropTypes.bool,
bookId: PropTypes.number.isRequired,
+ fetchNotifications: PropTypes.func.isRequired,
fetchBookFiles: PropTypes.func.isRequired,
clearBookFiles: PropTypes.func.isRequired,
fetchEditions: PropTypes.func.isRequired,
diff --git a/frontend/src/Book/Editor/BookEditorFooter.js b/frontend/src/Book/Editor/BookEditorFooter.js
index b16d5cc4..6175ed65 100644
--- a/frontend/src/Book/Editor/BookEditorFooter.js
+++ b/frontend/src/Book/Editor/BookEditorFooter.js
@@ -1,9 +1,15 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import * as commandNames from 'Commands/commandNames';
import SelectInput from 'Components/Form/SelectInput';
import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter';
+import GrimmoryPushModal from 'Grimmory/GrimmoryPushModal';
import { kinds } from 'Helpers/Props';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchNotifications } from 'Store/Actions/settingsActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import translate from 'Utilities/String/translate';
import BookEditorFooterLabel from './BookEditorFooterLabel';
import DeleteBookModal from './Delete/DeleteBookModal';
@@ -24,12 +30,17 @@ class BookEditorFooter extends Component {
rootFolderPath: NO_CHANGE,
savingTags: false,
isDeleteBookModalOpen: false,
+ isGrimmoryPushModalOpen: false,
isTagsModalOpen: false,
isConfirmMoveModalOpen: false,
destinationRootFolder: null
};
}
+ componentDidMount() {
+ this.props.fetchNotifications();
+ }
+
componentDidUpdate(prevProps) {
const {
isSaving,
@@ -72,6 +83,24 @@ class BookEditorFooter extends Component {
this.setState({ isDeleteBookModalOpen: false });
};
+ onPushToGrimmoryPress = () => {
+ this.setState({ isGrimmoryPushModalOpen: true });
+ };
+
+ onGrimmoryPushModalClose = () => {
+ this.setState({ isGrimmoryPushModalOpen: false });
+ };
+
+ onGrimmoryPushConfirmed = (fields) => {
+ this.setState({ isGrimmoryPushModalOpen: false });
+
+ this.props.executeCommand({
+ name: commandNames.PUSH_GRIMMORY_METADATA,
+ bookIds: this.props.bookIds,
+ fields
+ });
+ };
+
//
// Render
@@ -80,12 +109,15 @@ class BookEditorFooter extends Component {
bookIds,
selectedCount,
isSaving,
- isDeleting
+ isDeleting,
+ isPushingToGrimmory,
+ showPushToGrimmory
} = this.props;
const {
monitored,
- isDeleteBookModalOpen
+ isDeleteBookModalOpen,
+ isGrimmoryPushModalOpen
} = this.state;
const monitoredOptions = [
@@ -119,6 +151,20 @@ class BookEditorFooter extends Component {
/>
+ {
+ showPushToGrimmory ?
+
+ {translate('PushChaptarrMetadataToGrimmory')}
+ :
+ null
+ }
+
+
+
);
}
@@ -150,7 +203,20 @@ BookEditorFooter.propTypes = {
saveError: PropTypes.object,
isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object,
+ isPushingToGrimmory: PropTypes.bool.isRequired,
+ showPushToGrimmory: PropTypes.bool.isRequired,
+ fetchNotifications: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
onSaveSelected: PropTypes.func.isRequired
};
-export default BookEditorFooter;
+const selectIsPushingToGrimmory = createCommandExecutingSelector(commandNames.PUSH_GRIMMORY_METADATA);
+
+function mapStateToProps(state) {
+ return {
+ isPushingToGrimmory: selectIsPushingToGrimmory(state),
+ showPushToGrimmory: state.settings.notifications.items.some((n) => n.implementation === 'Grimmory')
+ };
+}
+
+export default connect(mapStateToProps, { executeCommand, fetchNotifications })(BookEditorFooter);
diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js
index db9a4a0e..f0e7bfa9 100644
--- a/frontend/src/Commands/commandNames.js
+++ b/frontend/src/Commands/commandNames.js
@@ -13,6 +13,7 @@ export const BOOK_SEARCH = 'BookSearch';
export const INTERACTIVE_IMPORT = 'ManualImport';
export const MISSING_BOOK_SEARCH = 'MissingBookSearch';
export const MOVE_AUTHOR = 'MoveAuthor';
+export const PUSH_GRIMMORY_METADATA = 'PushGrimmoryMetadata';
export const REFRESH_AUTHOR = 'RefreshAuthor';
export const BULK_REFRESH_AUTHOR = 'BulkRefreshAuthor';
export const REFRESH_BOOK = 'RefreshBook';
diff --git a/frontend/src/Grimmory/GrimmoryPushModal.js b/frontend/src/Grimmory/GrimmoryPushModal.js
new file mode 100644
index 00000000..a21d4e23
--- /dev/null
+++ b/frontend/src/Grimmory/GrimmoryPushModal.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import GrimmoryPushModalContent from './GrimmoryPushModalContent';
+
+function GrimmoryPushModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {
+ isOpen &&
+
+ }
+
+ );
+}
+
+GrimmoryPushModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default GrimmoryPushModal;
diff --git a/frontend/src/Grimmory/GrimmoryPushModalContent.css b/frontend/src/Grimmory/GrimmoryPushModalContent.css
new file mode 100644
index 00000000..242fed40
--- /dev/null
+++ b/frontend/src/Grimmory/GrimmoryPushModalContent.css
@@ -0,0 +1,29 @@
+.description {
+ margin-bottom: 20px;
+}
+
+.field {
+ display: flex;
+ align-items: center;
+ padding: 6px 0;
+ border-bottom: 1px solid var(--borderColor);
+}
+
+.field:last-child {
+ border-bottom: none;
+}
+
+.check {
+ flex: 0 0 30px;
+}
+
+.label {
+ flex: 0 0 130px;
+ font-weight: bold;
+}
+
+.value {
+ flex: 1 1 auto;
+ color: var(--helpTextColor);
+ word-break: break-word;
+}
diff --git a/frontend/src/Grimmory/GrimmoryPushModalContent.css.d.ts b/frontend/src/Grimmory/GrimmoryPushModalContent.css.d.ts
new file mode 100644
index 00000000..f8a86fb7
--- /dev/null
+++ b/frontend/src/Grimmory/GrimmoryPushModalContent.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'check': string;
+ 'description': string;
+ 'field': string;
+ 'label': string;
+ 'value': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Grimmory/GrimmoryPushModalContent.js b/frontend/src/Grimmory/GrimmoryPushModalContent.js
new file mode 100644
index 00000000..6b84822f
--- /dev/null
+++ b/frontend/src/Grimmory/GrimmoryPushModalContent.js
@@ -0,0 +1,129 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import Button from 'Components/Link/Button';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import styles from './GrimmoryPushModalContent.css';
+
+const grimmoryFields = [
+ { name: 'cover', label: 'Cover' },
+ { name: 'title', label: 'Title' },
+ { name: 'authors', label: 'Author' },
+ { name: 'series', label: 'Series' },
+ { name: 'description', label: 'Description' },
+ { name: 'publisher', label: 'Publisher' },
+ { name: 'publisheddate', label: 'PublishedDate' },
+ { name: 'language', label: 'Language' },
+ { name: 'tags', label: 'Tags' },
+ { name: 'identifiers', label: 'Identifiers' }
+];
+
+class GrimmoryPushModalContent extends Component {
+
+ constructor(props, context) {
+ super(props, context);
+
+ const selected = {};
+ grimmoryFields.forEach((field) => {
+ selected[field.name] = true;
+ });
+
+ this.state = { selected };
+ }
+
+ //
+ // Listeners
+
+ onFieldChange = ({ name, value }) => {
+ this.setState((state) => {
+ return { selected: { ...state.selected, [name]: value } };
+ });
+ };
+
+ onPushPress = () => {
+ const fields = grimmoryFields
+ .map((field) => field.name)
+ .filter((name) => this.state.selected[name]);
+
+ this.props.onPushPress(fields);
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ bookCount,
+ previewValues,
+ onModalClose
+ } = this.props;
+
+ const {
+ selected
+ } = this.state;
+
+ const anySelected = grimmoryFields.some((field) => selected[field.name]);
+
+ return (
+
+
+ {translate('PushChaptarrMetadataToGrimmory')}
+
+
+
+
+ {translate('GrimmoryPushDescriptionInterp', [bookCount])}
+
+
+ {
+ grimmoryFields.map((field) => {
+ const preview = previewValues ? previewValues[field.name] : null;
+
+ return (
+
+
+
+
+
{translate(field.label)}
+
{preview}
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+GrimmoryPushModalContent.propTypes = {
+ bookCount: PropTypes.number.isRequired,
+ previewValues: PropTypes.object,
+ onPushPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default GrimmoryPushModalContent;
diff --git a/frontend/src/System/Quickstart/Quickstart.js b/frontend/src/System/Quickstart/Quickstart.js
index 4f8dc3fd..7a7a8200 100644
--- a/frontend/src/System/Quickstart/Quickstart.js
+++ b/frontend/src/System/Quickstart/Quickstart.js
@@ -12,6 +12,7 @@ import translate from 'Utilities/String/translate';
import QuickstartAudioBookShelfSection from './QuickstartAudioBookShelfSection';
import QuickstartCustomFormatsSection from './QuickstartCustomFormatsSection';
import QuickstartDownloadClientsSection from './QuickstartDownloadClientsSection';
+import QuickstartGrimmorySection from './QuickstartGrimmorySection';
import QuickstartHardcoverSection from './QuickstartHardcoverSection';
import QuickstartMAMSection from './QuickstartMAMSection';
import QuickstartMatchingSection from './QuickstartMatchingSection';
@@ -98,6 +99,8 @@ function Quickstart(props) {
const {
hasActiveAudioBookShelf,
audioBookShelfNotification,
+ hasActiveGrimmory,
+ grimmoryNotification,
mamIndexer,
indexersState,
notificationsState,
@@ -169,6 +172,19 @@ function Quickstart(props) {
/>
+
+
+
+
+ const audioBookShelfNotification = notifications.find((notification) =>
notification.implementationName === 'AudioBookShelf'
);
+ const grimmoryNotification = notifications.find((notification) =>
+ notification.implementationName === 'Grimmory'
+ );
+
// Check if proxy is configured
const proxyMode = generalSettings.item?.proxyMode?.value || 'disabled';
const globalProxyId = generalSettings.item?.globalProxyId?.value;
@@ -73,8 +77,10 @@ function createMapStateToProps() {
return {
hasActiveMAMIndexer: !!(mamIndexer && mamIndexer.enable),
hasActiveAudioBookShelf: !!(audioBookShelfNotification && audioBookShelfNotification.enable),
+ hasActiveGrimmory: !!(grimmoryNotification && grimmoryNotification.enable),
mamIndexer,
audioBookShelfNotification,
+ grimmoryNotification,
indexersState,
notificationsState,
downloadClientsState,
diff --git a/frontend/src/System/Quickstart/QuickstartGrimmorySection.js b/frontend/src/System/Quickstart/QuickstartGrimmorySection.js
new file mode 100644
index 00000000..7c182fac
--- /dev/null
+++ b/frontend/src/System/Quickstart/QuickstartGrimmorySection.js
@@ -0,0 +1,273 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import Alert from 'Components/Alert';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import { kinds } from 'Helpers/Props';
+import EditNotificationModalConnector from 'Settings/Notifications/Notifications/EditNotificationModalConnector';
+import { deleteNotification } from 'Store/Actions/settingsActions';
+import translate from 'Utilities/String/translate';
+import styles from './Quickstart.css';
+
+class QuickstartGrimmorySection extends Component {
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditNotificationModalOpen: false,
+ isDeleteNotificationModalOpen: false,
+ pendingOpenGrimmory: false,
+ schemaSelectionError: false
+ };
+ }
+
+ componentDidMount() {
+ if (!this.props.notificationsState.isSchemaPopulated) {
+ this.props.fetchNotificationSchema();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const previousSchemaWasUsable = prevProps.notificationsState.isSchemaPopulated &&
+ !prevProps.notificationsState.schemaError;
+ const schemaIsUsable = this.props.notificationsState.isSchemaPopulated &&
+ !this.props.notificationsState.schemaError;
+ const schemaJustBecameUsable = !previousSchemaWasUsable && schemaIsUsable;
+ const schemaFetchJustFailed = prevProps.notificationsState.isSchemaFetching &&
+ !this.props.notificationsState.isSchemaFetching &&
+ this.props.notificationsState.schemaError;
+
+ if (this.state.pendingOpenGrimmory && schemaJustBecameUsable) {
+ this.openAddGrimmoryNotification();
+ }
+
+ if (this.state.pendingOpenGrimmory && schemaFetchJustFailed) {
+ this.setState({ pendingOpenGrimmory: false });
+ }
+ }
+
+ //
+ // Listeners
+
+ onButtonPress = () => {
+ const {
+ grimmoryNotification,
+ notificationsState,
+ fetchNotificationSchema
+ } = this.props;
+
+ if (grimmoryNotification) {
+ this.setState({
+ isEditNotificationModalOpen: true,
+ schemaSelectionError: false
+ });
+ } else {
+ const hasUsableSchema = notificationsState.isSchemaPopulated && !notificationsState.schemaError;
+
+ if (!hasUsableSchema) {
+ if (!notificationsState.isSchemaFetching && fetchNotificationSchema) {
+ fetchNotificationSchema();
+ }
+
+ this.setState({
+ pendingOpenGrimmory: true,
+ schemaSelectionError: false
+ });
+ return;
+ }
+
+ this.openAddGrimmoryNotification();
+ }
+ };
+
+ openAddGrimmoryNotification = () => {
+ const schemaItems = Array.isArray(this.props.notificationsState?.schema) ? this.props.notificationsState.schema : [];
+ const hasGrimmorySchema = schemaItems.some((schemaItem) => schemaItem.implementation === 'Grimmory');
+
+ if (!hasGrimmorySchema) {
+ this.setState({
+ pendingOpenGrimmory: false,
+ schemaSelectionError: true
+ });
+ return;
+ }
+
+ this.props.selectNotificationSchema({ implementation: 'Grimmory' });
+ this.setState({
+ isEditNotificationModalOpen: true,
+ pendingOpenGrimmory: false,
+ schemaSelectionError: false
+ });
+ };
+
+ onEditNotificationModalClose = () => {
+ this.setState({
+ isEditNotificationModalOpen: false,
+ pendingOpenGrimmory: false,
+ schemaSelectionError: false
+ });
+
+ if (this.props.fetchNotifications) {
+ this.props.fetchNotifications();
+ }
+ };
+
+ onDeleteNotificationPress = () => {
+ this.setState({
+ isEditNotificationModalOpen: false,
+ isDeleteNotificationModalOpen: true
+ });
+ };
+
+ onDeleteNotificationModalClose = () => {
+ this.setState({ isDeleteNotificationModalOpen: false });
+ };
+
+ onConfirmDeleteNotification = () => {
+ const { grimmoryNotification } = this.props;
+
+ if (grimmoryNotification) {
+ this.props.deleteNotification({ id: grimmoryNotification.id });
+ }
+
+ this.onDeleteNotificationModalClose();
+ };
+
+ onTestConnectionSuccess = () => {
+ const { markSectionInteracted } = this.props;
+ if (markSectionInteracted) {
+ markSectionInteracted({ section: 'grimmory' });
+ }
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ hasActiveGrimmory,
+ grimmoryNotification
+ } = this.props;
+
+ const {
+ isEditNotificationModalOpen,
+ isDeleteNotificationModalOpen,
+ pendingOpenGrimmory,
+ schemaSelectionError
+ } = this.state;
+
+ const buttonText = grimmoryNotification ?
+ translate('ConfigureName', { name: 'Grimmory' }) :
+ translate('AddName', { name: 'Grimmory' });
+ const isAddSchemaLoading = !grimmoryNotification &&
+ (this.props.notificationsState.isSchemaFetching || pendingOpenGrimmory);
+ const schemaError = !this.props.notificationsState.isSchemaFetching &&
+ (this.props.notificationsState.schemaError || schemaSelectionError);
+
+ if (this.props.compact) {
+ return (
+ <>
+
+
+
+
+ {
+ schemaError &&
+
+ {translate('QuickstartUnableToLoadNotificationOptions')}
+
+ }
+
+
+
+
+ >
+ );
+ }
+
+ return (
+
+
+ {translate('QuickstartGrimmoryConnectHeader')}
+
+ {!hasActiveGrimmory && (
+
+ {translate('QuickstartGrimmoryConnectDescription')}
+
+ )}
+
+
+
+
+
+ {
+ schemaError &&
+
+ {translate('QuickstartUnableToLoadNotificationOptions')}
+
+ }
+
+
+
+
+
+ );
+ }
+}
+
+QuickstartGrimmorySection.propTypes = {
+ hasActiveGrimmory: PropTypes.bool,
+ grimmoryNotification: PropTypes.object,
+ compact: PropTypes.bool,
+ notificationsState: PropTypes.object.isRequired,
+ fetchNotificationSchema: PropTypes.func.isRequired,
+ selectNotificationSchema: PropTypes.func.isRequired,
+ deleteNotification: PropTypes.func.isRequired,
+ markSectionInteracted: PropTypes.func,
+ fetchNotifications: PropTypes.func
+};
+
+export default connect(null, { deleteNotification })(QuickstartGrimmorySection);
diff --git a/src/Chaptarr.Core.Test/MediaFiles/DiskScanServiceFixture.cs b/src/Chaptarr.Core.Test/MediaFiles/DiskScanServiceFixture.cs
index 86ef1b41..a44641e6 100644
--- a/src/Chaptarr.Core.Test/MediaFiles/DiskScanServiceFixture.cs
+++ b/src/Chaptarr.Core.Test/MediaFiles/DiskScanServiceFixture.cs
@@ -185,6 +185,7 @@ private class DiskProviderProxy : DispatchProxy
public bool FileExistsResult { get; set; } = true;
public long FileLength { get; set; } = 100;
public DateTime FileLastWriteTime { get; set; } = DateTime.UtcNow;
+ public string[] GetDirectoriesResult { get; set; } = { "/books/Some Author" };
protected override object Invoke(MethodInfo targetMethod, object[] args)
{
@@ -193,6 +194,11 @@ protected override object Invoke(MethodInfo targetMethod, object[] args)
return FolderExistsResult;
}
+ if (targetMethod?.Name == "GetDirectories")
+ {
+ return GetDirectoriesResult;
+ }
+
if (targetMethod?.Name == "GetFileInfo")
{
var fileInfo = DispatchProxy.Create();
@@ -803,6 +809,26 @@ public void scan_should_skip_cleanup_when_safe_scan_finds_no_media_files()
Assert.That(cleanupProxy.CleanedPaths, Is.Empty);
}
+ [Test]
+ public void scan_should_cleanup_empty_subfolder_when_root_is_populated()
+ {
+ var sut = CreateScanService(
+ folderExists: true,
+ orchestratorResult: new OrchestratorImportResult
+ {
+ CleanupSafe = true,
+ ScannedFilePaths = new List()
+ },
+ out var importOrchestratorProxy,
+ out var cleanupProxy);
+
+ sut.Scan(new List { "/books/Some Author/Deleted Book" }, authorIds: new List());
+
+ Assert.That(importOrchestratorProxy.Calls, Is.EqualTo(1));
+ Assert.That(cleanupProxy.CleanedPaths, Has.Count.EqualTo(1));
+ Assert.That(cleanupProxy.CleanedPaths.Single(), Is.Empty);
+ }
+
[Test]
public void scan_should_cleanup_with_scanned_paths_only_when_orchestrator_result_is_cleanup_safe()
{
diff --git a/src/Chaptarr.Core.Test/Notifications/Grimmory/GrimmoryFixture.cs b/src/Chaptarr.Core.Test/Notifications/Grimmory/GrimmoryFixture.cs
new file mode 100644
index 00000000..b5922e54
--- /dev/null
+++ b/src/Chaptarr.Core.Test/Notifications/Grimmory/GrimmoryFixture.cs
@@ -0,0 +1,272 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using FluentValidation.Results;
+using NLog;
+using NUnit.Framework;
+using NzbDrone.Common.Cache;
+using NzbDrone.Core.Books;
+using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.Messaging.Commands;
+using NzbDrone.Core.Notifications;
+using NzbDrone.Core.Notifications.Grimmory;
+using NzbDrone.Core.Qualities;
+
+namespace Chaptarr.Core.Test.Notifications.Grimmory
+{
+ [TestFixture]
+ public class GrimmoryFixture
+ {
+ private const long EbookLibraryId = 10;
+ private const long AudiobookLibraryId = 20;
+
+ [Test]
+ public void should_not_refresh_at_event_time_and_refresh_on_process_queue()
+ {
+ var proxy = new FakeGrimmoryProxy();
+ var subject = CreateSubject(proxy);
+
+ subject.OnReleaseImport(BuildImport(BookMediaType.Ebook));
+
+ Assert.That(proxy.RefreshedLibraryIds, Is.Empty);
+ Assert.That(subject.HasPendingQueue, Is.True);
+
+ subject.ProcessQueue();
+
+ Assert.That(proxy.RefreshedLibraryIds, Is.EqualTo(new List { EbookLibraryId }));
+ Assert.That(subject.HasPendingQueue, Is.False);
+ }
+
+ [Test]
+ public void should_notify_on_library_imports_only_when_pushing()
+ {
+ var subject = CreateSubject(new FakeGrimmoryProxy());
+ var settings = (GrimmorySettings)subject.Definition.Settings;
+
+ Assert.That(subject.NotifyOnLibraryImports, Is.False);
+
+ settings.PushMetadata = true;
+ Assert.That(subject.NotifyOnLibraryImports, Is.True);
+
+ settings.PushMetadata = false;
+ settings.PushCovers = true;
+ Assert.That(subject.NotifyOnLibraryImports, Is.True);
+ }
+
+ [Test]
+ public void should_dedupe_multiple_events_into_single_refresh()
+ {
+ var proxy = new FakeGrimmoryProxy();
+ var subject = CreateSubject(proxy);
+
+ subject.OnReleaseImport(BuildImport(BookMediaType.Ebook));
+ subject.OnReleaseImport(BuildImport(BookMediaType.Ebook));
+ subject.OnBookFileDelete(new BookFileDeleteMessage
+ {
+ Book = new Book { MediaType = BookMediaType.Ebook },
+ BookFile = new BookFile { MediaType = "ebook" }
+ });
+
+ subject.ProcessQueue();
+
+ Assert.That(proxy.RefreshedLibraryIds, Is.EqualTo(new List { EbookLibraryId }));
+ }
+
+ [Test]
+ public void should_route_audiobook_events_to_audiobook_library()
+ {
+ var proxy = new FakeGrimmoryProxy();
+ var subject = CreateSubject(proxy);
+
+ subject.OnReleaseImport(BuildImport(BookMediaType.Audiobook));
+ subject.ProcessQueue();
+
+ Assert.That(proxy.RefreshedLibraryIds, Is.EqualTo(new List { AudiobookLibraryId }));
+ }
+
+ [Test]
+ public void should_skip_events_for_unconfigured_library()
+ {
+ var proxy = new FakeGrimmoryProxy();
+ var subject = CreateSubject(proxy, audiobookLibraryId: 0);
+
+ subject.OnReleaseImport(BuildImport(BookMediaType.Audiobook));
+
+ Assert.That(subject.HasPendingQueue, Is.False);
+
+ subject.ProcessQueue();
+
+ Assert.That(proxy.RefreshedLibraryIds, Is.Empty);
+ }
+
+ [Test]
+ public void should_refresh_both_libraries_for_mixed_renames()
+ {
+ var proxy = new FakeGrimmoryProxy();
+ var subject = CreateSubject(proxy);
+
+ subject.OnRename(new Author { Name = "Robin Hobb" }, new List
+ {
+ new RenamedBookFile { BookFile = new BookFile { MediaType = "ebook" } },
+ new RenamedBookFile { BookFile = new BookFile { MediaType = "audiobook" } }
+ });
+
+ subject.ProcessQueue();
+
+ Assert.That(proxy.RefreshedLibraryIds, Is.EquivalentTo(new List { EbookLibraryId, AudiobookLibraryId }));
+ }
+
+ [Test]
+ public void should_determine_media_type_from_quality_when_not_set_on_file()
+ {
+ var proxy = new FakeGrimmoryProxy();
+ var subject = CreateSubject(proxy);
+
+ subject.OnRename(new Author { Name = "Robin Hobb" }, new List
+ {
+ new RenamedBookFile { BookFile = new BookFile { MediaType = null, Quality = new QualityModel(Quality.EPUB) } }
+ });
+
+ subject.ProcessQueue();
+
+ Assert.That(proxy.RefreshedLibraryIds, Is.EqualTo(new List { EbookLibraryId }));
+ }
+
+ [Test]
+ public void should_not_queue_book_delete_without_deleted_files()
+ {
+ var proxy = new FakeGrimmoryProxy();
+ var subject = CreateSubject(proxy);
+
+ subject.OnBookDelete(new BookDeleteMessage(new Book { MediaType = BookMediaType.Ebook }, false));
+
+ Assert.That(subject.HasPendingQueue, Is.False);
+ }
+
+ [Test]
+ public void should_queue_both_configured_libraries_on_author_delete()
+ {
+ var proxy = new FakeGrimmoryProxy();
+ var subject = CreateSubject(proxy);
+
+ subject.OnAuthorDelete(new AuthorDeleteMessage(new Author { Name = "Robin Hobb" }, true));
+ subject.ProcessQueue();
+
+ Assert.That(proxy.RefreshedLibraryIds, Is.EquivalentTo(new List { EbookLibraryId, AudiobookLibraryId }));
+ }
+
+ [Test]
+ public void should_throw_and_still_refresh_remaining_when_one_library_fails()
+ {
+ var proxy = new FakeGrimmoryProxy();
+ proxy.FailingLibraryIds.Add(EbookLibraryId);
+
+ var subject = CreateSubject(proxy);
+
+ subject.OnReleaseImport(BuildImport(BookMediaType.Ebook));
+ subject.OnReleaseImport(BuildImport(BookMediaType.Audiobook));
+
+ Assert.Throws(() => subject.ProcessQueue());
+ Assert.That(proxy.RefreshedLibraryIds, Is.EqualTo(new List { AudiobookLibraryId }));
+ }
+
+ [Test]
+ public void should_queue_again_after_process_queue_failure()
+ {
+ var proxy = new FakeGrimmoryProxy();
+ proxy.FailingLibraryIds.Add(EbookLibraryId);
+
+ var subject = CreateSubject(proxy);
+
+ subject.OnReleaseImport(BuildImport(BookMediaType.Ebook));
+ Assert.Throws(() => subject.ProcessQueue());
+
+ proxy.FailingLibraryIds.Clear();
+
+ subject.OnReleaseImport(BuildImport(BookMediaType.Ebook));
+ subject.ProcessQueue();
+
+ Assert.That(proxy.RefreshedLibraryIds, Is.EqualTo(new List { EbookLibraryId }));
+ }
+
+ private static BookDownloadMessage BuildImport(BookMediaType mediaType)
+ {
+ var fileMediaType = mediaType == BookMediaType.Ebook ? "ebook" : "audiobook";
+
+ return new BookDownloadMessage
+ {
+ Author = new Author { Name = "Robin Hobb" },
+ Book = new Book { Title = "Assassin's Apprentice", MediaType = mediaType },
+ BookFiles = new List
+ {
+ new BookFile { MediaType = fileMediaType }
+ }
+ };
+ }
+
+ private static NzbDrone.Core.Notifications.Grimmory.Grimmory CreateSubject(FakeGrimmoryProxy proxy,
+ long ebookLibraryId = EbookLibraryId,
+ long audiobookLibraryId = AudiobookLibraryId)
+ {
+ var settings = new GrimmorySettings
+ {
+ Url = "http://grimmory:6060",
+ Username = "chaptarr",
+ Password = "secret",
+ EbookLibraryId = ebookLibraryId,
+ AudiobookLibraryId = audiobookLibraryId
+ };
+
+ return new NzbDrone.Core.Notifications.Grimmory.Grimmory(
+ proxy,
+ DispatchProxy.Create(),
+ null,
+ new CacheManager(),
+ LogManager.GetLogger("GrimmoryFixture"))
+ {
+ Definition = new NotificationDefinition { Settings = settings }
+ };
+ }
+
+ public class InertCommandQueueProxy : DispatchProxy
+ {
+ protected override object Invoke(MethodInfo targetMethod, object[] args)
+ {
+ return null;
+ }
+ }
+
+ private class FakeGrimmoryProxy : IGrimmoryProxy
+ {
+ public List Libraries { get; set; } = new List();
+ public List RefreshedLibraryIds { get; } = new List();
+ public HashSet FailingLibraryIds { get; } = new HashSet();
+
+ public List GetLibraries(GrimmorySettings settings)
+ {
+ return Libraries;
+ }
+
+ public void RefreshLibrary(GrimmorySettings settings, long libraryId)
+ {
+ if (FailingLibraryIds.Contains(libraryId))
+ {
+ throw new InvalidOperationException($"Refresh failed for library {libraryId}");
+ }
+
+ RefreshedLibraryIds.Add(libraryId);
+ }
+
+ public ValidationFailure Test(GrimmorySettings settings)
+ {
+ return null;
+ }
+
+ public GrimmoryBook FindBookByPath(GrimmorySettings settings, long libraryId, string relativePath, bool bypassCache = false) => null;
+ public void UpdateBookMetadata(GrimmorySettings settings, long bookId, Dictionary metadata) { }
+ public void UploadBookCover(GrimmorySettings settings, long bookId, byte[] image, string fileName) { }
+ public byte[] GetBookCover(GrimmorySettings settings, long bookId) => null;
+ public string BuildCoverUrl(GrimmorySettings settings, long bookId) => string.Empty;
+ }
+ }
+}
diff --git a/src/Chaptarr.Core.Test/Notifications/Grimmory/GrimmoryLibraryChangeForwarderFixture.cs b/src/Chaptarr.Core.Test/Notifications/Grimmory/GrimmoryLibraryChangeForwarderFixture.cs
new file mode 100644
index 00000000..001b2f7e
--- /dev/null
+++ b/src/Chaptarr.Core.Test/Notifications/Grimmory/GrimmoryLibraryChangeForwarderFixture.cs
@@ -0,0 +1,395 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using FluentValidation.Results;
+using NLog;
+using NUnit.Framework;
+using NzbDrone.Common.Cache;
+using NzbDrone.Core.Books;
+using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.Messaging.Commands;
+using NzbDrone.Core.Notifications;
+using NzbDrone.Core.Notifications.Grimmory;
+using NzbDrone.Core.RootFolders;
+
+namespace Chaptarr.Core.Test.Notifications.Grimmory
+{
+ [TestFixture]
+ public class GrimmoryLibraryChangeForwarderFixture
+ {
+ private const long EbookLibraryId = 3;
+ private const string RelativePath = "Robin Hobb/Assassin's Apprentice/Assassin's Apprentice.epub";
+
+ [SetUp]
+ public void Setup()
+ {
+ GrimmoryPushRegistry.Clear();
+ GrimmoryPushRegistry.EchoShadow = TimeSpan.Zero;
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ GrimmoryPushRegistry.EchoShadow = TimeSpan.FromSeconds(15);
+ }
+
+ public class StubProxy : DispatchProxy
+ {
+ public Dictionary> Handlers { get; } = new Dictionary>();
+
+ protected override object Invoke(MethodInfo targetMethod, object[] args)
+ {
+ if (Handlers.TryGetValue(targetMethod.Name, out var handler))
+ {
+ return handler(args);
+ }
+
+ throw new NotImplementedException($"Stub does not handle {targetMethod.Name}");
+ }
+ }
+
+ private static T Stub(out StubProxy stub)
+ {
+ var proxy = DispatchProxy.Create();
+ stub = (StubProxy)(object)proxy;
+ return proxy;
+ }
+
+ private class ScriptedGrimmoryProxy : IGrimmoryProxy
+ {
+ public Dictionary BooksByPath { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ public List<(long BookId, Dictionary Metadata)> MetadataUpdates { get; } = new List<(long, Dictionary)>();
+
+ public List GetLibraries(GrimmorySettings settings) => new List();
+ public void RefreshLibrary(GrimmorySettings settings, long libraryId) { }
+
+ public GrimmoryBook FindBookByPath(GrimmorySettings settings, long libraryId, string relativePath, bool bypassCache = false)
+ {
+ return BooksByPath.TryGetValue(relativePath.Replace('\\', '/'), out var book) ? book : null;
+ }
+
+ public void UpdateBookMetadata(GrimmorySettings settings, long bookId, Dictionary metadata) => MetadataUpdates.Add((bookId, metadata));
+ public void UploadBookCover(GrimmorySettings settings, long bookId, byte[] image, string fileName) { }
+ public byte[] GetBookCover(GrimmorySettings settings, long bookId) => new byte[] { 9 };
+ public string BuildCoverUrl(GrimmorySettings settings, long bookId) => $"http://grimmory/cover/{bookId}";
+ public ValidationFailure Test(GrimmorySettings settings) => null;
+ }
+
+ private class TestEditTarget : NotificationBase, IExternalLibraryEditTarget
+ {
+ public List<(Book Book, ExternalLibraryEditPayload Payload)> Pushes { get; } = new List<(Book, ExternalLibraryEditPayload)>();
+
+ public override string Name => "TestTarget";
+ public override string Link => string.Empty;
+ public bool AcceptsExternalLibraryEdits => true;
+
+ public void PushExternalLibraryEdit(Book book, List files, ExternalLibraryEditPayload payload)
+ {
+ Pushes.Add((book, payload));
+ }
+
+ public override ValidationResult Test()
+ {
+ return new ValidationResult();
+ }
+ }
+
+ private class Context
+ {
+ public ScriptedGrimmoryProxy Proxy;
+ public ScriptedGrimmoryProxy SiblingProxy;
+ public GrimmoryLibraryChangeForwarder Forwarder;
+ public TestEditTarget Target;
+ public string SidecarPath;
+ public string CoverSidecarPath;
+ }
+
+ private static GrimmoryBook BuildGrimmoryBook()
+ {
+ var slash = RelativePath.LastIndexOf('/');
+
+ return new GrimmoryBook
+ {
+ Id = 100,
+ LibraryId = EbookLibraryId,
+ PrimaryFile = new GrimmoryBookFile
+ {
+ FileSubPath = RelativePath.Substring(0, slash),
+ FileName = RelativePath.Substring(slash + 1)
+ },
+ Metadata = new GrimmoryBookMetadata
+ {
+ Title = "Assassin's Apprentice",
+ Description = "Edited in Grimmory.",
+ Publisher = "Voyager",
+ SeriesName = "Farseer",
+ SeriesNumber = 1,
+ Language = "eng",
+ Isbn13 = "9780007562252",
+ Categories = new List { "fantasy" }
+ }
+ };
+ }
+
+ private static Context CreateContext(int targetDefinitionId = 2, bool withSibling = false)
+ {
+ var context = new Context();
+ var proxy = new ScriptedGrimmoryProxy();
+ context.Proxy = proxy;
+
+ var settings = new GrimmorySettings
+ {
+ Url = "http://grimmory:6060",
+ Username = "chaptarr",
+ Password = "secret",
+ EbookLibraryId = EbookLibraryId,
+ ForwardEdits = true
+ };
+
+ var commandQueue = Stub(out var commandStub);
+ commandStub.Handlers["Push"] = _ => null;
+
+ var rootPath = @"C:\books".AsOsAgnostic();
+ var bookDir = Path.Combine(rootPath, "Robin Hobb", "Assassin's Apprentice");
+ var bookFilePath = Path.Combine(bookDir, "Assassin's Apprentice.epub");
+ context.SidecarPath = Path.Combine(bookDir, "Assassin's Apprentice.metadata.json");
+ context.CoverSidecarPath = Path.Combine(bookDir, "Assassin's Apprentice.cover.jpg");
+
+ var bookFile = new BookFile { Id = 40, EditionId = 30, Path = bookFilePath, MediaType = "ebook" };
+
+ var rootFolderService = Stub(out var rootStub);
+ rootStub.Handlers["All"] = _ => new List { new RootFolder { Id = 1, Path = rootPath } };
+ rootStub.Handlers["GetBestRootFolder"] = _ => new RootFolder { Id = 1, Path = rootPath };
+
+ var source = new NzbDrone.Core.Notifications.Grimmory.Grimmory(proxy, commandQueue, rootFolderService, new CacheManager(), LogManager.GetLogger("test"))
+ {
+ Definition = new NotificationDefinition { Id = 1, Name = "Grimmory", Settings = settings }
+ };
+
+ var target = new TestEditTarget
+ {
+ Definition = new NotificationDefinition { Id = targetDefinitionId, Name = "TestTarget", Settings = new GrimmorySettings() }
+ };
+ context.Target = target;
+
+ var providers = new List { source, target };
+
+ if (withSibling)
+ {
+ var siblingProxy = new ScriptedGrimmoryProxy();
+ context.SiblingProxy = siblingProxy;
+
+ var siblingSettings = new GrimmorySettings
+ {
+ Url = "http://grimmory-b:6060",
+ Username = "chaptarr",
+ Password = "secret",
+ EbookLibraryId = EbookLibraryId,
+ PushMetadata = true
+ };
+
+ providers.Add(new NzbDrone.Core.Notifications.Grimmory.Grimmory(siblingProxy, commandQueue, rootFolderService, new CacheManager(), LogManager.GetLogger("test"))
+ {
+ Definition = new NotificationDefinition { Id = 3, Name = "Grimmory B", Settings = siblingSettings }
+ });
+ }
+
+ var factory = Stub(out var factoryStub);
+ factoryStub.Handlers["GetAvailableProviders"] = _ => providers;
+
+ var mediaFileService = Stub(out var mediaFileStub);
+ mediaFileStub.Handlers["GetFilesWithBasePath"] = args => string.Equals((string)args[0], bookDir, StringComparison.OrdinalIgnoreCase)
+ ? new List { bookFile }
+ : new List();
+ mediaFileStub.Handlers["GetFilesByBook"] = _ => new List { bookFile };
+
+ var editionService = Stub(out var editionStub);
+ editionStub.Handlers["GetEdition"] = args => (int)args[0] == 30 ? new Edition { Id = 30, BookId = 10 } : null;
+
+ var bookService = Stub(out var bookStub);
+ bookStub.Handlers["GetBook"] = args => (int)args[0] == 10 ? new Book { Id = 10, Title = "Assassin's Apprentice", MediaType = BookMediaType.Ebook } : null;
+
+ context.Forwarder = new GrimmoryLibraryChangeForwarder(
+ factory,
+ proxy,
+ rootFolderService,
+ mediaFileService,
+ editionService,
+ bookService,
+ LogManager.GetLogger("GrimmoryLibraryChangeForwarderFixture"));
+
+ return context;
+ }
+
+ [Test]
+ public void should_forward_edit_signalled_by_metadata_sidecar()
+ {
+ var context = CreateContext();
+ context.Proxy.BooksByPath[RelativePath] = BuildGrimmoryBook();
+
+ context.Forwarder.QueueSidecar(context.SidecarPath);
+ context.Forwarder.ForwardPending();
+
+ Assert.That(context.Target.Pushes, Has.Count.EqualTo(1));
+
+ var (book, payload) = context.Target.Pushes[0];
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(book.Id, Is.EqualTo(10));
+ Assert.That(payload.Title, Is.EqualTo("Assassin's Apprentice"));
+ Assert.That(payload.Description, Is.EqualTo("Edited in Grimmory."));
+ Assert.That(payload.SeriesName, Is.EqualTo("Farseer"));
+ Assert.That(payload.Identifiers["isbn"], Is.EqualTo("9780007562252"));
+ Assert.That(payload.CoverBytes, Is.Not.Null);
+ });
+
+ context.Forwarder.Dispose();
+ }
+
+ [Test]
+ public void should_forward_edit_signalled_by_cover_sidecar()
+ {
+ var context = CreateContext();
+ context.Proxy.BooksByPath[RelativePath] = BuildGrimmoryBook();
+
+ context.Forwarder.QueueSidecar(context.CoverSidecarPath);
+ context.Forwarder.ForwardPending();
+
+ Assert.That(context.Target.Pushes, Has.Count.EqualTo(1));
+
+ context.Forwarder.Dispose();
+ }
+
+ [Test]
+ public void should_dedupe_metadata_and_cover_sidecars_for_same_book()
+ {
+ var context = CreateContext();
+ context.Proxy.BooksByPath[RelativePath] = BuildGrimmoryBook();
+
+ context.Forwarder.QueueSidecar(context.SidecarPath);
+ context.Forwarder.QueueSidecar(context.CoverSidecarPath);
+ context.Forwarder.ForwardPending();
+
+ Assert.That(context.Target.Pushes, Has.Count.EqualTo(1));
+
+ context.Forwarder.Dispose();
+ }
+
+ [Test]
+ public void should_not_forward_sidecar_written_after_chaptarrs_own_push()
+ {
+ var context = CreateContext();
+ context.Proxy.BooksByPath[RelativePath] = BuildGrimmoryBook();
+
+ GrimmoryPushRegistry.RecordPush(10);
+
+ context.Forwarder.QueueSidecar(context.SidecarPath);
+ context.Forwarder.ForwardPending();
+
+ Assert.That(context.Target.Pushes, Is.Empty);
+
+ context.Forwarder.Dispose();
+ }
+
+ [Test]
+ public void should_forward_edit_made_after_push_echo_was_consumed()
+ {
+ var context = CreateContext();
+ context.Proxy.BooksByPath[RelativePath] = BuildGrimmoryBook();
+
+ GrimmoryPushRegistry.RecordPush(10);
+
+ // Grimmory rewriting the sidecar in response to Chaptarr's own push - suppressed.
+ context.Forwarder.QueueSidecar(context.SidecarPath);
+ context.Forwarder.ForwardPending();
+ Assert.That(context.Target.Pushes, Is.Empty);
+
+ // A person's edit right after - the push entry is spent, so this forwards.
+ context.Forwarder.QueueSidecar(context.SidecarPath);
+ context.Forwarder.ForwardPending();
+ Assert.That(context.Target.Pushes, Has.Count.EqualTo(1));
+
+ context.Forwarder.Dispose();
+ }
+
+ [Test]
+ public void should_forward_edit_coalesced_into_the_same_batch_as_a_push_echo()
+ {
+ var context = CreateContext();
+ context.Proxy.BooksByPath[RelativePath] = BuildGrimmoryBook();
+
+ GrimmoryPushRegistry.RecordPush(10);
+
+ // The push's echo and a person's edit land inside one debounce window: the echo
+ // is dropped at arrival, so the edit still comes out of the shared batch.
+ context.Forwarder.QueueSidecar(context.SidecarPath);
+ context.Forwarder.QueueSidecar(context.SidecarPath);
+ context.Forwarder.ForwardPending();
+
+ Assert.That(context.Target.Pushes, Has.Count.EqualTo(1));
+
+ context.Forwarder.Dispose();
+ }
+
+ [Test]
+ public void should_ignore_sidecar_without_matching_book_file()
+ {
+ var context = CreateContext();
+ context.Proxy.BooksByPath[RelativePath] = BuildGrimmoryBook();
+
+ context.Forwarder.QueueSidecar(Path.Combine(@"C:\books".AsOsAgnostic(), "Unknown", "Unknown.metadata.json"));
+ context.Forwarder.ForwardPending();
+
+ Assert.That(context.Target.Pushes, Is.Empty);
+
+ context.Forwarder.Dispose();
+ }
+
+ [Test]
+ public void should_forward_edit_to_sibling_grimmory_connection()
+ {
+ var context = CreateContext(withSibling: true);
+ context.Proxy.BooksByPath[RelativePath] = BuildGrimmoryBook();
+ context.SiblingProxy.BooksByPath[RelativePath] = new GrimmoryBook
+ {
+ Id = 500,
+ LibraryId = EbookLibraryId,
+ PrimaryFile = BuildGrimmoryBook().PrimaryFile
+ };
+
+ context.Forwarder.QueueSidecar(context.SidecarPath);
+ context.Forwarder.ForwardPending();
+
+ Assert.That(context.SiblingProxy.MetadataUpdates, Has.Count.EqualTo(1));
+
+ var (bookId, metadata) = context.SiblingProxy.MetadataUpdates[0];
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(bookId, Is.EqualTo(500));
+ Assert.That(metadata["description"], Is.EqualTo("Edited in Grimmory."));
+ Assert.That(metadata["seriesName"], Is.EqualTo("Farseer"));
+ Assert.That(context.Proxy.MetadataUpdates, Is.Empty, "the source instance must not receive its own edit back");
+ });
+
+ context.Forwarder.Dispose();
+ }
+
+ [Test]
+ public void should_skip_target_sharing_the_sources_definition()
+ {
+ var context = CreateContext(targetDefinitionId: 1);
+ context.Proxy.BooksByPath[RelativePath] = BuildGrimmoryBook();
+
+ context.Forwarder.QueueSidecar(context.SidecarPath);
+ context.Forwarder.ForwardPending();
+
+ Assert.That(context.Target.Pushes, Is.Empty);
+
+ context.Forwarder.Dispose();
+ }
+ }
+}
diff --git a/src/Chaptarr.Core.Test/Notifications/Grimmory/GrimmoryProxyFixture.cs b/src/Chaptarr.Core.Test/Notifications/Grimmory/GrimmoryProxyFixture.cs
new file mode 100644
index 00000000..7e2d03a3
--- /dev/null
+++ b/src/Chaptarr.Core.Test/Notifications/Grimmory/GrimmoryProxyFixture.cs
@@ -0,0 +1,269 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Threading.Tasks;
+using NLog;
+using NUnit.Framework;
+using NzbDrone.Common.Cache;
+using NzbDrone.Common.Http;
+using NzbDrone.Core.Notifications.Grimmory;
+
+namespace Chaptarr.Core.Test.Notifications.Grimmory
+{
+ [TestFixture]
+ public class GrimmoryProxyFixture
+ {
+ private const string LibrariesJson = "[{\"id\":10,\"name\":\"Ebooks\",\"allowedFormats\":[\"EPUB\",\"PDF\"]},{\"id\":20,\"name\":\"Audiobooks\",\"allowedFormats\":[\"AUDIOBOOK\"]}]";
+ private const string LibraryBooksJson = "[{\"id\":100,\"libraryId\":10,\"primaryFile\":{\"fileName\":\"Book One.epub\",\"fileSubPath\":\"Author Name/Book One\"},\"metadata\":{\"title\":\"Book One\"}}]";
+
+ [Test]
+ public void should_login_and_fetch_libraries_with_bearer_token()
+ {
+ var httpClient = new ScriptedHttpClient { ValidTokens = { "token1" } };
+ var proxy = CreateProxy(httpClient);
+
+ var libraries = proxy.GetLibraries(BuildSettings());
+
+ Assert.That(libraries, Has.Count.EqualTo(2));
+ Assert.That(libraries[0].Id, Is.EqualTo(10));
+ Assert.That(libraries[0].Name, Is.EqualTo("Ebooks"));
+ Assert.That(libraries[0].AllowedFormats, Is.EqualTo(new List { "EPUB", "PDF" }));
+ Assert.That(httpClient.LoginCount, Is.EqualTo(1));
+
+ var libraryRequest = httpClient.Requests.Last();
+ Assert.That(libraryRequest.Url.ToString(), Does.EndWith("/api/v1/libraries"));
+ Assert.That(libraryRequest.Headers["Authorization"], Is.EqualTo("Bearer token1"));
+ }
+
+ [Test]
+ public void should_reuse_cached_token_across_calls()
+ {
+ var httpClient = new ScriptedHttpClient { ValidTokens = { "token1" } };
+ var proxy = CreateProxy(httpClient);
+ var settings = BuildSettings();
+
+ proxy.GetLibraries(settings);
+ proxy.RefreshLibrary(settings, 10);
+
+ Assert.That(httpClient.LoginCount, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void should_relogin_once_when_token_is_rejected()
+ {
+ var httpClient = new ScriptedHttpClient { ValidTokens = { "token2" } };
+ var proxy = CreateProxy(httpClient);
+
+ var libraries = proxy.GetLibraries(BuildSettings());
+
+ Assert.That(libraries, Has.Count.EqualTo(2));
+ Assert.That(httpClient.LoginCount, Is.EqualTo(2));
+ }
+
+ [Test]
+ public void should_throw_authentication_exception_when_relogin_still_rejected()
+ {
+ var httpClient = new ScriptedHttpClient();
+ var proxy = CreateProxy(httpClient);
+
+ Assert.Throws(() => proxy.GetLibraries(BuildSettings()));
+ Assert.That(httpClient.LoginCount, Is.EqualTo(2));
+ }
+
+ [Test]
+ public void should_throw_authentication_exception_when_login_is_rejected()
+ {
+ var httpClient = new ScriptedHttpClient { RejectLogin = true };
+ var proxy = CreateProxy(httpClient);
+
+ Assert.Throws(() => proxy.GetLibraries(BuildSettings()));
+ }
+
+ [Test]
+ public void should_send_put_to_refresh_endpoint()
+ {
+ var httpClient = new ScriptedHttpClient { ValidTokens = { "token1" } };
+ var proxy = CreateProxy(httpClient);
+
+ proxy.RefreshLibrary(BuildSettings(), 20);
+
+ var refreshRequest = httpClient.Requests.Last();
+ Assert.That(refreshRequest.Method, Is.EqualTo(HttpMethod.Put));
+ Assert.That(refreshRequest.Url.ToString(), Does.EndWith("/api/v1/libraries/20/refresh"));
+ }
+
+ [Test]
+ public void test_should_fail_when_configured_library_is_missing()
+ {
+ var httpClient = new ScriptedHttpClient { ValidTokens = { "token1" } };
+ var proxy = CreateProxy(httpClient);
+
+ var settings = BuildSettings();
+ settings.EbookLibraryId = 99;
+
+ var failure = proxy.Test(settings);
+
+ Assert.That(failure, Is.Not.Null);
+ Assert.That(failure.PropertyName, Is.EqualTo(nameof(GrimmorySettings.EbookLibraryId)));
+ }
+
+ [Test]
+ public void test_should_pass_when_configured_libraries_exist()
+ {
+ var httpClient = new ScriptedHttpClient { ValidTokens = { "token1" } };
+ var proxy = CreateProxy(httpClient);
+
+ Assert.That(proxy.Test(BuildSettings()), Is.Null);
+ }
+
+ [Test]
+ public void should_find_book_by_path_ignoring_slash_direction_and_case()
+ {
+ var httpClient = new ScriptedHttpClient { ValidTokens = { "token1" } };
+ var proxy = CreateProxy(httpClient);
+
+ var book = proxy.FindBookByPath(BuildSettings(), 10, "Author Name\\book one\\Book One.epub");
+
+ Assert.That(book, Is.Not.Null);
+ Assert.That(book.Id, Is.EqualTo(100));
+ }
+
+ [Test]
+ public void should_send_metadata_update_with_replace_when_provided_mode()
+ {
+ var httpClient = new ScriptedHttpClient { ValidTokens = { "token1" } };
+ var proxy = CreateProxy(httpClient);
+
+ proxy.UpdateBookMetadata(BuildSettings(), 100, new Dictionary { { "title", "New Title" }, { "titleLocked", true } });
+
+ var request = httpClient.Requests.Last();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(request.Method, Is.EqualTo(HttpMethod.Put));
+ Assert.That(request.Url.ToString(), Does.Contain("/api/v1/books/100/metadata"));
+ Assert.That(request.Url.ToString(), Does.Contain("replaceMode=REPLACE_WHEN_PROVIDED"));
+
+ var body = System.Text.Encoding.UTF8.GetString(request.ContentData);
+ Assert.That(body, Does.Contain("\"metadata\""));
+ Assert.That(body, Does.Contain("\"titleLocked\": true").Or.Contain("\"titleLocked\":true"));
+ });
+ }
+
+ [Test]
+ public void should_upload_cover_as_multipart_file()
+ {
+ var httpClient = new ScriptedHttpClient { ValidTokens = { "token1" } };
+ var proxy = CreateProxy(httpClient);
+
+ proxy.UploadBookCover(BuildSettings(), 100, new byte[] { 1, 2, 3 }, "cover.jpg");
+
+ var request = httpClient.Requests.Last();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(request.Method, Is.EqualTo(HttpMethod.Post));
+ Assert.That(request.Url.ToString(), Does.EndWith("/api/v1/books/100/metadata/cover/upload"));
+ Assert.That(request.Headers.ContentType, Does.Contain("multipart/form-data"));
+ });
+ }
+
+ private static GrimmoryProxy CreateProxy(ScriptedHttpClient httpClient)
+ {
+ return new GrimmoryProxy(httpClient, new CacheManager(), LogManager.GetLogger("GrimmoryProxyFixture"));
+ }
+
+ private static GrimmorySettings BuildSettings()
+ {
+ return new GrimmorySettings
+ {
+ Url = "http://grimmory:6060",
+ Username = "chaptarr",
+ Password = "secret",
+ EbookLibraryId = 10,
+ AudiobookLibraryId = 20
+ };
+ }
+
+ private class ScriptedHttpClient : IHttpClient
+ {
+ public List Requests { get; } = new List();
+ public HashSet ValidTokens { get; } = new HashSet();
+ public bool RejectLogin { get; set; }
+ public int LoginCount { get; private set; }
+
+ public HttpResponse Execute(HttpRequest request)
+ {
+ Requests.Add(request);
+
+ var url = request.Url.ToString();
+ var headers = new HttpHeader { ContentType = "application/json" };
+
+ if (url.EndsWith("/api/v1/auth/login"))
+ {
+ LoginCount++;
+
+ if (RejectLogin)
+ {
+ return new HttpResponse(request, headers, string.Empty, HttpStatusCode.Unauthorized);
+ }
+
+ // The first login hands out token1, the second token2, and so on. Which of
+ // them the server still accepts is controlled per-test via ValidTokens.
+ return new HttpResponse(request, headers, $"{{\"accessToken\":\"token{LoginCount}\"}}");
+ }
+
+ var authorization = request.Headers["Authorization"];
+
+ if (authorization == null || !ValidTokens.Contains(authorization.Replace("Bearer ", string.Empty)))
+ {
+ return new HttpResponse(request, headers, string.Empty, HttpStatusCode.Unauthorized);
+ }
+
+ if (url.EndsWith("/api/v1/libraries"))
+ {
+ return new HttpResponse(request, headers, LibrariesJson);
+ }
+
+ if (url.Contains("/api/v1/libraries/") && url.EndsWith("/refresh"))
+ {
+ return new HttpResponse(request, headers, string.Empty, HttpStatusCode.NoContent);
+ }
+
+ if (url.Contains("/api/v1/libraries/") && url.EndsWith("/book"))
+ {
+ return new HttpResponse(request, headers, LibraryBooksJson);
+ }
+
+ if (url.Contains("/metadata/cover/upload"))
+ {
+ return new HttpResponse(request, headers, string.Empty);
+ }
+
+ if (url.Contains("/api/v1/books/") && url.Contains("/metadata"))
+ {
+ return new HttpResponse(request, headers, "{}");
+ }
+
+ return new HttpResponse(request, headers, string.Empty, HttpStatusCode.NotFound);
+ }
+
+ public HttpResponse Get(HttpRequest request) => Execute(request);
+
+ public void DownloadFile(string url, string fileName, string userAgent = null) => throw new NotImplementedException();
+ public HttpResponse Get(HttpRequest request) where T : new() => throw new NotImplementedException();
+ public HttpResponse Head(HttpRequest request) => throw new NotImplementedException();
+ public HttpResponse Post(HttpRequest request) => throw new NotImplementedException();
+ public HttpResponse Post(HttpRequest request) where T : new() => throw new NotImplementedException();
+ public Task ExecuteAsync(HttpRequest request) => throw new NotImplementedException();
+ public Task DownloadFileAsync(string url, string fileName, string userAgent = null) => throw new NotImplementedException();
+ public Task GetAsync(HttpRequest request) => throw new NotImplementedException();
+ public Task> GetAsync(HttpRequest request) where T : new() => throw new NotImplementedException();
+ public Task HeadAsync(HttpRequest request) => throw new NotImplementedException();
+ public Task PostAsync(HttpRequest request) => throw new NotImplementedException();
+ public Task> PostAsync(HttpRequest request) where T : new() => throw new NotImplementedException();
+ }
+ }
+}
diff --git a/src/Chaptarr.Core.Test/Notifications/Grimmory/GrimmoryPushServiceFixture.cs b/src/Chaptarr.Core.Test/Notifications/Grimmory/GrimmoryPushServiceFixture.cs
new file mode 100644
index 00000000..98084343
--- /dev/null
+++ b/src/Chaptarr.Core.Test/Notifications/Grimmory/GrimmoryPushServiceFixture.cs
@@ -0,0 +1,471 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using FluentValidation.Results;
+using NLog;
+using NUnit.Framework;
+using NzbDrone.Common.Cache;
+using NzbDrone.Core.Books;
+using NzbDrone.Core.MediaCover;
+using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.Messaging.Commands;
+using NzbDrone.Core.Notifications;
+using NzbDrone.Core.Notifications.Grimmory;
+using NzbDrone.Core.RootFolders;
+
+namespace Chaptarr.Core.Test.Notifications.Grimmory
+{
+ [TestFixture]
+ public class GrimmoryPushServiceFixture
+ {
+ private const long EbookLibraryId = 3;
+ private const long AudiobookLibraryId = 4;
+
+ [SetUp]
+ public void Setup()
+ {
+ GrimmoryPushRegistry.Clear();
+ }
+
+ public class StubProxy : DispatchProxy
+ {
+ public Dictionary> Handlers { get; } = new Dictionary>();
+
+ protected override object Invoke(MethodInfo targetMethod, object[] args)
+ {
+ if (Handlers.TryGetValue(targetMethod.Name, out var handler))
+ {
+ return handler(args);
+ }
+
+ throw new NotImplementedException($"Stub does not handle {targetMethod.Name}");
+ }
+ }
+
+ private static T Stub(out StubProxy stub)
+ {
+ var proxy = DispatchProxy.Create();
+ stub = (StubProxy)(object)proxy;
+ return proxy;
+ }
+
+ private class FakeGrimmoryProxy : IGrimmoryProxy
+ {
+ public Dictionary BooksByPath { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ public List<(long BookId, Dictionary Metadata)> MetadataUpdates { get; } = new List<(long, Dictionary)>();
+ public List<(long BookId, string FileName)> CoverUploads { get; } = new List<(long, string)>();
+
+ public List GetLibraries(GrimmorySettings settings) => new List();
+ public void RefreshLibrary(GrimmorySettings settings, long libraryId) { }
+
+ public GrimmoryBook FindBookByPath(GrimmorySettings settings, long libraryId, string relativePath, bool bypassCache = false)
+ {
+ return BooksByPath.TryGetValue(relativePath.Replace('\\', '/'), out var book) ? book : null;
+ }
+
+ public void UpdateBookMetadata(GrimmorySettings settings, long bookId, Dictionary metadata) => MetadataUpdates.Add((bookId, metadata));
+ public void UploadBookCover(GrimmorySettings settings, long bookId, byte[] image, string fileName) => CoverUploads.Add((bookId, fileName));
+ public byte[] GetBookCover(GrimmorySettings settings, long bookId) => null;
+ public string BuildCoverUrl(GrimmorySettings settings, long bookId) => $"http://grimmory/cover/{bookId}";
+ public ValidationFailure Test(GrimmorySettings settings) => null;
+ }
+
+ private class TestEditTarget : NotificationBase, IExternalLibraryEditTarget
+ {
+ public List<(Book Book, ExternalLibraryEditPayload Payload)> Pushes { get; } = new List<(Book, ExternalLibraryEditPayload)>();
+
+ public override string Name => "TestTarget";
+ public override string Link => string.Empty;
+ public bool AcceptsExternalLibraryEdits => true;
+
+ public void PushExternalLibraryEdit(Book book, List files, ExternalLibraryEditPayload payload)
+ {
+ Pushes.Add((book, payload));
+ }
+
+ public override ValidationResult Test()
+ {
+ return new ValidationResult();
+ }
+ }
+
+ private class Context
+ {
+ public FakeGrimmoryProxy Proxy;
+ public GrimmoryPushService Service;
+ public List PushedCommands = new List();
+ public GrimmorySettings Settings;
+ public TestEditTarget Target;
+ }
+
+ private static Context CreateContext(bool pushMetadata = true, bool pushCovers = true, string coverPath = null)
+ {
+ var context = new Context();
+ var proxy = new FakeGrimmoryProxy();
+ context.Proxy = proxy;
+
+ var settings = new GrimmorySettings
+ {
+ Url = "http://grimmory:6060",
+ Username = "chaptarr",
+ Password = "secret",
+ EbookLibraryId = EbookLibraryId,
+ AudiobookLibraryId = AudiobookLibraryId,
+ PushMetadata = pushMetadata,
+ PushCovers = pushCovers
+ };
+ context.Settings = settings;
+
+ var commandQueue = Stub(out var commandStub);
+ commandStub.Handlers["Push"] = args =>
+ {
+ context.PushedCommands.Add((Command)args[0]);
+ return null;
+ };
+
+ var provider = new NzbDrone.Core.Notifications.Grimmory.Grimmory(proxy, commandQueue, null, new CacheManager(), LogManager.GetLogger("test"))
+ {
+ Definition = new NotificationDefinition { Id = 1, Name = "Grimmory", Settings = settings }
+ };
+
+ context.Target = new TestEditTarget
+ {
+ Definition = new NotificationDefinition { Id = 2, Name = "TestTarget", Settings = new GrimmorySettings() }
+ };
+
+ var factory = Stub(out var factoryStub);
+ factoryStub.Handlers["GetAvailableProviders"] = _ => new List { provider, context.Target };
+
+ var book = new Book
+ {
+ Id = 10,
+ AuthorId = 20,
+ Title = "Assassin's Apprentice",
+ Overview = "A royal bastard trains as an assassin.",
+ MediaType = BookMediaType.Ebook,
+ Genres = new List { "fantasy" }
+ };
+
+ var bookService = Stub(out var bookStub);
+ bookStub.Handlers["GetBook"] = args => (int)args[0] == 10 ? book : null;
+
+ var authorService = Stub(out var authorStub);
+ authorStub.Handlers["GetAuthor"] = _ => new Author { Id = 20, Name = "Robin Hobb" };
+
+ var edition = new Edition
+ {
+ Id = 30,
+ BookId = 10,
+ Title = "Assassin's Apprentice",
+ Overview = "Edition overview.",
+ Publisher = "Voyager",
+ Language = "eng",
+ Isbn13 = "9780007562252",
+ Monitored = true,
+ Images = new List { new NzbDrone.Core.MediaCover.MediaCover(MediaCoverTypes.Cover, "http://x/cover.jpg") }
+ };
+
+ var editionService = Stub(out var editionStub);
+ editionStub.Handlers["GetEditionsByBook"] = _ => new List { edition };
+
+ var mediaFileService = Stub(out var mediaFileStub);
+ mediaFileStub.Handlers["GetFilesByBook"] = _ => new List
+ {
+ new BookFile { Id = 40, EditionId = 30, Path = @"C:\books\Robin Hobb\Assassin's Apprentice\Assassin's Apprentice.epub".AsOsAgnostic(), MediaType = "ebook" }
+ };
+
+ var rootFolderService = Stub(out var rootStub);
+ rootStub.Handlers["GetBestRootFolder"] = _ => new RootFolder { Id = 1, Path = @"C:\books".AsOsAgnostic() };
+
+ var coverMapper = Stub(out var coverStub);
+ coverStub.Handlers["GetCoverPath"] = _ => coverPath ?? @"C:\nonexistent\cover.jpg".AsOsAgnostic();
+
+ context.Service = new GrimmoryPushService(
+ factory,
+ proxy,
+ bookService,
+ authorService,
+ editionService,
+ mediaFileService,
+ rootFolderService,
+ coverMapper,
+ commandQueue,
+ new CacheManager(),
+ LogManager.GetLogger("GrimmoryPushServiceFixture"));
+
+ return context;
+ }
+
+ private static GrimmoryBook GrimmoryBookAt(string relativePath, long id = 100)
+ {
+ var slash = relativePath.LastIndexOf('/');
+
+ return new GrimmoryBook
+ {
+ Id = id,
+ LibraryId = EbookLibraryId,
+ PrimaryFile = new GrimmoryBookFile
+ {
+ FileSubPath = slash > 0 ? relativePath.Substring(0, slash) : string.Empty,
+ FileName = relativePath.Substring(slash + 1)
+ }
+ };
+ }
+
+ [Test]
+ public void should_push_metadata_with_locks_to_matched_book()
+ {
+ var context = CreateContext();
+ context.Proxy.BooksByPath["Robin Hobb/Assassin's Apprentice/Assassin's Apprentice.epub"] = GrimmoryBookAt("Robin Hobb/Assassin's Apprentice/Assassin's Apprentice.epub");
+
+ context.Service.Execute(new PushGrimmoryMetadataCommand
+ {
+ BookIds = new List { 10 },
+ Fields = new List { "title", "description", "authors", "identifiers", "tags" }
+ });
+
+ Assert.That(context.Proxy.MetadataUpdates, Has.Count.EqualTo(1));
+
+ var (bookId, metadata) = context.Proxy.MetadataUpdates[0];
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(bookId, Is.EqualTo(100));
+ Assert.That(metadata["title"], Is.EqualTo("Assassin's Apprentice"));
+ Assert.That(metadata["titleLocked"], Is.True);
+ Assert.That(metadata["description"], Is.EqualTo("Edition overview."));
+ Assert.That(metadata["authors"], Is.EqualTo(new List { "Robin Hobb" }));
+ Assert.That(metadata["authorsLocked"], Is.True);
+ Assert.That(metadata["categories"], Is.EqualTo(new List { "fantasy" }));
+ Assert.That(metadata["categoriesLocked"], Is.True);
+ Assert.That(metadata["isbn13"], Is.EqualTo("9780007562252"));
+ Assert.That(metadata["isbn13Locked"], Is.True);
+ Assert.That(metadata.ContainsKey("publisher"), Is.False);
+ });
+ }
+
+ [Test]
+ public void should_record_push_in_registry_for_echo_suppression()
+ {
+ var context = CreateContext();
+ context.Proxy.BooksByPath["Robin Hobb/Assassin's Apprentice/Assassin's Apprentice.epub"] = GrimmoryBookAt("Robin Hobb/Assassin's Apprentice/Assassin's Apprentice.epub");
+
+ context.Service.Execute(new PushGrimmoryMetadataCommand
+ {
+ BookIds = new List { 10 },
+ Fields = new List { "title" }
+ });
+
+ Assert.That(GrimmoryPushRegistry.WasRecentlyPushed(10), Is.True);
+ Assert.That(GrimmoryPushRegistry.WasRecentlyPushed(11), Is.False);
+ }
+
+ [Test]
+ public void should_not_record_push_in_registry_when_nothing_was_sent()
+ {
+ var context = CreateContext();
+ context.Proxy.BooksByPath["Robin Hobb/Assassin's Apprentice/Assassin's Apprentice.epub"] = GrimmoryBookAt("Robin Hobb/Assassin's Apprentice/Assassin's Apprentice.epub");
+
+ context.Service.Execute(new PushGrimmoryMetadataCommand
+ {
+ BookIds = new List { 10 },
+ Fields = new List { "cover" }
+ });
+
+ Assert.That(GrimmoryPushRegistry.WasRecentlyPushed(10), Is.False);
+ }
+
+ [Test]
+ public void should_leave_a_locked_cover_alone()
+ {
+ var coverFile = Path.GetTempFileName();
+ File.WriteAllBytes(coverFile, new byte[] { 1, 2, 3 });
+
+ try
+ {
+ var context = CreateContext(coverPath: coverFile);
+ var grimmoryBook = GrimmoryBookAt("Robin Hobb/Assassin's Apprentice/Assassin's Apprentice.epub");
+ grimmoryBook.Metadata = new GrimmoryBookMetadata { CoverLocked = true };
+ context.Proxy.BooksByPath["Robin Hobb/Assassin's Apprentice/Assassin's Apprentice.epub"] = grimmoryBook;
+
+ context.Service.Execute(new PushGrimmoryMetadataCommand
+ {
+ BookIds = new List { 10 },
+ Fields = new List { "cover" }
+ });
+
+ Assert.That(context.Proxy.CoverUploads, Is.Empty);
+ Assert.That(context.Proxy.MetadataUpdates, Is.Empty);
+ }
+ finally
+ {
+ File.Delete(coverFile);
+ }
+ }
+
+ [Test]
+ public void should_mirror_push_to_other_edit_targets()
+ {
+ var coverFile = Path.GetTempFileName();
+ File.WriteAllBytes(coverFile, new byte[] { 1, 2, 3 });
+
+ try
+ {
+ var context = CreateContext(coverPath: coverFile);
+ context.Proxy.BooksByPath["Robin Hobb/Assassin's Apprentice/Assassin's Apprentice.epub"] = GrimmoryBookAt("Robin Hobb/Assassin's Apprentice/Assassin's Apprentice.epub");
+
+ context.Service.Execute(new PushGrimmoryMetadataCommand
+ {
+ BookIds = new List { 10 },
+ Fields = new List { "description", "publisher", "tags", "cover" }
+ });
+
+ Assert.That(context.Target.Pushes, Has.Count.EqualTo(1));
+
+ var payload = context.Target.Pushes[0].Payload;
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(payload.Description, Is.EqualTo("Edition overview."));
+ Assert.That(payload.Publisher, Is.EqualTo("Voyager"));
+ Assert.That(payload.Genres, Is.EqualTo(new List { "fantasy" }));
+ Assert.That(payload.CoverBytes, Is.EqualTo(new byte[] { 1, 2, 3 }));
+ Assert.That(payload.Title, Is.Null);
+ });
+ }
+ finally
+ {
+ File.Delete(coverFile);
+ }
+ }
+
+ [Test]
+ public void should_not_mirror_push_when_nothing_was_pushed_to_grimmory()
+ {
+ var context = CreateContext();
+
+ context.Service.Execute(new PushGrimmoryMetadataCommand
+ {
+ BookIds = new List { 10 },
+ Fields = new List { "title" }
+ });
+
+ Assert.That(context.Target.Pushes, Is.Empty);
+ }
+
+ [Test]
+ public void should_skip_when_book_not_found_in_grimmory()
+ {
+ var context = CreateContext();
+
+ context.Service.Execute(new PushGrimmoryMetadataCommand
+ {
+ BookIds = new List { 10 },
+ Fields = new List { "title" }
+ });
+
+ Assert.That(context.Proxy.MetadataUpdates, Is.Empty);
+ }
+
+ [Test]
+ public void should_upload_cover_when_cover_field_selected_and_file_exists()
+ {
+ var coverFile = Path.GetTempFileName();
+ File.WriteAllBytes(coverFile, new byte[] { 1, 2, 3 });
+
+ try
+ {
+ var context = CreateContext(coverPath: coverFile);
+ context.Proxy.BooksByPath["Robin Hobb/Assassin's Apprentice/Assassin's Apprentice.epub"] = GrimmoryBookAt("Robin Hobb/Assassin's Apprentice/Assassin's Apprentice.epub");
+
+ context.Service.Execute(new PushGrimmoryMetadataCommand
+ {
+ BookIds = new List { 10 },
+ Fields = new List { "cover" }
+ });
+
+ Assert.That(context.Proxy.CoverUploads, Has.Count.EqualTo(1));
+ Assert.That(context.Proxy.MetadataUpdates, Has.Count.EqualTo(1));
+ Assert.That(context.Proxy.MetadataUpdates[0].Metadata.Keys, Is.EqualTo(new[] { "coverLocked" }));
+ }
+ finally
+ {
+ File.Delete(coverFile);
+ }
+ }
+
+ [Test]
+ public void should_skip_cover_upload_when_cover_file_missing()
+ {
+ var context = CreateContext();
+ context.Proxy.BooksByPath["Robin Hobb/Assassin's Apprentice/Assassin's Apprentice.epub"] = GrimmoryBookAt("Robin Hobb/Assassin's Apprentice/Assassin's Apprentice.epub");
+
+ context.Service.Execute(new PushGrimmoryMetadataCommand
+ {
+ BookIds = new List { 10 },
+ Fields = new List { "cover" }
+ });
+
+ Assert.That(context.Proxy.CoverUploads, Is.Empty);
+ }
+
+ [Test]
+ public void should_queue_push_command_for_book_scoped_cover_event()
+ {
+ var context = CreateContext();
+
+ context.Service.Handle(new MediaCoversUpdatedEvent(new Book { Id = 10 }));
+
+ Assert.That(context.PushedCommands.OfType().Count(), Is.EqualTo(1));
+
+ var command = context.PushedCommands.OfType().Single();
+ Assert.That(command.BookIds, Is.EqualTo(new List { 10 }));
+ Assert.That(command.Fields, Does.Contain("cover"));
+ Assert.That(command.Fields, Does.Contain("title"));
+ }
+
+ [Test]
+ public void should_not_queue_push_for_author_scoped_cover_event()
+ {
+ var context = CreateContext();
+
+ context.Service.Handle(new MediaCoversUpdatedEvent(new Author { Id = 20 }));
+
+ Assert.That(context.PushedCommands, Is.Empty);
+ }
+
+ [Test]
+ public void should_not_queue_push_when_toggles_disabled()
+ {
+ var context = CreateContext(pushMetadata: false, pushCovers: false);
+
+ context.Service.Handle(new MediaCoversUpdatedEvent(new Book { Id = 10 }));
+
+ Assert.That(context.PushedCommands, Is.Empty);
+ }
+
+ [Test]
+ public void should_dedupe_repeated_cover_events_for_same_book()
+ {
+ var context = CreateContext();
+
+ context.Service.Handle(new MediaCoversUpdatedEvent(new Book { Id = 10 }));
+ context.Service.Handle(new MediaCoversUpdatedEvent(new Book { Id = 10 }));
+
+ Assert.That(context.PushedCommands, Has.Count.EqualTo(1));
+ }
+
+ [Test]
+ public void toggle_fields_should_reflect_settings()
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(GrimmoryPushService.ToggleFields(new GrimmorySettings { PushCovers = true }), Is.EqualTo(new List { "cover" }));
+ Assert.That(GrimmoryPushService.ToggleFields(new GrimmorySettings { PushMetadata = true }), Does.Not.Contain("cover"));
+ Assert.That(GrimmoryPushService.ToggleFields(new GrimmorySettings()), Is.Empty);
+ });
+ }
+ }
+}
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 922e7af4..e7a02678 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -108,6 +108,13 @@
+
+
+
+
+
diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json
index 12c5600a..1af42ecc 100644
--- a/src/NzbDrone.Core/Localization/Core/en.json
+++ b/src/NzbDrone.Core/Localization/Core/en.json
@@ -442,6 +442,7 @@
"CountIndexersSelected": "{selectedCount} indexer(s) selected",
"CountMore": "{count} more",
"Country": "Country",
+ "Cover": "Cover",
"CreateEmptyAuthorFolders": "Create empty author folders",
"CreateEmptyAuthorFoldersHelpText": "Create missing author folders during disk scan",
"CreateGroup": "Create group",
@@ -828,6 +829,8 @@
"GrabReleaseMessageText": "Chaptarr was unable to determine which author and book this release was for. Chaptarr may be unable to automatically import this release. Do you want to grab '{0}'?",
"GrabSelected": "Download Now",
"GraphicAudio": "Graphic Audio",
+ "GrimmoryPush": "Grimmory Push",
+ "GrimmoryPushDescriptionInterp": "Choose which fields to push to Grimmory for {0} book(s). Pushed fields are locked in Grimmory so its own metadata refreshes do not overwrite them; anything left unticked is untouched.",
"Group": "Group",
"Hardcover": "Hardcover",
"HardcoverApiKeyPlaceholder": "Paste your Hardcover API key here",
@@ -857,6 +860,7 @@
"ISBN": "ISBN",
"IconForCutoffUnmet": "Icon for Cutoff Unmet",
"IconTooltip": "Scheduled",
+ "Identifiers": "Identifiers",
"IfYouDontAddAnImportListExclusionAndTheAuthorHasAMetadataProfileOtherThanNoneThenThisBookMayBeReaddedDuringTheNextAuthorRefresh": "If you don't add an import list exclusion and the author has a metadata profile other than 'None' then this book may be re-added during the next author refresh.",
"IgnoreDeletedBooks": "Ignore Deleted Books",
"IgnoreDownload": "Ignore Download",
@@ -1503,6 +1507,8 @@
"Publisher": "Publisher",
"PurgeAndReaddAuthor": "Purge & Re-add Author",
"PurgeAndReaddAuthorHelpText": "Remove all database records for this author and immediately re-add and refresh from scratch. No files on disk will be touched.",
+ "PushChaptarrMetadataToGrimmory": "Push Chaptarr metadata to Grimmory",
+ "PushToGrimmory": "Push to Grimmory",
"Qualities": "Qualities",
"Quality": "Quality",
"QualityCriteriaAllowUpgrades": "Allow Upgrades",
@@ -1531,6 +1537,8 @@
"QuickstartCustomFormatsTitle": "3. Custom Formats (Optional)",
"QuickstartDownloadClientsDescription": "Configure download clients to handle your audiobook downloads. Download clients manage torrent and usenet downloads.",
"QuickstartDownloadClientsTitle": "2. Download Clients",
+ "QuickstartGrimmoryConnectDescription": "Connect to Grimmory so Chaptarr can refresh its libraries after imports, renames and deletes, push metadata and covers, and forward edits made in Grimmory to other connections.",
+ "QuickstartGrimmoryConnectHeader": "Connect Grimmory",
"QuickstartHardcoverConnectDescription": "Connect to Hardcover to enable direct metadata searching and library-based features.",
"QuickstartMamAddMyAnonaMouse": "Add MyAnonaMouse",
"QuickstartMamSectionDescription": "Add indexers to search for audiobooks.",
diff --git a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs
index ee5aa18d..ab2229f1 100644
--- a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs
+++ b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs
@@ -185,7 +185,21 @@ public void Scan(List folders = null, FilterFilesType filter = FilterFil
}
else if (!result.ScannedFilePaths.Any())
{
- _logger.Warn("Skipping scan cleanup for {0} because the scan found no media files. This avoids wiping tracked files when a mount is visible but empty.", folder);
+ // An empty result means either a dropped mount or a genuine delete;
+ // only a root-level scan can still be a dropped mount.
+ var subfolderOfHealthyRoot = !rootFolder.Path.PathEquals(folder) &&
+ _diskProvider.FolderExists(rootFolder.Path) &&
+ _diskProvider.GetDirectories(rootFolder.Path).Any();
+
+ if (subfolderOfHealthyRoot)
+ {
+ _logger.Debug("Scan of {0} found no media files but root folder {1} is populated; cleaning up files tracked under it", folder, rootFolder.Path);
+ CleanMediaFiles(folder, result.ScannedFilePaths, rootFolder);
+ }
+ else
+ {
+ _logger.Warn("Skipping scan cleanup for {0} because the scan found no media files. This avoids wiping tracked files when a mount is visible but empty.", folder);
+ }
}
else
{
diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs
index 35adedf4..57a25a70 100644
--- a/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs
+++ b/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs
@@ -305,6 +305,10 @@ public void HandleAsync(BookDeletedEvent message)
{
CleanupEmptyFolders(author, folder);
}
+
+ // Providers queue work on OnBookDelete and drain it on this event; author
+ // deletes already publish it.
+ _eventAggregator.PublishEvent(new DeleteCompletedEvent());
}
private static void CollectFolder(List folders, string folder)
diff --git a/src/NzbDrone.Core/Notifications/ExternalLibraryEdits.cs b/src/NzbDrone.Core/Notifications/ExternalLibraryEdits.cs
new file mode 100644
index 00000000..9cf9ef2e
--- /dev/null
+++ b/src/NzbDrone.Core/Notifications/ExternalLibraryEdits.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Generic;
+using NzbDrone.Core.Books;
+using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.ThingiProvider;
+
+namespace NzbDrone.Core.Notifications
+{
+ public class ExternalLibraryEditPayload
+ {
+ public string Title { get; set; }
+ public string Subtitle { get; set; }
+ public string Description { get; set; }
+ public string Publisher { get; set; }
+ public DateTime? PublishedDate { get; set; }
+ public string SeriesName { get; set; }
+ public double? SeriesPosition { get; set; }
+ public List Languages { get; set; }
+ public List Genres { get; set; }
+ public Dictionary Identifiers { get; set; }
+ public string CoverUrl { get; set; }
+ public byte[] CoverBytes { get; set; }
+ }
+
+ // Seam between library-edit sources (e.g. the Grimmory forwarder) and connections able to
+ // mirror those edits outward. Sources discover targets via the notification factory, so a
+ // provider opts in simply by implementing this on top of NotificationBase; nothing here
+ // references a concrete provider, keeping each side independently mergeable.
+ public interface IExternalLibraryEditTarget
+ {
+ ProviderDefinition Definition { get; }
+ bool AcceptsExternalLibraryEdits { get; }
+ void PushExternalLibraryEdit(Book book, List files, ExternalLibraryEditPayload payload);
+ }
+}
diff --git a/src/NzbDrone.Core/Notifications/Grimmory/Grimmory.cs b/src/NzbDrone.Core/Notifications/Grimmory/Grimmory.cs
new file mode 100644
index 00000000..7fcf1603
--- /dev/null
+++ b/src/NzbDrone.Core/Notifications/Grimmory/Grimmory.cs
@@ -0,0 +1,381 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using FluentValidation.Results;
+using NLog;
+using NzbDrone.Common.Cache;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Books;
+using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.Messaging.Commands;
+using NzbDrone.Core.RootFolders;
+
+namespace NzbDrone.Core.Notifications.Grimmory
+{
+ public class Grimmory : NotificationBase, IExternalLibraryEditTarget
+ {
+ private readonly IGrimmoryProxy _proxy;
+ private readonly IManageCommandQueue _commandQueueManager;
+ private readonly IRootFolderService _rootFolderService;
+ private readonly Logger _logger;
+ private readonly ICached _pendingLibrariesCache;
+
+ public Grimmory(IGrimmoryProxy proxy, IManageCommandQueue commandQueueManager, IRootFolderService rootFolderService, ICacheManager cacheManager, Logger logger)
+ {
+ _proxy = proxy;
+ _commandQueueManager = commandQueueManager;
+ _rootFolderService = rootFolderService;
+ _logger = logger;
+ _pendingLibrariesCache = cacheManager.GetRollingCache(GetType(), "pendingLibraries", TimeSpan.FromDays(1));
+ }
+
+ public override string Name => "Grimmory";
+ public override string Link => "https://github.com/grimmory-tools/grimmory";
+
+ public override bool NotifyOnLibraryImports => Settings.PushMetadata || Settings.PushCovers;
+
+ private class GrimmoryUpdateQueue
+ {
+ public HashSet PendingLibraries { get; } = new HashSet();
+ public bool Refreshing { get; set; }
+ }
+
+ public override bool HasPendingQueue
+ {
+ get
+ {
+ var queue = _pendingLibrariesCache.Find(QueueKey);
+
+ if (queue == null)
+ {
+ return false;
+ }
+
+ lock (queue)
+ {
+ return !queue.Refreshing && queue.PendingLibraries.Any();
+ }
+ }
+ }
+
+ public override void OnReleaseImport(BookDownloadMessage message)
+ {
+ if (message.BookFiles == null || message.BookFiles.Empty())
+ {
+ return;
+ }
+
+ QueueRefresh(GetLibraryId(message.Book, message.BookFiles.FirstOrDefault()), "import");
+
+ // The push waits for Grimmory's (async) refresh to ingest the new files first.
+ QueueAutoPush(message.Book, waitForBook: true);
+ }
+
+ public override void OnRename(Author author, List renamedFiles)
+ {
+ foreach (var renamedFile in renamedFiles ?? new List())
+ {
+ if (renamedFile?.BookFile != null)
+ {
+ QueueRefresh(GetLibraryId(null, renamedFile.BookFile), "rename");
+ }
+ }
+ }
+
+ public override void OnAuthorDelete(AuthorDeleteMessage message)
+ {
+ if (message.DeletedFiles)
+ {
+ QueueRefresh(Settings.EbookLibraryId, "author delete");
+ QueueRefresh(Settings.AudiobookLibraryId, "author delete");
+ }
+ }
+
+ public override void OnBookDelete(BookDeleteMessage message)
+ {
+ if (message.DeletedFiles)
+ {
+ QueueRefresh(GetLibraryId(message.Book, null), "book delete");
+ }
+ }
+
+ public override void OnBookFileDelete(BookFileDeleteMessage message)
+ {
+ QueueRefresh(GetLibraryId(message.Book, message.BookFile), "file delete");
+ }
+
+ public override void OnBookRetag(BookRetagMessage message)
+ {
+ QueueRefresh(GetLibraryId(message.Book, message.BookFile), "retag");
+ QueueAutoPush(message.Book, waitForBook: false);
+ }
+
+ private void QueueAutoPush(Book book, bool waitForBook)
+ {
+ if (book == null || (!Settings.PushMetadata && !Settings.PushCovers))
+ {
+ return;
+ }
+
+ var fields = GrimmoryPushService.ToggleFields(Settings);
+
+ if (fields.Empty())
+ {
+ return;
+ }
+
+ _commandQueueManager.Push(new PushGrimmoryMetadataCommand
+ {
+ BookIds = new List { book.Id },
+ Fields = fields,
+ WaitForBook = waitForBook
+ });
+ }
+
+ public override void ProcessQueue()
+ {
+ var queue = _pendingLibrariesCache.Find(QueueKey);
+
+ if (queue == null)
+ {
+ return;
+ }
+
+ lock (queue)
+ {
+ if (queue.Refreshing)
+ {
+ return;
+ }
+
+ queue.Refreshing = true;
+ }
+
+ try
+ {
+ while (true)
+ {
+ List libraryIds;
+
+ lock (queue)
+ {
+ if (queue.PendingLibraries.Empty())
+ {
+ queue.Refreshing = false;
+ return;
+ }
+
+ libraryIds = queue.PendingLibraries.ToList();
+ queue.PendingLibraries.Clear();
+ }
+
+ var failed = new List();
+
+ foreach (var libraryId in libraryIds)
+ {
+ try
+ {
+ _proxy.RefreshLibrary(Settings, libraryId);
+ }
+ catch (Exception ex)
+ {
+ _logger.Warn(ex, "Failed to trigger Grimmory refresh for library {0}", libraryId);
+ failed.Add(libraryId);
+ }
+ }
+
+ if (failed.Any())
+ {
+ throw new InvalidOperationException($"Failed to trigger Grimmory refresh for libraries: {string.Join(", ", failed)}");
+ }
+ }
+ }
+ catch
+ {
+ lock (queue)
+ {
+ queue.Refreshing = false;
+ }
+
+ throw;
+ }
+ }
+
+ public override ValidationResult Test()
+ {
+ var failures = new List();
+
+ failures.AddIfNotNull(_proxy.Test(Settings));
+
+ return new ValidationResult(failures);
+ }
+
+ public override object RequestAction(string action, IDictionary query)
+ {
+ if (action == "getLibraries")
+ {
+ if (Settings.Url.IsNullOrWhiteSpace() || Settings.Username.IsNullOrWhiteSpace() || Settings.Password.IsNullOrWhiteSpace())
+ {
+ return new { options = new List