pull/1335/head
Weblate 3 years ago
commit d460cbf319

@ -5,6 +5,7 @@ import DeleteAuthorModal from 'Author/Delete/DeleteAuthorModal';
import EditAuthorModalConnector from 'Author/Edit/EditAuthorModalConnector';
import AuthorHistoryTable from 'Author/History/AuthorHistoryTable';
import MonitoringOptionsModal from 'Author/MonitoringOptions/MonitoringOptionsModal';
import BookEditorFooter from 'Book/Editor/BookEditorFooter';
import BookFileEditorTable from 'BookFile/Editor/BookFileEditorTable';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
@ -22,6 +23,7 @@ import InteractiveSearchTable from 'InteractiveSearch/InteractiveSearchTable';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal';
@ -53,13 +55,56 @@ class AuthorDetails extends Component {
isDeleteAuthorModalOpen: false,
isInteractiveImportModalOpen: false,
isMonitorOptionsModalOpen: false,
isBookEditorActive: false,
allExpanded: false,
allCollapsed: false,
expandedState: {},
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
selectedTabIndex: 0
};
}
//
// Control
setSelectedState = (items) => {
const {
selectedState
} = this.state;
const newSelectedState = {};
items.forEach((item) => {
const isItemSelected = selectedState[item.id];
if (isItemSelected) {
newSelectedState[item.id] = isItemSelected;
} else {
newSelectedState[item.id] = false;
}
});
const selectedCount = getSelectedIds(newSelectedState).length;
const newStateCount = Object.keys(newSelectedState).length;
let isAllSelected = false;
let isAllUnselected = false;
if (selectedCount === 0) {
isAllUnselected = true;
} else if (selectedCount === newStateCount) {
isAllSelected = true;
}
this.setState({ selectedState: newSelectedState, allSelected: isAllSelected, allUnselected: isAllUnselected });
}
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
}
//
// Listeners
@ -114,6 +159,10 @@ class AuthorDetails extends Component {
this.setState({ isMonitorOptionsModalOpen: false });
}
onBookEditorTogglePress = () => {
this.setState({ isBookEditorActive: !this.state.isBookEditorActive });
}
onExpandAllPress = () => {
const {
allExpanded,
@ -137,6 +186,27 @@ class AuthorDetails extends Component {
});
}
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
}
onSelectAllPress = () => {
this.onSelectAllChange({ value: !this.state.allSelected });
}
onSelectedChange = (items, id, value, shiftKey = false) => {
this.setState((state) => {
return toggleSelected(state, items, id, value, shiftKey);
});
}
onSaveSelected = (changes) => {
this.props.onSaveSelected({
bookIds: this.getSelectedIds(),
...changes
});
}
onTabSelect = (index, lastIndex) => {
this.setState({ selectedTabIndex: index });
}
@ -165,6 +235,10 @@ class AuthorDetails extends Component {
nextAuthor,
onRefreshPress,
onSearchPress,
isSaving,
saveError,
isDeleting,
deleteError,
statistics
} = this.props;
@ -175,6 +249,9 @@ class AuthorDetails extends Component {
isDeleteAuthorModalOpen,
isInteractiveImportModalOpen,
isMonitorOptionsModalOpen,
isBookEditorActive,
allSelected,
selectedState,
allExpanded,
allCollapsed,
expandedState,
@ -189,6 +266,8 @@ class AuthorDetails extends Component {
expandIcon = icons.EXPAND;
}
const selectedBookIds = this.getSelectedIds();
return (
<PageContent title={authorName}>
<PageToolbar>
@ -252,6 +331,33 @@ class AuthorDetails extends Component {
iconName={icons.DELETE}
onPress={this.onDeleteAuthorPress}
/>
<PageToolbarSeparator />
{
isBookEditorActive ?
<PageToolbarButton
label={translate('BookList')}
iconName={icons.AUTHOR_CONTINUING}
onPress={this.onBookEditorTogglePress}
/> :
<PageToolbarButton
label={translate('BookEditor')}
iconName={icons.EDIT}
onPress={this.onBookEditorTogglePress}
/>
}
{
isBookEditorActive ?
<PageToolbarButton
label={allSelected ? translate('UnselectAll') : translate('SelectAll')}
iconName={icons.CHECK_SQUARE}
onPress={this.onSelectAllPress}
/> :
null
}
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
@ -377,7 +483,11 @@ class AuthorDetails extends Component {
<AuthorDetailsSeasonConnector
authorId={id}
isExpanded={true}
selectedState={selectedState}
onExpandPress={this.onExpandPress}
setSelectedState={this.setSelectedState}
onSelectedChange={this.onSelectedChange}
isBookEditorActive={isBookEditorActive}
/>
</TabPanel>
@ -422,7 +532,6 @@ class AuthorDetails extends Component {
</TabPanel>
</Tabs>
}
</div>
<div className={styles.metadataMessage}>
@ -474,6 +583,19 @@ class AuthorDetails extends Component {
onModalClose={this.onMonitorOptionsClose}
/>
</PageContentBody>
{
isBookEditorActive &&
<BookEditorFooter
bookIds={selectedBookIds}
selectedCount={selectedBookIds.length}
isSaving={isSaving}
saveError={saveError}
isDeleting={isDeleting}
deleteError={deleteError}
onSaveSelected={this.onSaveSelected}
/>
}
</PageContent>
);
}
@ -493,7 +615,6 @@ AuthorDetails.propTypes = {
images: PropTypes.arrayOf(PropTypes.object).isRequired,
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
isSaving: PropTypes.bool.isRequired,
isRefreshing: PropTypes.bool.isRequired,
isSearching: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
@ -510,13 +631,17 @@ AuthorDetails.propTypes = {
isSmallScreen: PropTypes.bool.isRequired,
onMonitorTogglePress: PropTypes.func.isRequired,
onRefreshPress: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired
onSearchPress: PropTypes.func.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object,
onSaveSelected: PropTypes.func.isRequired
};
AuthorDetails.defaultProps = {
statistics: {},
tags: [],
isSaving: false
tags: []
};
export default AuthorDetails;

@ -6,6 +6,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import { toggleAuthorMonitored } from 'Store/Actions/authorActions';
import { saveBookEditor } from 'Store/Actions/bookEditorActions';
import { clearBookFiles, fetchBookFiles } from 'Store/Actions/bookFileActions';
import { executeCommand } from 'Store/Actions/commandActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
@ -21,7 +22,8 @@ import AuthorDetails from './AuthorDetails';
const selectBooks = createSelector(
(state) => state.books,
(books) => {
(state) => state.bookEditor,
(books, editor) => {
const {
items,
isFetching,
@ -37,7 +39,8 @@ const selectBooks = createSelector(
isBooksPopulated: isPopulated,
booksError: error,
hasBooks,
hasMonitoredBooks
hasMonitoredBooks,
...editor
};
}
);
@ -187,6 +190,7 @@ function createMapStateToProps() {
const mapDispatchToProps = {
fetchSeries,
clearSeries,
saveBookEditor,
fetchBookFiles,
clearBookFiles,
toggleAuthorMonitored,
@ -282,6 +286,10 @@ class AuthorDetailsConnector extends Component {
});
}
onSaveSelected = (payload) => {
this.props.saveBookEditor(payload);
}
//
// Render
@ -292,6 +300,7 @@ class AuthorDetailsConnector extends Component {
onMonitorTogglePress={this.onMonitorTogglePress}
onRefreshPress={this.onRefreshPress}
onSearchPress={this.onSearchPress}
onSaveSelected={this.onSaveSelected}
/>
);
}
@ -307,6 +316,7 @@ AuthorDetailsConnector.propTypes = {
isRenamingAuthor: PropTypes.bool.isRequired,
fetchSeries: PropTypes.func.isRequired,
clearSeries: PropTypes.func.isRequired,
saveBookEditor: PropTypes.func.isRequired,
fetchBookFiles: PropTypes.func.isRequired,
clearBookFiles: PropTypes.func.isRequired,
toggleAuthorMonitored: PropTypes.func.isRequired,

@ -4,6 +4,7 @@ import React, { Component } from 'react';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { sortDirections } from 'Helpers/Props';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import getToggledRange from 'Utilities/Table/getToggledRange';
import BookRowConnector from './BookRowConnector';
import styles from './AuthorDetailsSeason.css';
@ -21,6 +22,26 @@ class AuthorDetailsSeason extends Component {
};
}
componentDidMount() {
this.props.setSelectedState(this.props.items);
}
componentDidUpdate(prevProps) {
const {
items,
sortKey,
sortDirection,
setSelectedState
} = this.props;
if (sortKey !== prevProps.sortKey ||
sortDirection !== prevProps.sortDirection ||
hasDifferentItemsOrOrder(prevProps.items, items)
) {
setSelectedState(items);
}
}
//
// Listeners
@ -42,26 +63,42 @@ class AuthorDetailsSeason extends Component {
this.props.onMonitorBookPress(_.uniq(bookIds), monitored);
}
onSelectedChange = ({ id, value, shiftKey = false }) => {
const {
onSelectedChange,
items
} = this.props;
return onSelectedChange(items, id, value, shiftKey);
}
//
// Render
render() {
const {
items,
isBookEditorActive,
columns,
sortKey,
sortDirection,
onSortPress,
onTableOptionChange
onTableOptionChange,
selectedState
} = this.props;
let titleColumns = columns;
if (!isBookEditorActive) {
titleColumns = columns.filter((x) => x.name !== 'select');
}
return (
<div
className={styles.bookType}
>
<div className={styles.books}>
<Table
columns={columns}
columns={titleColumns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
@ -76,6 +113,9 @@ class AuthorDetailsSeason extends Component {
columns={columns}
{...item}
onMonitorBookPress={this.onMonitorBookPress}
isBookEditorActive={isBookEditorActive}
isSelected={selectedState[item.id]}
onSelectedChange={this.onSelectedChange}
/>
);
})
@ -92,9 +132,13 @@ AuthorDetailsSeason.propTypes = {
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
items: PropTypes.arrayOf(PropTypes.object).isRequired,
isBookEditorActive: PropTypes.bool.isRequired,
selectedState: PropTypes.object.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onTableOptionChange: PropTypes.func.isRequired,
onExpandPress: PropTypes.func.isRequired,
setSelectedState: PropTypes.func.isRequired,
onSelectedChange: PropTypes.func.isRequired,
onSortPress: PropTypes.func.isRequired,
onMonitorBookPress: PropTypes.func.isRequired,
uiSettings: PropTypes.object.isRequired

@ -6,6 +6,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton';
import StarRating from 'Components/StarRating';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import BookStatus from './BookStatus';
import styles from './BookRow.css';
@ -65,6 +66,9 @@ class BookRow extends Component {
authorMonitored,
titleSlug,
bookFiles,
isBookEditorActive,
isSelected,
onSelectedChange,
columns
} = this.props;
@ -84,6 +88,18 @@ class BookRow extends Component {
return null;
}
if (isBookEditorActive && name === 'select') {
return (
<TableSelectCell
key={name}
id={id}
isSelected={isSelected}
isDisabled={false}
onSelectedChange={onSelectedChange}
/>
);
}
if (name === 'monitored') {
return (
<TableRowCell
@ -220,6 +236,9 @@ BookRow.propTypes = {
isSaving: PropTypes.bool,
authorMonitored: PropTypes.bool.isRequired,
bookFiles: PropTypes.arrayOf(PropTypes.object).isRequired,
isBookEditorActive: PropTypes.bool.isRequired,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onMonitorBookPress: PropTypes.func.isRequired
};

@ -6,11 +6,13 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
class EditBookModalContent extends Component {
@ -36,6 +38,9 @@ class EditBookModalContent extends Component {
authorName,
statistics,
item,
isFetching,
isPopulated,
error,
isSaving,
onInputChange,
onModalClose,
@ -49,6 +54,7 @@ class EditBookModalContent extends Component {
} = item;
const hasFile = statistics ? statistics.bookFileCount : 0;
const errorMessage = getErrorMessage(error, 'Unable to load editions');
return (
<ModalContent onModalClose={onModalClose}>
@ -88,20 +94,33 @@ class EditBookModalContent extends Component {
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('Edition')}
</FormLabel>
<FormInputGroup
type={inputTypes.BOOK_EDITION_SELECT}
name="editions"
helpText={translate('EditionsHelpText')}
isDisabled={anyEditionOk.value && hasFile}
bookEditions={editions}
onChange={onInputChange}
/>
</FormGroup>
{
isFetching &&
<LoadingIndicator />
}
{
error &&
<div>{errorMessage}</div>
}
{
isPopulated && !isFetching && !!editions.value.length &&
<FormGroup>
<FormLabel>
{translate('Edition')}
</FormLabel>
<FormInputGroup
type={inputTypes.BOOK_EDITION_SELECT}
name="editions"
helpText={translate('EditionsHelpText')}
isDisabled={anyEditionOk.value && hasFile}
bookEditions={editions}
onChange={onInputChange}
/>
</FormGroup>
}
</Form>
</ModalBody>
@ -131,6 +150,9 @@ EditBookModalContent.propTypes = {
authorName: PropTypes.string.isRequired,
statistics: PropTypes.object.isRequired,
item: PropTypes.object.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isPopulated: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,

@ -4,6 +4,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveBook, setBookValue } from 'Store/Actions/bookActions';
import { clearEditions, fetchEditions } from 'Store/Actions/editionActions';
import createAuthorSelector from 'Store/Selectors/createAuthorSelector';
import createBookSelector from 'Store/Selectors/createBookSelector';
import selectSettings from 'Store/Selectors/selectSettings';
@ -12,15 +13,25 @@ import EditBookModalContent from './EditBookModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.books,
(state) => state.editions,
createBookSelector(),
createAuthorSelector(),
(bookState, book, author) => {
(bookState, editionState, book, author) => {
const {
isSaving,
saveError,
pendingChanges
} = bookState;
const {
isFetching,
isPopulated,
error,
items
} = editionState;
book.editions = items;
const bookSettings = _.pick(book, [
'monitored',
'anyEditionOk',
@ -34,6 +45,9 @@ function createMapStateToProps() {
authorName: author.authorName,
bookType: book.bookType,
statistics: book.statistics,
isFetching,
isPopulated,
error,
isSaving,
saveError,
item: settings.settings,
@ -44,6 +58,8 @@ function createMapStateToProps() {
}
const mapDispatchToProps = {
dispatchFetchEditions: fetchEditions,
dispatchClearEditions: clearEditions,
dispatchSetBookValue: setBookValue,
dispatchSaveBook: saveBook
};
@ -53,12 +69,20 @@ class EditBookModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchEditions({ bookId: this.props.bookId });
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
componentWillUnmount() {
this.props.dispatchClearEditions();
}
//
// Listeners
@ -90,6 +114,8 @@ EditBookModalContentConnector.propTypes = {
bookId: PropTypes.number,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
dispatchFetchEditions: PropTypes.func.isRequired,
dispatchClearEditions: PropTypes.func.isRequired,
dispatchSetBookValue: PropTypes.func.isRequired,
dispatchSaveBook: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired

@ -0,0 +1,70 @@
.inputContainer {
margin-right: 20px;
min-width: 150px;
}
.buttonContainer {
display: flex;
justify-content: flex-end;
flex-grow: 1;
}
.buttonContainerContent {
flex-grow: 0;
}
.buttons {
display: flex;
justify-content: flex-end;
flex-grow: 1;
}
.organizeSelectedButton,
.tagsButton {
composes: button from '~Components/Link/SpinnerButton.css';
margin-right: 10px;
height: 35px;
}
.deleteSelectedButton {
composes: button from '~Components/Link/SpinnerButton.css';
margin-left: 50px;
height: 35px;
}
@media only screen and (max-width: $breakpointExtraLarge) {
.deleteSelectedButton {
margin-left: 0;
}
}
@media only screen and (max-width: $breakpointLarge) {
.buttonContainer {
justify-content: flex-start;
margin-top: 10px;
}
}
@media only screen and (max-width: $breakpointSmall) {
.inputContainer {
margin-right: 0;
}
.buttonContainer {
justify-content: flex-start;
}
.buttonContainerContent {
flex-grow: 1;
}
.buttons {
justify-content: space-between;
}
.selectedAuthorLabel {
text-align: left;
}
}

@ -0,0 +1,156 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SelectInput from 'Components/Form/SelectInput';
import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import BookEditorFooterLabel from './BookEditorFooterLabel';
import DeleteBookModal from './Delete/DeleteBookModal';
import styles from './BookEditorFooter.css';
const NO_CHANGE = 'noChange';
class BookEditorFooter extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
monitored: NO_CHANGE,
rootFolderPath: NO_CHANGE,
savingTags: false,
isDeleteBookModalOpen: false,
isTagsModalOpen: false,
isConfirmMoveModalOpen: false,
destinationRootFolder: null
};
}
componentDidUpdate(prevProps) {
const {
isSaving,
saveError
} = this.props;
if (prevProps.isSaving && !isSaving && !saveError) {
this.setState({
monitored: NO_CHANGE,
rootFolderPath: NO_CHANGE,
savingTags: false
});
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.setState({ [name]: value });
if (value === NO_CHANGE) {
return;
}
switch (name) {
case 'monitored':
this.props.onSaveSelected({ [name]: value === 'monitored' });
break;
default:
this.props.onSaveSelected({ [name]: value });
}
}
onDeleteSelectedPress = () => {
this.setState({ isDeleteBookModalOpen: true });
}
onDeleteBookModalClose = () => {
this.setState({ isDeleteBookModalOpen: false });
}
//
// Render
render() {
const {
bookIds,
selectedCount,
isSaving,
isDeleting
} = this.props;
const {
monitored,
isDeleteBookModalOpen
} = this.state;
const monitoredOptions = [
{ key: NO_CHANGE, value: 'No Change', disabled: true },
{ key: 'monitored', value: 'Monitored' },
{ key: 'unmonitored', value: 'Unmonitored' }
];
return (
<PageContentFooter>
<div className={styles.inputContainer}>
<BookEditorFooterLabel
label={translate('MonitorBook')}
isSaving={isSaving && monitored !== NO_CHANGE}
/>
<SelectInput
name="monitored"
value={monitored}
values={monitoredOptions}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div className={styles.buttonContainer}>
<div className={styles.buttonContainerContent}>
<BookEditorFooterLabel
label={translate('SelectedCountBooksSelectedInterp', [selectedCount])}
isSaving={false}
/>
<div className={styles.buttons}>
<SpinnerButton
className={styles.deleteSelectedButton}
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!selectedCount || isDeleting}
onPress={this.onDeleteSelectedPress}
>
Delete
</SpinnerButton>
</div>
</div>
</div>
<DeleteBookModal
isOpen={isDeleteBookModalOpen}
bookIds={bookIds}
onModalClose={this.onDeleteBookModalClose}
/>
</PageContentFooter>
);
}
}
BookEditorFooter.propTypes = {
bookIds: PropTypes.arrayOf(PropTypes.number).isRequired,
selectedCount: PropTypes.number.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object,
onSaveSelected: PropTypes.func.isRequired
};
export default BookEditorFooter;

@ -0,0 +1,8 @@
.label {
margin-bottom: 3px;
font-weight: bold;
}
.savingIcon {
margin-left: 8px;
}

@ -0,0 +1,40 @@
import PropTypes from 'prop-types';
import React from 'react';
import SpinnerIcon from 'Components/SpinnerIcon';
import { icons } from 'Helpers/Props';
import styles from './BookEditorFooterLabel.css';
function BookEditorFooterLabel(props) {
const {
className,
label,
isSaving
} = props;
return (
<div className={className}>
{label}
{
isSaving &&
<SpinnerIcon
className={styles.savingIcon}
name={icons.SPINNER}
isSpinning={true}
/>
}
</div>
);
}
BookEditorFooterLabel.propTypes = {
className: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
isSaving: PropTypes.bool.isRequired
};
BookEditorFooterLabel.defaultProps = {
className: styles.label
};
export default BookEditorFooterLabel;

@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import DeleteBookModalContentConnector from './DeleteBookModalContentConnector';
function DeleteBookModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<DeleteBookModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
DeleteBookModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default DeleteBookModal;

@ -0,0 +1,9 @@
.message {
margin-top: 20px;
margin-bottom: 10px;
}
.deleteFilesMessage {
margin-top: 20px;
color: $dangerColor;
}

@ -0,0 +1,172 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './DeleteBookModalContent.css';
class DeleteBookModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
deleteFiles: false,
addImportListExclusion: true
};
}
//
// Listeners
onDeleteFilesChange = ({ value }) => {
this.setState({ deleteFiles: value });
}
onAddImportListExclusionChange = ({ value }) => {
this.setState({ addImportListExclusion: value });
}
onDeleteBookConfirmed = () => {
const {
deleteFiles,
addImportListExclusion
} = this.state;
this.setState({ deleteFiles: false });
this.props.onDeleteSelectedPress(deleteFiles, addImportListExclusion);
}
//
// Render
render() {
const {
book,
files,
onModalClose
} = this.props;
const {
deleteFiles,
addImportListExclusion
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Delete Selected Book
</ModalHeader>
<ModalBody>
<div>
<FormGroup>
<FormLabel>{`Delete File${book.length > 1 ? 's' : ''}`}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="deleteFiles"
value={deleteFiles}
helpText={'Delete book files'}
kind={kinds.DANGER}
isDisabled={files.length === 0}
onChange={this.onDeleteFilesChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('AddListExclusion')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="addImportListExclusion"
value={addImportListExclusion}
helpText={translate('AddImportListExclusionHelpText')}
kind={kinds.DANGER}
onChange={this.onAddImportListExclusionChange}
/>
</FormGroup>
{
!addImportListExclusion &&
<div className={styles.deleteFilesMessage}>
<div>
{translate('IfYouDontAddAnImportListExclusionAndTheAuthorHasAMetadataProfileOtherThanNoneThenThisBookMayBeReaddedDuringTheNextAuthorRefresh')}
</div>
</div>
}
</div>
<div className={styles.message}>
{`Are you sure you want to delete ${book.length} selected book${book.length > 1 ? 's' : ''}${deleteFiles ? ' and their files' : ''}?`}
</div>
<ul>
{
book.map((s) => {
return (
<li key={s.title}>
<span>{s.title}</span>
</li>
);
})
}
</ul>
{
deleteFiles &&
<div>
<div className={styles.deleteFilesMessage}>
{translate('TheFollowingFilesWillBeDeleted')}
</div>
<ul>
{
files.map((s) => {
return (
<li key={s.path}>
<span>{s.path}</span>
</li>
);
})
}
</ul>
</div>
}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onDeleteBookConfirmed}
>
Delete
</Button>
</ModalFooter>
</ModalContent>
);
}
}
DeleteBookModalContent.propTypes = {
book: PropTypes.arrayOf(PropTypes.object).isRequired,
files: PropTypes.arrayOf(PropTypes.object).isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteSelectedPress: PropTypes.func.isRequired
};
export default DeleteBookModalContent;

@ -0,0 +1,54 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { bulkDeleteBook } from 'Store/Actions/bookEditorActions';
import DeleteBookModalContent from './DeleteBookModalContent';
function createMapStateToProps() {
return createSelector(
(state, { bookIds }) => bookIds,
(state) => state.books.items,
(state) => state.bookFiles.items,
(bookIds, allBooks, allBookFiles) => {
const selectedBook = _.intersectionWith(allBooks, bookIds, (s, id) => {
return s.id === id;
});
const sortedBook = _.orderBy(selectedBook, 'title');
const selectedFiles = _.intersectionWith(allBookFiles, bookIds, (s, id) => {
return s.bookId === id;
});
const files = _.orderBy(selectedFiles, ['bookId', 'path']);
const book = _.map(sortedBook, (s) => {
return {
title: s.title,
path: s.path
};
});
return {
book,
files
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onDeleteSelectedPress(deleteFiles, addImportListExclusion) {
dispatch(bulkDeleteBook({
bookIds: props.bookIds,
deleteFiles,
addImportListExclusion
}));
props.onModalClose();
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteBookModalContent);

@ -36,6 +36,7 @@ import {
faCaretDown as fasCaretDown,
faCheck as fasCheck,
faCheckCircle as fasCheckCircle,
faCheckSquare as fasCheckSquare,
faChevronCircleDown as fasChevronCircleDown,
faChevronCircleRight as fasChevronCircleRight,
faChevronCircleUp as fasChevronCircleUp,
@ -127,6 +128,7 @@ export const CARET_DOWN = fasCaretDown;
export const CHECK = fasCheck;
export const CHECK_INDETERMINATE = fasMinus;
export const CHECK_CIRCLE = fasCheckCircle;
export const CHECK_SQUARE = fasCheckSquare;
export const CIRCLE = fasCircle;
export const CIRCLE_OUTLINE = farCircle;
export const CLEAR = fasTrashAlt;

@ -132,6 +132,14 @@ export const defaultState = {
},
columns: [
{
name: 'select',
columnLabel: 'Select',
isSortable: false,
isVisible: true,
isModifiable: false,
isHidden: true
},
{
name: 'monitored',
columnLabel: 'Monitored',

@ -0,0 +1,114 @@
import { batchActions } from 'redux-batched-actions';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { set, updateItem } from './baseActions';
import createHandleActions from './Creators/createHandleActions';
//
// Variables
export const section = 'bookEditor';
//
// State
export const defaultState = {
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null
};
//
// Actions Types
export const SAVE_BOOK_EDITOR = 'bookEditor/saveBookEditor';
export const BULK_DELETE_BOOK = 'bookEditor/bulkDeleteBook';
//
// Action Creators
export const saveBookEditor = createThunk(SAVE_BOOK_EDITOR);
export const bulkDeleteBook = createThunk(BULK_DELETE_BOOK);
//
// Action Handlers
export const actionHandlers = handleThunks({
[SAVE_BOOK_EDITOR]: function(getState, payload, dispatch) {
dispatch(set({
section,
isSaving: true
}));
const promise = createAjaxRequest({
url: '/book/editor',
method: 'PUT',
data: JSON.stringify(payload),
dataType: 'json'
}).request;
promise.done((data) => {
dispatch(batchActions([
...data.map((book) => {
return updateItem({
id: book.id,
section: 'books',
...book
});
}),
set({
section,
isSaving: false,
saveError: null
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isSaving: false,
saveError: xhr
}));
});
},
[BULK_DELETE_BOOK]: function(getState, payload, dispatch) {
dispatch(set({
section,
isDeleting: true
}));
const promise = createAjaxRequest({
url: '/book/editor',
method: 'DELETE',
data: JSON.stringify(payload),
dataType: 'json'
}).request;
promise.done(() => {
// SignalR will take care of removing the book from the collection
dispatch(set({
section,
isDeleting: false,
deleteError: null
}));
});
promise.fail((xhr) => {
dispatch(set({
section,
isDeleting: false,
deleteError: xhr
}));
});
}
});
//
// Reducers
export const reducers = createHandleActions({}, defaultState, section);

@ -0,0 +1,54 @@
import { createAction } from 'redux-actions';
import { createThunk, handleThunks } from 'Store/thunks';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createClearReducer from './Creators/Reducers/createClearReducer';
//
// Variables
export const section = 'editions';
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
items: [],
itemMap: {}
};
//
// Actions Types
export const FETCH_EDITIONS = 'editions/fetchEditions';
export const CLEAR_EDITIONS = 'editions/clearEditions';
//
// Action Creators
export const fetchEditions = createThunk(FETCH_EDITIONS);
export const clearEditions = createAction(CLEAR_EDITIONS);
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_EDITIONS]: createFetchHandler(section, '/edition')
});
//
// Reducers
export const reducers = createHandleActions({
[CLEAR_EDITIONS]: createClearReducer(section, {
isFetching: false,
isPopulated: false,
error: null,
items: [],
itemMap: {}
})
}, defaultState, section);

@ -6,6 +6,7 @@ import * as authorHistory from './authorHistoryActions';
import * as authorIndex from './authorIndexActions';
import * as blocklist from './blocklistActions';
import * as books from './bookActions';
import * as bookEditor from './bookEditorActions';
import * as bookFiles from './bookFileActions';
import * as bookHistory from './bookHistoryActions';
import * as bookIndex from './bookIndexActions';
@ -14,6 +15,7 @@ import * as calendar from './calendarActions';
import * as captcha from './captchaActions';
import * as commands from './commandActions';
import * as customFilters from './customFilterActions';
import * as editions from './editionActions';
import * as history from './historyActions';
import * as interactiveImportActions from './interactiveImportActions';
import * as oAuth from './oAuthActions';
@ -42,11 +44,13 @@ export default [
bookHistory,
bookIndex,
books,
bookEditor,
bookStudio,
calendar,
captcha,
commands,
customFilters,
editions,
history,
interactiveImportActions,
oAuth,

@ -4,6 +4,7 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using FluentAssertions;
using Moq;
@ -15,6 +16,8 @@ using NzbDrone.Common.Http;
using NzbDrone.Common.Http.Dispatchers;
using NzbDrone.Common.Http.Proxy;
using NzbDrone.Common.TPL;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Security;
using NzbDrone.Test.Common;
using NzbDrone.Test.Common.Categories;
using HttpClient = NzbDrone.Common.Http.HttpClient;
@ -41,7 +44,7 @@ namespace NzbDrone.Common.Test.Http
var mainHost = "httpbin.servarr.com";
// Use mirrors for tests that use two hosts
var candidates = new[] { "eu.httpbin.org", /* "httpbin.org", */ "www.httpbin.org" };
var candidates = new[] { "httpbin1.servarr.com" };
// httpbin.org is broken right now, occassionally redirecting to https if it's unavailable.
_httpBinHost = mainHost;
@ -49,7 +52,7 @@ namespace NzbDrone.Common.Test.Http
TestLogger.Info($"{candidates.Length} TestSites available.");
_httpBinSleep = _httpBinHosts.Count() < 2 ? 100 : 10;
_httpBinSleep = 10;
}
private bool IsTestSiteAvailable(string site)
@ -84,10 +87,13 @@ namespace NzbDrone.Common.Test.Http
Mocker.GetMock<IOsInfo>().Setup(c => c.Name).Returns("TestOS");
Mocker.GetMock<IOsInfo>().Setup(c => c.Version).Returns("9.0.0");
Mocker.GetMock<IConfigService>().SetupGet(x => x.CertificateValidation).Returns(CertificateValidationType.Enabled);
Mocker.SetConstant<IUserAgentBuilder>(Mocker.Resolve<UserAgentBuilder>());
Mocker.SetConstant<ICacheManager>(Mocker.Resolve<CacheManager>());
Mocker.SetConstant<ICreateManagedWebProxy>(Mocker.Resolve<ManagedWebProxyFactory>());
Mocker.SetConstant<ICertificateValidationService>(new X509CertificateValidationService(Mocker.GetMock<IConfigService>().Object, TestLogger));
Mocker.SetConstant<IRateLimitService>(Mocker.Resolve<RateLimitService>());
Mocker.SetConstant<IEnumerable<IHttpRequestInterceptor>>(new IHttpRequestInterceptor[0]);
Mocker.SetConstant<IHttpDispatcher>(Mocker.Resolve<TDispatcher>());
@ -127,6 +133,28 @@ namespace NzbDrone.Common.Test.Http
response.Content.Should().NotBeNullOrWhiteSpace();
}
[TestCase(CertificateValidationType.Enabled)]
[TestCase(CertificateValidationType.DisabledForLocalAddresses)]
public void bad_ssl_should_fail_when_remote_validation_enabled(CertificateValidationType validationType)
{
Mocker.GetMock<IConfigService>().SetupGet(x => x.CertificateValidation).Returns(validationType);
var request = new HttpRequest($"https://expired.badssl.com");
Assert.Throws<HttpRequestException>(() => Subject.Execute(request));
ExceptionVerification.ExpectedErrors(2);
}
[Test]
public void bad_ssl_should_pass_if_remote_validation_disabled()
{
Mocker.GetMock<IConfigService>().SetupGet(x => x.CertificateValidation).Returns(CertificateValidationType.Disabled);
var request = new HttpRequest($"https://expired.badssl.com");
Subject.Execute(request);
ExceptionVerification.ExpectedErrors(0);
}
[Test]
public void should_execute_typed_get()
{

@ -3,6 +3,8 @@ using DryIoc;
using DryIoc.Microsoft.DependencyInjection;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Composition.Extensions;
using NzbDrone.Common.EnvironmentInfo;
@ -25,12 +27,14 @@ namespace NzbDrone.Common.Test
.AddNzbDroneLogger()
.AutoAddServices(Bootstrap.ASSEMBLIES)
.AddDummyDatabase()
.AddStartupContext(new StartupContext("first", "second"))
.GetServiceProvider();
.AddStartupContext(new StartupContext("first", "second"));
container.GetRequiredService<IAppFolderFactory>().Register();
container.RegisterInstance<IHostLifetime>(new Mock<IHostLifetime>().Object);
Mocker.SetConstant<System.IServiceProvider>(container);
var serviceProvider = container.GetServiceProvider();
serviceProvider.GetRequiredService<IAppFolderFactory>().Register();
Mocker.SetConstant<System.IServiceProvider>(serviceProvider);
var handlers = Subject.BuildAll<IHandle<ApplicationStartedEvent>>()
.Select(c => c.GetType().FullName);

@ -1,9 +1,9 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Security.Principal;
using System.ServiceProcess;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Hosting.WindowsServices;
using NLog;
using NzbDrone.Common.Processes;
@ -14,14 +14,11 @@ namespace NzbDrone.Common.EnvironmentInfo
private readonly Logger _logger;
private readonly DateTime _startTime = DateTime.UtcNow;
public RuntimeInfo(IServiceProvider serviceProvider, Logger logger)
public RuntimeInfo(IHostLifetime hostLifetime, Logger logger)
{
_logger = logger;
IsWindowsService = !IsUserInteractive &&
OsInfo.IsWindows &&
serviceProvider.ServiceExist(ServiceProvider.SERVICE_NAME) &&
serviceProvider.GetStatus(ServiceProvider.SERVICE_NAME) == ServiceControllerStatus.StartPending;
IsWindowsService = hostLifetime is WindowsServiceLifetime;
//Guarded to avoid issues when running in a non-managed process
var entry = Process.GetCurrentProcess().MainModule;

@ -0,0 +1,11 @@
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
namespace NzbDrone.Common.Http.Dispatchers
{
public interface ICertificateValidationService
{
bool ShouldByPassValidationError(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors);
}
}

@ -3,6 +3,7 @@ using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Security;
using System.Net.Sockets;
using System.Text;
using System.Threading;
@ -24,6 +25,7 @@ namespace NzbDrone.Common.Http.Dispatchers
private readonly IHttpProxySettingsProvider _proxySettingsProvider;
private readonly ICreateManagedWebProxy _createManagedWebProxy;
private readonly ICertificateValidationService _certificateValidationService;
private readonly IUserAgentBuilder _userAgentBuilder;
private readonly ICached<System.Net.Http.HttpClient> _httpClientCache;
private readonly ICached<CredentialCache> _credentialCache;
@ -31,12 +33,14 @@ namespace NzbDrone.Common.Http.Dispatchers
public ManagedHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider,
ICreateManagedWebProxy createManagedWebProxy,
ICertificateValidationService certificateValidationService,
IUserAgentBuilder userAgentBuilder,
ICacheManager cacheManager,
Logger logger)
{
_proxySettingsProvider = proxySettingsProvider;
_createManagedWebProxy = createManagedWebProxy;
_certificateValidationService = certificateValidationService;
_userAgentBuilder = userAgentBuilder;
_logger = logger;
@ -158,7 +162,12 @@ namespace NzbDrone.Common.Http.Dispatchers
AllowAutoRedirect = false,
Credentials = GetCredentialCache(),
PreAuthenticate = true,
MaxConnectionsPerServer = 12,
ConnectCallback = onConnect,
SslOptions = new SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = _certificateValidationService.ShouldByPassValidationError
}
};
if (proxySettings != null)

@ -7,6 +7,7 @@
<ItemGroup>
<PackageReference Include="DryIoc.dll" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
<PackageReference Include="NLog.Extensions.Logging" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="NLog" />

@ -4,7 +4,6 @@
<TargetFrameworks>net6.0</TargetFrameworks>
<ApplicationIcon>..\NzbDrone.Host\Readarr.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<PropertyGroup Condition="!$(RuntimeIdentifier.StartsWith('win'))">
<AssemblyName>Readarr</AssemblyName>

@ -1,52 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- UAC Manifest Options
If you want to change the Windows User Account Control level replace the
requestedExecutionLevel node with one of the following.
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
Specifying requestedExecutionLevel element will disable file and registry virtualization.
Remove this element if your application requires this virtualization for backwards
compatibility.
-->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on and is
is designed to work with. Uncomment the appropriate elements and Windows will
automatically selected the most compatible environment. -->
<!-- Windows Vista -->
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />
<!-- Windows 7 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
<!-- Windows 8 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
<!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>

@ -11,6 +11,7 @@ using NzbDrone.Common.TPL;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Http;
using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.Security;
using NzbDrone.Test.Common;
namespace NzbDrone.Core.Test.Framework
@ -25,7 +26,8 @@ namespace NzbDrone.Core.Test.Framework
Mocker.SetConstant<IHttpProxySettingsProvider>(new HttpProxySettingsProvider(Mocker.Resolve<ConfigService>()));
Mocker.SetConstant<ICreateManagedWebProxy>(new ManagedWebProxyFactory(Mocker.Resolve<CacheManager>()));
Mocker.SetConstant<IHttpDispatcher>(new ManagedHttpDispatcher(Mocker.Resolve<IHttpProxySettingsProvider>(), Mocker.Resolve<ICreateManagedWebProxy>(), Mocker.Resolve<UserAgentBuilder>(), Mocker.Resolve<CacheManager>(), TestLogger));
Mocker.SetConstant<ICertificateValidationService>(new X509CertificateValidationService(Mocker.Resolve<ConfigService>(), TestLogger));
Mocker.SetConstant<IHttpDispatcher>(new ManagedHttpDispatcher(Mocker.Resolve<IHttpProxySettingsProvider>(), Mocker.Resolve<ICreateManagedWebProxy>(), Mocker.Resolve<ICertificateValidationService>(), Mocker.Resolve<UserAgentBuilder>(), Mocker.Resolve<CacheManager>(), TestLogger));
Mocker.SetConstant<IHttpClient>(new HttpClient(new IHttpRequestInterceptor[0], Mocker.Resolve<CacheManager>(), Mocker.Resolve<RateLimitService>(), Mocker.Resolve<IHttpDispatcher>(), TestLogger));
Mocker.SetConstant<IReadarrCloudRequestBuilder>(new ReadarrCloudRequestBuilder());
Mocker.SetConstant<IMetadataRequestBuilder>(Mocker.Resolve<MetadataRequestBuilder>());

@ -9,6 +9,7 @@ namespace NzbDrone.Core.Books
{
public interface IEditionRepository : IBasicRepository<Edition>
{
List<Edition> GetAllMonitoredEditions();
Edition FindByForeignEditionId(string foreignEditionId);
List<Edition> FindByBook(int id);
List<Edition> FindByAuthor(int id);
@ -25,6 +26,11 @@ namespace NzbDrone.Core.Books
{
}
public List<Edition> GetAllMonitoredEditions()
{
return Query(x => x.Monitored == true);
}
public Edition FindByForeignEditionId(string foreignEditionId)
{
var edition = Query(x => x.ForeignEditionId == foreignEditionId).SingleOrDefault();

@ -12,7 +12,7 @@ namespace NzbDrone.Core.Books
{
Edition GetEdition(int id);
Edition GetEditionByForeignEditionId(string foreignEditionId);
List<Edition> GetAllEditions();
List<Edition> GetAllMonitoredEditions();
void InsertMany(List<Edition> editions);
void UpdateMany(List<Edition> editions);
void DeleteMany(List<Edition> editions);
@ -48,9 +48,9 @@ namespace NzbDrone.Core.Books
return _editionRepository.FindByForeignEditionId(foreignEditionId);
}
public List<Edition> GetAllEditions()
public List<Edition> GetAllMonitoredEditions()
{
return _editionRepository.All().ToList();
return _editionRepository.GetAllMonitoredEditions();
}
public void InsertMany(List<Edition> editions)

@ -66,6 +66,7 @@
"Book": "Book",
"BookAvailableButMissing": "Book Available, but Missing",
"BookDownloaded": "Book Downloaded",
"BookEditor": "Book Editor",
"BookFileCountBookCountTotalTotalBookCountInterp": "{0} / {1} (Total: {2})",
"BookFileCounttotalBookCountBooksDownloadedInterp": "{0}/{1} books downloaded",
"BookFilesCountMessage": "No book files",
@ -73,6 +74,7 @@
"BookIsDownloading": "Book is downloading",
"BookIsDownloadingInterp": "Book is downloading - {0}% {1}",
"BookIsNotMonitored": "Book is not monitored",
"BookList": "Book List",
"BookMissingFromDisk": "Book missing from disk",
"BookMonitoring": "Book Monitoring",
"BookNaming": "Book Naming",
@ -558,7 +560,9 @@
"SearchSelected": "Search Selected",
"Season": "Season",
"Security": "Security",
"SelectAll": "Select All",
"SelectedCountAuthorsSelectedInterp": "{0} Author(s) Selected",
"SelectedCountBooksSelectedInterp": "{0} Book(s) Selected",
"SendAnonymousUsageData": "Send Anonymous Usage Data",
"SendMetadataToCalibre": "Send Metadata to Calibre",
"Series": "Series",
@ -644,6 +648,7 @@
"TestAllLists": "Test All Lists",
"TheAuthorFolderAndAllOfItsContentWillBeDeleted": "The author folder {0} and all of its content will be deleted.",
"TheBooksFilesWillBeDeleted": "The book's files will be deleted.",
"TheFollowingFilesWillBeDeleted": "The following files will be deleted:",
"ThisWillApplyToAllIndexersPleaseFollowTheRulesSetForthByThem": "This will apply to all indexers, please follow the rules set forth by them",
"Time": "Time",
"TimeFormat": "Time Format",
@ -706,6 +711,7 @@
"UnmappedFiles": "UnmappedFiles",
"Unmonitored": "Unmonitored",
"UnmonitoredHelpText": "Include unmonitored books in the iCal feed",
"UnselectAll": "Unselect All",
"UpdateAll": "Update all",
"UpdateAutomaticallyHelpText": "Automatically download and install updates. You will still be able to install from System: Updates",
"UpdateCovers": "Update Covers",

@ -4,13 +4,12 @@ using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http.Dispatchers;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Lifecycle;
using NzbDrone.Core.Messaging.Events;
namespace NzbDrone.Core.Security
{
public class X509CertificateValidationService : IHandle<ApplicationStartedEvent>
public class X509CertificateValidationService : ICertificateValidationService
{
private readonly IConfigService _configService;
private readonly Logger _logger;
@ -21,19 +20,16 @@ namespace NzbDrone.Core.Security
_logger = logger;
}
private bool ShouldByPassValidationError(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
public bool ShouldByPassValidationError(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
{
var request = sender as HttpWebRequest;
if (request == null)
if (sender is not SslStream request)
{
return true;
}
var cert2 = certificate as X509Certificate2;
if (cert2 != null && request != null && cert2.SignatureAlgorithm.FriendlyName == "md5RSA")
if (certificate is X509Certificate2 cert2 && cert2.SignatureAlgorithm.FriendlyName == "md5RSA")
{
_logger.Error("https://{0} uses the obsolete md5 hash in it's https certificate, if that is your certificate, please (re)create certificate with better algorithm as soon as possible.", request.RequestUri.Authority);
_logger.Error("https://{0} uses the obsolete md5 hash in it's https certificate, if that is your certificate, please (re)create certificate with better algorithm as soon as possible.", request.TargetHostName);
}
if (sslPolicyErrors == SslPolicyErrors.None)
@ -41,12 +37,12 @@ namespace NzbDrone.Core.Security
return true;
}
if (request.RequestUri.Host == "localhost" || request.RequestUri.Host == "127.0.0.1")
if (request.TargetHostName == "localhost" || request.TargetHostName == "127.0.0.1")
{
return true;
}
var ipAddresses = GetIPAddresses(request.RequestUri.Host);
var ipAddresses = GetIPAddresses(request.TargetHostName);
var certificateValidation = _configService.CertificateValidation;
if (certificateValidation == CertificateValidationType.Disabled)
@ -60,7 +56,7 @@ namespace NzbDrone.Core.Security
return true;
}
_logger.Error("Certificate validation for {0} failed. {1}", request.Address, sslPolicyErrors);
_logger.Error("Certificate validation for {0} failed. {1}", request.TargetHostName, sslPolicyErrors);
return false;
}
@ -74,10 +70,5 @@ namespace NzbDrone.Core.Security
return Dns.GetHostEntry(host).AddressList;
}
public void Handle(ApplicationStartedEvent message)
{
ServicePointManager.ServerCertificateValidationCallback = ShouldByPassValidationError;
}
}
}

@ -4,6 +4,7 @@ using DryIoc;
using DryIoc.Microsoft.DependencyInjection;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Moq;
using NUnit.Framework;
using NzbDrone.Common;
@ -33,16 +34,15 @@ namespace NzbDrone.App.Test
{
var args = new StartupContext("first", "second");
// set up a dummy broadcaster to allow tests to resolve
var mockBroadcaster = new Mock<IBroadcastSignalRMessage>();
var container = new Container(rules => rules.WithNzbDroneRules())
.AutoAddServices(Bootstrap.ASSEMBLIES)
.AddNzbDroneLogger()
.AddDummyDatabase()
.AddStartupContext(args);
container.RegisterInstance<IBroadcastSignalRMessage>(mockBroadcaster.Object);
// set up a dummy broadcaster and lifetime to allow tests to resolve
container.RegisterInstance<IHostLifetime>(new Mock<IHostLifetime>().Object);
container.RegisterInstance<IBroadcastSignalRMessage>(new Mock<IBroadcastSignalRMessage>().Object);
_container = container.GetServiceProvider();
}

@ -19,7 +19,6 @@ namespace NzbDrone.Host
private readonly IBrowserService _browserService;
private readonly IProcessProvider _processProvider;
private readonly IEventAggregator _eventAggregator;
private readonly IUtilityModeRouter _utilityModeRouter;
private readonly Logger _logger;
public AppLifetime(IHostApplicationLifetime appLifetime,
@ -29,7 +28,6 @@ namespace NzbDrone.Host
IBrowserService browserService,
IProcessProvider processProvider,
IEventAggregator eventAggregator,
IUtilityModeRouter utilityModeRouter,
Logger logger)
{
_appLifetime = appLifetime;
@ -39,7 +37,6 @@ namespace NzbDrone.Host
_browserService = browserService;
_processProvider = processProvider;
_eventAggregator = eventAggregator;
_utilityModeRouter = utilityModeRouter;
_logger = logger;
appLifetime.ApplicationStarted.Register(OnAppStarted);
@ -71,7 +68,7 @@ namespace NzbDrone.Host
private void OnAppStopped()
{
if (_runtimeInfo.RestartPending)
if (_runtimeInfo.RestartPending && !_runtimeInfo.IsWindowsService)
{
var restartArgs = GetRestartArgs();

@ -180,7 +180,20 @@ namespace NzbDrone.Host
return ApplicationModes.UninstallService;
}
if (OsInfo.IsWindows && WindowsServiceHelpers.IsWindowsService())
Logger.Debug("Getting windows service status");
// IsWindowsService can throw sometimes, so wrap it
var isWindowsService = false;
try
{
isWindowsService = WindowsServiceHelpers.IsWindowsService();
}
catch (Exception e)
{
Logger.Error(e, "Failed to get service status");
}
if (OsInfo.IsWindows && isWindowsService)
{
return ApplicationModes.Service;
}

@ -5,7 +5,6 @@
<RuntimeIdentifiers>win-x64;win-x86</RuntimeIdentifiers>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>..\NzbDrone.Host\Readarr.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
<GenerateResourceUsePreserializedResources>true</GenerateResourceUsePreserializedResources>
</PropertyGroup>
<ItemGroup>

@ -4,9 +4,8 @@ using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using Microsoft.Extensions.Hosting;
using NLog;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Processes;
using NzbDrone.Core.Lifecycle;
using NzbDrone.Host;
namespace NzbDrone.SysTray
@ -14,28 +13,19 @@ namespace NzbDrone.SysTray
public class SystemTrayApp : Form, IHostedService
{
private readonly IBrowserService _browserService;
private readonly IRuntimeInfo _runtimeInfo;
private readonly IProcessProvider _processProvider;
private readonly ILifecycleService _lifecycle;
private readonly NotifyIcon _trayIcon = new NotifyIcon();
private readonly ContextMenuStrip _trayMenu = new ContextMenuStrip();
public SystemTrayApp(IBrowserService browserService, IRuntimeInfo runtimeInfo, IProcessProvider processProvider)
public SystemTrayApp(IBrowserService browserService, ILifecycleService lifecycle)
{
_browserService = browserService;
_runtimeInfo = runtimeInfo;
_processProvider = processProvider;
_lifecycle = lifecycle;
}
public void Start()
{
Application.ThreadException += OnThreadException;
Application.ApplicationExit += OnApplicationExit;
Application.SetHighDpiMode(HighDpiMode.PerMonitor);
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
_trayMenu.Items.Add(new ToolStripMenuItem("Launch Browser", null, LaunchBrowser));
_trayMenu.Items.Add(new ToolStripSeparator());
_trayMenu.Items.Add(new ToolStripMenuItem("Exit", null, OnExit));
@ -69,12 +59,6 @@ namespace NzbDrone.SysTray
DisposeTrayIcon();
}
protected override void OnClosed(EventArgs e)
{
Console.WriteLine("Closing");
base.OnClosed(e);
}
protected override void OnLoad(EventArgs e)
{
Visible = false;
@ -102,8 +86,7 @@ namespace NzbDrone.SysTray
private void OnExit(object sender, EventArgs e)
{
LogManager.Configuration = null;
Environment.Exit(0);
_lifecycle.Shutdown();
}
private void LaunchBrowser(object sender, EventArgs e)
@ -117,33 +100,17 @@ namespace NzbDrone.SysTray
}
}
private void OnApplicationExit(object sender, EventArgs e)
{
if (_runtimeInfo.RestartPending)
{
_processProvider.SpawnNewProcess(_runtimeInfo.ExecutingApplication, "--restart --nobrowser");
}
DisposeTrayIcon();
}
private void OnThreadException(object sender, EventArgs e)
{
DisposeTrayIcon();
}
private void DisposeTrayIcon()
{
try
{
_trayIcon.Visible = false;
_trayIcon.Icon = null;
_trayIcon.Visible = false;
_trayIcon.Dispose();
}
catch (Exception)
if (_trayIcon == null)
{
return;
}
_trayIcon.Visible = false;
_trayIcon.Icon = null;
_trayIcon.Visible = false;
_trayIcon.Dispose();
}
}
}

@ -16,21 +16,22 @@ namespace NzbDrone
public static void Main(string[] args)
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.SetHighDpiMode(HighDpiMode.SystemAware);
try
{
var startupArgs = new StartupContext(args);
NzbDroneLogger.Register(startupArgs, false, true);
Bootstrap.Start(args, e =>
{
e.ConfigureServices((_, s) => s.AddSingleton<IHostedService, SystemTrayApp>());
});
Bootstrap.Start(args, e => { e.ConfigureServices((_, s) => s.AddSingleton<IHostedService, SystemTrayApp>()); });
}
catch (Exception e)
{
Logger.Fatal(e, "EPIC FAIL");
MessageBox.Show($"{e.GetType().Name}: {e.Message}", buttons: MessageBoxButtons.OK, icon: MessageBoxIcon.Error, caption: "Epic Fail!");
MessageBox.Show($"{e.GetType().Name}: {e}", buttons: MessageBoxButtons.OK, icon: MessageBoxIcon.Error, caption: "Epic Fail!");
}
}
}

@ -1,52 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- UAC Manifest Options
If you want to change the Windows User Account Control level replace the
requestedExecutionLevel node with one of the following.
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
Specifying requestedExecutionLevel element will disable file and registry virtualization.
Remove this element if your application requires this virtualization for backwards
compatibility.
-->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on and is
is designed to work with. Uncomment the appropriate elements and Windows will
automatically selected the most compatible environment. -->
<!-- Windows Vista -->
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />
<!-- Windows 7 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
<!-- Windows 8 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
<!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>

@ -73,7 +73,7 @@ namespace Readarr.Api.V1.Books
var books = _bookService.GetAllBooks();
var authors = _authorService.GetAllAuthors().ToDictionary(x => x.AuthorMetadataId);
var editions = _editionService.GetAllEditions().GroupBy(x => x.BookId).ToDictionary(x => x.Key, y => y.ToList());
var editions = _editionService.GetAllMonitoredEditions().GroupBy(x => x.BookId).ToDictionary(x => x.Key, y => y.ToList());
foreach (var book in books)
{

@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Books;
using NzbDrone.Core.Messaging.Commands;
using Readarr.Http;
namespace Readarr.Api.V1.Books
{
[V1ApiController("book/editor")]
public class BookEditorController : Controller
{
private readonly IBookService _bookService;
private readonly IManageCommandQueue _commandQueueManager;
public BookEditorController(IBookService bookService, IManageCommandQueue commandQueueManager)
{
_bookService = bookService;
_commandQueueManager = commandQueueManager;
}
[HttpPut]
public IActionResult SaveAll([FromBody] BookEditorResource resource)
{
var booksToUpdate = _bookService.GetBooks(resource.BookIds);
foreach (var book in booksToUpdate)
{
if (resource.Monitored.HasValue)
{
book.Monitored = resource.Monitored.Value;
}
}
_bookService.UpdateMany(booksToUpdate);
return Accepted(booksToUpdate.ToResource());
}
[HttpDelete]
public object DeleteBook([FromBody] BookEditorResource resource)
{
foreach (var bookId in resource.BookIds)
{
_bookService.DeleteBook(bookId, resource.DeleteFiles ?? false, resource.AddImportListExclusion ?? false);
}
return new object();
}
}
}

@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace Readarr.Api.V1.Books
{
public class BookEditorResource
{
public List<int> BookIds { get; set; }
public bool? Monitored { get; set; }
public bool? DeleteFiles { get; set; }
public bool? AddImportListExclusion { get; set; }
}
}

@ -0,0 +1,27 @@
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Books;
using Readarr.Api.V1.Books;
using Readarr.Http;
namespace NzbDrone.Api.V1.Editions
{
[V1ApiController]
public class EditionController : Controller
{
private readonly IEditionService _editionService;
public EditionController(IEditionService editionService)
{
_editionService = editionService;
}
[HttpGet]
public List<EditionResource> GetEditions(int bookId)
{
var editions = _editionService.GetEditionsByBook(bookId);
return editions.ToResource();
}
}
}
Loading…
Cancel
Save