Merge branch 'develop' into mka-support

pull/4361/head
sharinganthief 3 months ago committed by GitHub
commit e4960194dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -9,7 +9,7 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '2.1.0'
majorVersion: '2.1.6'
minorVersion: $[counter('minorVersion', 1076)]
lidarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(lidarrVersion)'

@ -2,6 +2,8 @@ const loose = true;
module.exports = {
plugins: [
'@babel/plugin-transform-logical-assignment-operators',
// Stage 1
'@babel/plugin-proposal-export-default-from',
['@babel/plugin-transform-optional-chaining', { loose }],

@ -3,9 +3,10 @@ import React from 'react';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './HistoryEventTypeCell.css';
function getIconName(eventType) {
function getIconName(eventType, data) {
switch (eventType) {
case 'grabbed':
return icons.DOWNLOADING;
@ -16,7 +17,7 @@ function getIconName(eventType) {
case 'downloadFailed':
return icons.DOWNLOADING;
case 'trackFileDeleted':
return icons.DELETE;
return data.reason === 'MissingFromDisk' ? icons.FILE_MISSING : icons.DELETE;
case 'trackFileRenamed':
return icons.ORGANIZE;
case 'trackFileRetagged':
@ -54,11 +55,11 @@ function getTooltip(eventType, data) {
case 'downloadFailed':
return 'Album download failed';
case 'trackFileDeleted':
return 'Track file deleted';
return data.reason === 'MissingFromDisk' ? translate('TrackFileMissingTooltip') : translate('TrackFileDeletedTooltip');
case 'trackFileRenamed':
return 'Track file renamed';
return translate('TrackFileRenamedTooltip');
case 'trackFileRetagged':
return 'Track file tags updated';
return translate('TrackFileTagsUpdatedTooltip');
case 'albumImportIncomplete':
return 'Files downloaded but not all could be imported';
case 'downloadImported':
@ -71,7 +72,7 @@ function getTooltip(eventType, data) {
}
function HistoryEventTypeCell({ eventType, data }) {
const iconName = getIconName(eventType);
const iconName = getIconName(eventType, data);
const iconKind = getIconKind(eventType);
const tooltip = getTooltip(eventType, data);

@ -25,7 +25,7 @@ import toggleSelected from 'Utilities/Table/toggleSelected';
import QueueFilterModal from './QueueFilterModal';
import QueueOptionsConnector from './QueueOptionsConnector';
import QueueRowConnector from './QueueRowConnector';
import RemoveQueueItemsModal from './RemoveQueueItemsModal';
import RemoveQueueItemModal from './RemoveQueueItemModal';
class Queue extends Component {
@ -309,9 +309,16 @@ class Queue extends Component {
}
</PageContentBody>
<RemoveQueueItemsModal
<RemoveQueueItemModal
isOpen={isConfirmRemoveModalOpen}
selectedCount={selectedCount}
canChangeCategory={isConfirmRemoveModalOpen && (
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
return !!(item && item.downloadClientHasPostImportCategory);
})
)}
canIgnore={isConfirmRemoveModalOpen && (
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
@ -319,7 +326,7 @@ class Queue extends Component {
return !!(item && item.artistId && item.albumId);
})
)}
allPending={isConfirmRemoveModalOpen && (
pending={isConfirmRemoveModalOpen && (
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);

@ -98,8 +98,10 @@ class QueueRow extends Component {
indexer,
outputPath,
downloadClient,
downloadClientHasPostImportCategory,
downloadForced,
estimatedCompletionTime,
added,
timeleft,
size,
sizeleft,
@ -328,6 +330,15 @@ class QueueRow extends Component {
);
}
if (name === 'added') {
return (
<RelativeDateCellConnector
key={name}
date={added}
/>
);
}
if (name === 'actions') {
return (
<TableRowCell
@ -393,6 +404,7 @@ class QueueRow extends Component {
<RemoveQueueItemModal
isOpen={isRemoveQueueItemModalOpen}
sourceTitle={title}
canChangeCategory={!!downloadClientHasPostImportCategory}
canIgnore={!!artist}
isPending={isPending}
onRemovePress={this.onRemoveQueueItemModalConfirmed}
@ -422,8 +434,10 @@ QueueRow.propTypes = {
indexer: PropTypes.string,
outputPath: PropTypes.string,
downloadClient: PropTypes.string,
downloadClientHasPostImportCategory: PropTypes.bool,
downloadForced: PropTypes.bool.isRequired,
estimatedCompletionTime: PropTypes.string,
added: PropTypes.string,
timeleft: PropTypes.string,
size: PropTypes.number,
sizeleft: PropTypes.number,

@ -1,175 +0,0 @@
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 Modal from 'Components/Modal/Modal';
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, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
class RemoveQueueItemModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
removeFromClient: true,
blocklist: false,
skipRedownload: false
};
}
//
// Control
resetState = function() {
this.setState({
removeFromClient: true,
blocklist: false,
skipRedownload: false
});
};
//
// Listeners
onRemoveFromClientChange = ({ value }) => {
this.setState({ removeFromClient: value });
};
onBlocklistChange = ({ value }) => {
this.setState({ blocklist: value });
};
onSkipRedownloadChange = ({ value }) => {
this.setState({ skipRedownload: value });
};
onRemoveConfirmed = () => {
const state = this.state;
this.resetState();
this.props.onRemovePress(state);
};
onModalClose = () => {
this.resetState();
this.props.onModalClose();
};
//
// Render
render() {
const {
isOpen,
sourceTitle,
canIgnore,
isPending
} = this.props;
const { removeFromClient, blocklist, skipRedownload } = this.state;
return (
<Modal
isOpen={isOpen}
size={sizes.MEDIUM}
onModalClose={this.onModalClose}
>
<ModalContent
onModalClose={this.onModalClose}
>
<ModalHeader>
Remove - {sourceTitle}
</ModalHeader>
<ModalBody>
<div>
Are you sure you want to remove '{sourceTitle}' from the queue?
</div>
{
isPending ?
null :
<FormGroup>
<FormLabel>{translate('RemoveFromDownloadClient')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="removeFromClient"
value={removeFromClient}
helpTextWarning={translate('RemoveFromDownloadClientHelpTextWarning')}
isDisabled={!canIgnore}
onChange={this.onRemoveFromClientChange}
/>
</FormGroup>
}
<FormGroup>
<FormLabel>
{translate('BlocklistRelease')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="blocklist"
value={blocklist}
helpText={translate('BlocklistReleaseHelpText')}
onChange={this.onBlocklistChange}
/>
</FormGroup>
{
blocklist &&
<FormGroup>
<FormLabel>
{translate('SkipRedownload')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="skipRedownload"
value={skipRedownload}
helpText={translate('SkipRedownloadHelpText')}
onChange={this.onSkipRedownloadChange}
/>
</FormGroup>
}
</ModalBody>
<ModalFooter>
<Button onPress={this.onModalClose}>
Close
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onRemoveConfirmed}
>
Remove
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
RemoveQueueItemModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
sourceTitle: PropTypes.string.isRequired,
canIgnore: PropTypes.bool.isRequired,
isPending: PropTypes.bool.isRequired,
onRemovePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default RemoveQueueItemModal;

@ -0,0 +1,230 @@
import React, { useCallback, useMemo, useState } 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 Modal from 'Components/Modal/Modal';
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, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './RemoveQueueItemModal.css';
interface RemovePressProps {
removeFromClient: boolean;
changeCategory: boolean;
blocklist: boolean;
skipRedownload: boolean;
}
interface RemoveQueueItemModalProps {
isOpen: boolean;
sourceTitle: string;
canChangeCategory: boolean;
canIgnore: boolean;
isPending: boolean;
selectedCount?: number;
onRemovePress(props: RemovePressProps): void;
onModalClose: () => void;
}
type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore';
type BlocklistMethod =
| 'doNotBlocklist'
| 'blocklistAndSearch'
| 'blocklistOnly';
function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
const {
isOpen,
sourceTitle,
canIgnore,
canChangeCategory,
isPending,
selectedCount,
onRemovePress,
onModalClose,
} = props;
const multipleSelected = selectedCount && selectedCount > 1;
const [removalMethod, setRemovalMethod] =
useState<RemovalMethod>('removeFromClient');
const [blocklistMethod, setBlocklistMethod] =
useState<BlocklistMethod>('doNotBlocklist');
const { title, message } = useMemo(() => {
if (!selectedCount) {
return {
title: translate('RemoveQueueItem', { sourceTitle }),
message: translate('RemoveQueueItemConfirmation', { sourceTitle }),
};
}
if (selectedCount === 1) {
return {
title: translate('RemoveSelectedItem'),
message: translate('RemoveSelectedItemQueueMessageText'),
};
}
return {
title: translate('RemoveSelectedItems'),
message: translate('RemoveSelectedItemsQueueMessageText', {
selectedCount,
}),
};
}, [sourceTitle, selectedCount]);
const removalMethodOptions = useMemo(() => {
return [
{
key: 'removeFromClient',
value: translate('RemoveFromDownloadClient'),
hint: multipleSelected
? translate('RemoveMultipleFromDownloadClientHint')
: translate('RemoveFromDownloadClientHint'),
},
{
key: 'changeCategory',
value: translate('ChangeCategory'),
isDisabled: !canChangeCategory,
hint: multipleSelected
? translate('ChangeCategoryMultipleHint')
: translate('ChangeCategoryHint'),
},
{
key: 'ignore',
value: multipleSelected
? translate('IgnoreDownloads')
: translate('IgnoreDownload'),
isDisabled: !canIgnore,
hint: multipleSelected
? translate('IgnoreDownloadsHint')
: translate('IgnoreDownloadHint'),
},
];
}, [canChangeCategory, canIgnore, multipleSelected]);
const blocklistMethodOptions = useMemo(() => {
return [
{
key: 'doNotBlocklist',
value: translate('DoNotBlocklist'),
hint: translate('DoNotBlocklistHint'),
},
{
key: 'blocklistAndSearch',
value: translate('BlocklistAndSearch'),
hint: multipleSelected
? translate('BlocklistAndSearchMultipleHint')
: translate('BlocklistAndSearchHint'),
},
{
key: 'blocklistOnly',
value: translate('BlocklistOnly'),
hint: multipleSelected
? translate('BlocklistMultipleOnlyHint')
: translate('BlocklistOnlyHint'),
},
];
}, [multipleSelected]);
const handleRemovalMethodChange = useCallback(
({ value }: { value: RemovalMethod }) => {
setRemovalMethod(value);
},
[setRemovalMethod]
);
const handleBlocklistMethodChange = useCallback(
({ value }: { value: BlocklistMethod }) => {
setBlocklistMethod(value);
},
[setBlocklistMethod]
);
const handleConfirmRemove = useCallback(() => {
onRemovePress({
removeFromClient: removalMethod === 'removeFromClient',
changeCategory: removalMethod === 'changeCategory',
blocklist: blocklistMethod !== 'doNotBlocklist',
skipRedownload: blocklistMethod === 'blocklistOnly',
});
setRemovalMethod('removeFromClient');
setBlocklistMethod('doNotBlocklist');
}, [
removalMethod,
blocklistMethod,
setRemovalMethod,
setBlocklistMethod,
onRemovePress,
]);
const handleModalClose = useCallback(() => {
setRemovalMethod('removeFromClient');
setBlocklistMethod('doNotBlocklist');
onModalClose();
}, [setRemovalMethod, setBlocklistMethod, onModalClose]);
return (
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={handleModalClose}>
<ModalContent onModalClose={handleModalClose}>
<ModalHeader>{title}</ModalHeader>
<ModalBody>
<div className={styles.message}>{message}</div>
{isPending ? null : (
<FormGroup>
<FormLabel>{translate('RemoveQueueItemRemovalMethod')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="removalMethod"
value={removalMethod}
values={removalMethodOptions}
isDisabled={!canChangeCategory && !canIgnore}
helpTextWarning={translate(
'RemoveQueueItemRemovalMethodHelpTextWarning'
)}
onChange={handleRemovalMethodChange}
/>
</FormGroup>
)}
<FormGroup>
<FormLabel>
{multipleSelected
? translate('BlocklistReleases')
: translate('BlocklistRelease')}
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="blocklistMethod"
value={blocklistMethod}
values={blocklistMethodOptions}
helpText={translate('BlocklistReleaseHelpText')}
onChange={handleBlocklistMethodChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter>
<Button onPress={handleModalClose}>{translate('Close')}</Button>
<Button kind={kinds.DANGER} onPress={handleConfirmRemove}>
{translate('Remove')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
export default RemoveQueueItemModal;

@ -1,176 +0,0 @@
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 Modal from 'Components/Modal/Modal';
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, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './RemoveQueueItemsModal.css';
class RemoveQueueItemsModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
removeFromClient: true,
blocklist: false,
skipRedownload: false
};
}
//
// Control
resetState = function() {
this.setState({
removeFromClient: true,
blocklist: false,
skipRedownload: false
});
};
//
// Listeners
onRemoveFromClientChange = ({ value }) => {
this.setState({ removeFromClient: value });
};
onBlocklistChange = ({ value }) => {
this.setState({ blocklist: value });
};
onSkipRedownloadChange = ({ value }) => {
this.setState({ skipRedownload: value });
};
onRemoveConfirmed = () => {
const state = this.state;
this.resetState();
this.props.onRemovePress(state);
};
onModalClose = () => {
this.resetState();
this.props.onModalClose();
};
//
// Render
render() {
const {
isOpen,
selectedCount,
canIgnore,
allPending
} = this.props;
const { removeFromClient, blocklist, skipRedownload } = this.state;
return (
<Modal
isOpen={isOpen}
size={sizes.MEDIUM}
onModalClose={this.onModalClose}
>
<ModalContent
onModalClose={this.onModalClose}
>
<ModalHeader>
{selectedCount > 1 ? translate('RemoveSelectedItems') : translate('RemoveSelectedItem')}
</ModalHeader>
<ModalBody>
<div className={styles.message}>
{selectedCount > 1 ? translate('RemoveSelectedItemsQueueMessageText', [selectedCount]) : translate('RemoveSelectedItemQueueMessageText')}
</div>
{
allPending ?
null :
<FormGroup>
<FormLabel>{translate('RemoveFromDownloadClient')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="removeFromClient"
value={removeFromClient}
helpTextWarning={translate('RemoveFromDownloadClientHelpTextWarning')}
isDisabled={!canIgnore}
onChange={this.onRemoveFromClientChange}
/>
</FormGroup>
}
<FormGroup>
<FormLabel>
{selectedCount > 1 ? translate('BlocklistReleases') : translate('BlocklistRelease')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="blocklist"
value={blocklist}
helpText={translate('BlocklistReleaseHelpText')}
onChange={this.onBlocklistChange}
/>
</FormGroup>
{
blocklist &&
<FormGroup>
<FormLabel>
{translate('SkipRedownload')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="skipRedownload"
value={skipRedownload}
helpText={translate('SkipRedownloadHelpText')}
onChange={this.onSkipRedownloadChange}
/>
</FormGroup>
}
</ModalBody>
<ModalFooter>
<Button onPress={this.onModalClose}>
{translate('Close')}
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onRemoveConfirmed}
>
{translate('Remove')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
RemoveQueueItemsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
selectedCount: PropTypes.number.isRequired,
canIgnore: PropTypes.bool.isRequired,
allPending: PropTypes.bool.isRequired,
onRemovePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default RemoveQueueItemsModal;

@ -58,8 +58,8 @@ class DeleteAlbumModalContent extends Component {
} = this.props;
const {
trackFileCount,
sizeOnDisk
trackFileCount = 0,
sizeOnDisk = 0
} = statistics;
const deleteFiles = this.state.deleteFiles;
@ -133,14 +133,14 @@ class DeleteAlbumModalContent extends Component {
<ModalFooter>
<Button onPress={onModalClose}>
Close
{translate('Close')}
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onDeleteAlbumConfirmed}
>
Delete
{translate('Delete')}
</Button>
</ModalFooter>
</ModalContent>

@ -119,6 +119,7 @@
margin: 5px 10px 5px 0;
}
.releaseDate,
.sizeOnDisk,
.qualityProfileName,
.links,
@ -147,6 +148,12 @@
.headerContent {
padding: 15px;
}
.title {
font-weight: 300;
font-size: 30px;
line-height: 30px;
}
}
@media only screen and (max-width: $breakpointLarge) {

@ -19,6 +19,7 @@ interface CssExports {
'monitorToggleButton': string;
'overview': string;
'qualityProfileName': string;
'releaseDate': string;
'sizeOnDisk': string;
'tags': string;
'title': string;

@ -9,6 +9,7 @@ import EditAlbumModalConnector from 'Album/Edit/EditAlbumModalConnector';
import AlbumInteractiveSearchModalConnector from 'Album/Search/AlbumInteractiveSearchModalConnector';
import ArtistGenres from 'Artist/Details/ArtistGenres';
import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal';
import Alert from 'Components/Alert';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
@ -215,8 +216,8 @@ class AlbumDetails extends Component {
} = this.props;
const {
trackFileCount,
sizeOnDisk
trackFileCount = 0,
sizeOnDisk = 0
} = statistics;
const {
@ -414,6 +415,7 @@ class AlbumDetails extends Component {
<Label
className={styles.detailsLabel}
title={translate('ReleaseDate')}
size={sizes.LARGE}
>
<Icon
@ -421,10 +423,8 @@ class AlbumDetails extends Component {
size={17}
/>
<span className={styles.sizeOnDisk}>
{
moment(releaseDate).format(shortDateFormat)
}
<span className={styles.releaseDate}>
{moment(releaseDate).format(shortDateFormat)}
</span>
</Label>
@ -465,7 +465,7 @@ class AlbumDetails extends Component {
/>
<span className={styles.qualityProfileName}>
{monitored ? 'Monitored' : 'Unmonitored'}
{monitored ? translate('Monitored') : translate('Unmonitored')}
</span>
</Label>
@ -499,7 +499,7 @@ class AlbumDetails extends Component {
/>
<span className={styles.links}>
Links
{translate('Links')}
</span>
</Label>
}
@ -531,23 +531,24 @@ class AlbumDetails extends Component {
}
{
!isFetching && albumsError &&
<div>
{translate('LoadingAlbumsFailed')}
</div>
!isFetching && albumsError ?
<Alert kind={kinds.DANGER}>
{translate('AlbumsLoadError')}
</Alert> :
null
}
{
!isFetching && trackFilesError &&
<div>
{translate('LoadingTrackFilesFailed')}
</div>
!isFetching && trackFilesError ?
<Alert kind={kinds.DANGER}>
{translate('TrackFilesLoadError')}
</Alert> :
null
}
{
isPopulated && !!media.length &&
<div>
{
media.slice(0).map((medium) => {
return (

@ -70,6 +70,12 @@ function createMapStateToProps() {
isCommandExecuting(isSearchingCommand) &&
isSearchingCommand.body.albumIds.indexOf(album.id) > -1
);
const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, artistId: artist.id }));
const isRenamingArtistCommand = findCommand(commands, { name: commandNames.RENAME_ARTIST });
const isRenamingArtist = (
isCommandExecuting(isRenamingArtistCommand) &&
isRenamingArtistCommand.body.artistIds.indexOf(artist.id) > -1
);
const isFetching = tracks.isFetching || isTrackFilesFetching;
const isPopulated = tracks.isPopulated && isTrackFilesPopulated;
@ -80,6 +86,8 @@ function createMapStateToProps() {
shortDateFormat: uiSettings.shortDateFormat,
artist,
isSearching,
isRenamingFiles,
isRenamingArtist,
isFetching,
isPopulated,
tracksError,
@ -113,8 +121,27 @@ class AlbumDetailsConnector extends Component {
}
componentDidUpdate(prevProps) {
if (!_.isEqual(getMonitoredReleases(prevProps), getMonitoredReleases(this.props)) ||
(prevProps.anyReleaseOk === false && this.props.anyReleaseOk === true)) {
const {
id,
anyReleaseOk,
isRenamingFiles,
isRenamingArtist
} = this.props;
if (
(prevProps.isRenamingFiles && !isRenamingFiles) ||
(prevProps.isRenamingArtist && !isRenamingArtist) ||
!_.isEqual(getMonitoredReleases(prevProps), getMonitoredReleases(this.props)) ||
(prevProps.anyReleaseOk === false && anyReleaseOk === true)
) {
this.unpopulate();
this.populate();
}
// If the id has changed we need to clear the album
// files and fetch from the server.
if (prevProps.id !== id) {
this.unpopulate();
this.populate();
}
@ -174,6 +201,8 @@ class AlbumDetailsConnector extends Component {
AlbumDetailsConnector.propTypes = {
id: PropTypes.number,
anyReleaseOk: PropTypes.bool,
isRenamingFiles: PropTypes.bool.isRequired,
isRenamingArtist: PropTypes.bool.isRequired,
isAlbumFetching: PropTypes.bool,
isAlbumPopulated: PropTypes.bool,
foreignAlbumId: PropTypes.string.isRequired,

@ -23,6 +23,7 @@
}
.duration,
.size,
.status {
composes: cell from '~Components/Table/Cells/TableRowCell.css';

@ -5,6 +5,7 @@ interface CssExports {
'customFormatScore': string;
'duration': string;
'monitored': string;
'size': string;
'status': string;
'title': string;
'trackNumber': string;

@ -9,6 +9,7 @@ import { tooltipPositions } from 'Helpers/Props';
import MediaInfoConnector from 'TrackFile/MediaInfoConnector';
import * as mediaInfoTypes from 'TrackFile/mediaInfoTypes';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import TrackActionsCell from './TrackActionsCell';
import styles from './TrackRow.css';
@ -28,6 +29,7 @@ class TrackRow extends Component {
title,
duration,
trackFilePath,
trackFileSize,
customFormats,
customFormatScore,
columns,
@ -145,6 +147,17 @@ class TrackRow extends Component {
);
}
if (name === 'size') {
return (
<TableRowCell
key={name}
className={styles.size}
>
{!!trackFileSize && formatBytes(trackFileSize)}
</TableRowCell>
);
}
if (name === 'status') {
return (
<TableRowCell
@ -192,6 +205,7 @@ TrackRow.propTypes = {
duration: PropTypes.number.isRequired,
isSaving: PropTypes.bool,
trackFilePath: PropTypes.string,
trackFileSize: PropTypes.number,
customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
mediaInfo: PropTypes.object,

@ -11,6 +11,7 @@ function createMapStateToProps() {
(id, trackFile) => {
return {
trackFilePath: trackFile ? trackFile.path : null,
trackFileSize: trackFile ? trackFile.size : null,
customFormats: trackFile ? trackFile.customFormats : [],
customFormatScore: trackFile ? trackFile.customFormatScore : 0
};

@ -43,6 +43,10 @@ class EditAlbumModalContent extends Component {
...otherProps
} = this.props;
const {
trackFileCount = 0
} = statistics;
const {
monitored,
anyReleaseOk,
@ -96,7 +100,7 @@ class EditAlbumModalContent extends Component {
type={inputTypes.ALBUM_RELEASE_SELECT}
name="releases"
helpText={translate('ReleasesHelpText')}
isDisabled={anyReleaseOk.value && statistics.trackFileCount > 0}
isDisabled={anyReleaseOk.value && trackFileCount > 0}
albumReleases={releases}
onChange={onInputChange}
/>

@ -3,6 +3,7 @@ import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
function getTooltip(title, quality, size) {
if (!title) {
@ -26,13 +27,44 @@ function getTooltip(title, quality, size) {
return title;
}
function revisionLabel(className, quality, showRevision) {
if (!showRevision) {
return;
}
if (quality.revision.isRepack) {
return (
<Label
className={className}
kind={kinds.PRIMARY}
title={translate('Repack')}
>
R
</Label>
);
}
if (quality.revision.version && quality.revision.version > 1) {
return (
<Label
className={className}
kind={kinds.PRIMARY}
title={translate('Proper')}
>
P
</Label>
);
}
}
function TrackQuality(props) {
const {
className,
title,
quality,
size,
isCutoffNotMet
isCutoffNotMet,
showRevision
} = props;
if (!quality) {
@ -40,13 +72,15 @@ function TrackQuality(props) {
}
return (
<Label
className={className}
kind={isCutoffNotMet ? kinds.INVERSE : kinds.DEFAULT}
title={getTooltip(title, quality, size)}
>
{quality.quality.name}
</Label>
<span>
<Label
className={className}
kind={isCutoffNotMet ? kinds.INVERSE : kinds.DEFAULT}
title={getTooltip(title, quality, size)}
>
{quality.quality.name}
</Label>{revisionLabel(className, quality, showRevision)}
</span>
);
}
@ -55,11 +89,13 @@ TrackQuality.propTypes = {
title: PropTypes.string,
quality: PropTypes.object.isRequired,
size: PropTypes.number,
isCutoffNotMet: PropTypes.bool
isCutoffNotMet: PropTypes.bool,
showRevision: PropTypes.bool
};
TrackQuality.defaultProps = {
title: ''
title: '',
showRevision: false
};
export default TrackQuality;

@ -39,8 +39,17 @@ export interface CustomFilter {
filers: PropertyFilter[];
}
export interface AppSectionState {
dimensions: {
isSmallScreen: boolean;
width: number;
height: number;
};
}
interface AppState {
albums: AlbumAppState;
app: AppSectionState;
artist: ArtistAppState;
artistIndex: ArtistIndexAppState;
history: HistoryAppState;

@ -1,38 +1,10 @@
import ModelBase from 'App/ModelBase';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
import Queue from 'typings/Queue';
import AppSectionState, {
AppSectionFilterState,
AppSectionItemState,
Error,
} from './AppSectionState';
export interface StatusMessage {
title: string;
messages: string[];
}
export interface Queue extends ModelBase {
quality: QualityModel;
customFormats: CustomFormat[];
size: number;
title: string;
sizeleft: number;
timeleft: string;
estimatedCompletionTime: string;
status: string;
trackedDownloadStatus: string;
trackedDownloadState: string;
statusMessages: StatusMessage[];
errorMessage: string;
downloadId: string;
protocol: string;
downloadClient: string;
outputPath: string;
artistId?: number;
albumId?: number;
}
export interface QueueDetailsAppState extends AppSectionState<Queue> {
params: unknown;
}

@ -56,8 +56,8 @@ class DeleteArtistModalContent extends Component {
} = this.props;
const {
trackFileCount,
sizeOnDisk
trackFileCount = 0,
sizeOnDisk = 0
} = statistics;
const deleteFiles = this.state.deleteFiles;

@ -85,9 +85,9 @@ class AlbumRow extends Component {
} = this.props;
const {
trackCount,
trackFileCount,
totalTrackCount
trackCount = 0,
trackFileCount = 0,
totalTrackCount = 0
} = statistics;
return (
@ -257,7 +257,8 @@ AlbumRow.propTypes = {
AlbumRow.defaultProps = {
statistics: {
trackCount: 0,
trackFileCount: 0
trackFileCount: 0,
totalTrackCount: 0
}
};

@ -161,6 +161,12 @@
.headerContent {
padding: 15px;
}
.title {
font-weight: 300;
font-size: 30px;
line-height: 30px;
}
}
@media only screen and (max-width: $breakpointLarge) {

@ -8,6 +8,7 @@ import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal';
import MonitoringOptionsModal from 'Artist/MonitoringOptions/MonitoringOptionsModal';
import ArtistInteractiveSearchModalConnector from 'Artist/Search/ArtistInteractiveSearchModalConnector';
import Alert from 'Components/Alert';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
@ -221,8 +222,8 @@ class ArtistDetails extends Component {
} = this.props;
const {
trackFileCount,
sizeOnDisk
trackFileCount = 0,
sizeOnDisk = 0
} = statistics;
const {
@ -241,7 +242,7 @@ class ArtistDetails extends Component {
} = this.state;
const continuing = status === 'continuing';
const endedString = artistType === 'Person' ? 'Deceased' : 'Inactive';
const endedString = artistType === 'Person' ? translate('Deceased') : translate('Inactive');
let trackFilesCountMessage = translate('TrackFilesCountMessage');
@ -555,7 +556,7 @@ class ArtistDetails extends Component {
/>
<span className={styles.links}>
Links
{translate('Links')}
</span>
</Label>
}
@ -611,17 +612,19 @@ class ArtistDetails extends Component {
}
{
!isFetching && albumsError &&
<div>
{translate('LoadingAlbumsFailed')}
</div>
!isFetching && albumsError ?
<Alert kind={kinds.DANGER}>
{translate('AlbumsLoadError')}
</Alert> :
null
}
{
!isFetching && trackFilesError &&
<div>
{translate('LoadingTrackFilesFailed')}
</div>
!isFetching && trackFilesError ?
<Alert kind={kinds.DANGER}>
{translate('TrackFilesLoadError')}
</Alert> :
null
}
{

@ -107,7 +107,6 @@ function createMapStateToProps() {
const isRefreshing = isArtistRefreshing || allArtistRefreshing;
const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.ARTIST_SEARCH, artistId: artist.id }));
const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, artistId: artist.id }));
const isRenamingArtistCommand = findCommand(commands, { name: commandNames.RENAME_ARTIST });
const isRenamingArtist = (
isCommandExecuting(isRenamingArtistCommand) &&

@ -1,6 +1,7 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import ArtistHistoryModalContentConnector from './ArtistHistoryModalContentConnector';
function ArtistHistoryModal(props) {
@ -13,6 +14,7 @@ function ArtistHistoryModal(props) {
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
onModalClose={onModalClose}
>
<ArtistHistoryModalContentConnector

@ -35,13 +35,9 @@ const columns = [
isVisible: true
},
{
name: 'date',
label: () => translate('Date'),
isVisible: true
},
{
name: 'details',
label: () => translate('Details'),
name: 'customFormats',
label: () => translate('CustomFormats'),
isSortable: false,
isVisible: true
},
{
@ -53,9 +49,13 @@ const columns = [
isSortable: true,
isVisible: true
},
{
name: 'date',
label: () => translate('Date'),
isVisible: true
},
{
name: 'actions',
label: () => translate('Actions'),
isVisible: true
}
];

@ -4,7 +4,6 @@
word-break: break-word;
}
.details,
.actions {
composes: cell from '~Components/Table/Cells/TableRowCell.css';

@ -2,7 +2,6 @@
// Please do not change this file!
interface CssExports {
'actions': string;
'details': string;
'sourceTitle': string;
}
export const cssExports: CssExports;

@ -11,7 +11,6 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
@ -112,11 +111,19 @@ class ArtistHistoryRow extends Component {
/>
</TableRowCell>
<TableRowCell>
<AlbumFormats formats={customFormats} />
</TableRowCell>
<TableRowCell>
{formatCustomFormatScore(customFormatScore, customFormats.length)}
</TableRowCell>
<RelativeDateCellConnector
date={date}
/>
<TableRowCell className={styles.details}>
<TableRowCell className={styles.actions}>
<Popover
anchor={
<Icon
@ -134,25 +141,13 @@ class ArtistHistoryRow extends Component {
}
position={tooltipPositions.LEFT}
/>
</TableRowCell>
<TableRowCell className={styles.customFormatScore}>
<Tooltip
anchor={formatCustomFormatScore(
customFormatScore,
customFormats.length
)}
tooltip={<AlbumFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
<TableRowCell className={styles.actions}>
{
eventType === 'grabbed' &&
<IconButton
title={translate('MarkAsFailed')}
name={icons.REMOVE}
size={14}
onPress={this.onMarkAsFailedPress}
/>
}

@ -201,11 +201,15 @@ export default function ArtistIndexPosters(props: ArtistIndexPostersProps) {
if (isSmallScreen) {
const padding = bodyPaddingSmallScreen - 5;
setSize({
width: window.innerWidth - padding * 2,
height: window.innerHeight,
});
const width = window.innerWidth - padding * 2;
const height = window.innerHeight;
if (width !== size.width || height !== size.height) {
setSize({
width,
height,
});
}
return;
}
@ -213,13 +217,18 @@ export default function ArtistIndexPosters(props: ArtistIndexPostersProps) {
if (current) {
const width = current.clientWidth;
const padding = bodyPadding - 5;
const finalWidth = width - padding * 2;
if (Math.abs(size.width - finalWidth) < 20 || size.width === finalWidth) {
return;
}
setSize({
width: width - padding * 2,
width: finalWidth,
height: window.innerHeight,
});
}
}, [isSmallScreen, scrollerRef, bounds]);
}, [isSmallScreen, size, scrollerRef, bounds]);
useEffect(() => {
const currentScrollListener = isSmallScreen ? window : scrollerRef.current;

@ -33,7 +33,11 @@ function AlbumStudioAlbum(props: AlbumStudioAlbumProps) {
isSaving = false,
} = props;
const { trackFileCount, totalTrackCount, percentOfTracks } = statistics;
const {
trackFileCount = 0,
totalTrackCount = 0,
percentOfTracks = 0,
} = statistics;
const dispatch = useDispatch();
const onAlbumMonitoredPress = useCallback(() => {

@ -215,6 +215,7 @@ function EditArtistModalContent(props: EditArtistModalContentProps) {
value={metadataProfileId}
includeNoChange={true}
includeNoChangeDisabled={false}
includeNone={true}
onChange={onInputChange}
/>
</FormGroup>

@ -47,7 +47,7 @@ class CalendarConnector extends Component {
gotoCalendarToday
} = this.props;
registerPagePopulator(this.repopulate);
registerPagePopulator(this.repopulate, ['trackFileUpdated', 'trackFileDeleted']);
if (useCurrentPage) {
fetchCalendar();

@ -30,22 +30,24 @@ function CustomFiltersModalContent(props) {
<ModalBody>
{
customFilters.map((customFilter) => {
return (
<CustomFilter
key={customFilter.id}
id={customFilter.id}
label={customFilter.label}
filters={customFilter.filters}
selectedFilterKey={selectedFilterKey}
isDeleting={isDeleting}
deleteError={deleteError}
dispatchSetFilter={dispatchSetFilter}
dispatchDeleteCustomFilter={dispatchDeleteCustomFilter}
onEditPress={onEditCustomFilter}
/>
);
})
customFilters
.sort((a, b) => a.label.localeCompare(b.label))
.map((customFilter) => {
return (
<CustomFilter
key={customFilter.id}
id={customFilter.id}
label={customFilter.label}
filters={customFilter.filters}
selectedFilterKey={selectedFilterKey}
isDeleting={isDeleting}
deleteError={deleteError}
dispatchSetFilter={dispatchSetFilter}
dispatchDeleteCustomFilter={dispatchDeleteCustomFilter}
onEditPress={onEditCustomFilter}
/>
);
})
}
<div className={styles.addButtonContainer}>

@ -276,6 +276,7 @@ FormInputGroup.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.any,
values: PropTypes.arrayOf(PropTypes.any),
isDisabled: PropTypes.bool,
type: PropTypes.string.isRequired,
kind: PropTypes.oneOf(kinds.all),
min: PropTypes.number,
@ -289,6 +290,7 @@ FormInputGroup.propTypes = {
autoFocus: PropTypes.bool,
includeNoChange: PropTypes.bool,
includeNoChangeDisabled: PropTypes.bool,
includeNone: PropTypes.bool,
selectedValueOptions: PropTypes.object,
pending: PropTypes.bool,
errors: PropTypes.arrayOf(PropTypes.object),

@ -2,8 +2,10 @@
display: flex;
justify-content: flex-end;
margin-right: $formLabelRightMarginWidth;
padding-top: 8px;
min-height: 35px;
text-align: end;
font-weight: bold;
line-height: 35px;
}
.hasError {

@ -17,7 +17,6 @@ function createMapStateToProps() {
(state, { includeMixed }) => includeMixed,
(state, { includeNone }) => includeNone,
(metadataProfiles, includeNoChange, includeNoChangeDisabled = true, includeMixed, includeNone) => {
const profiles = metadataProfiles.items.filter((item) => item.name !== metadataProfileNames.NONE);
const noneProfile = metadataProfiles.items.find((item) => item.name === metadataProfileNames.NONE);

@ -40,18 +40,26 @@ class FilterMenuContent extends Component {
}
{
customFilters.map((filter) => {
return (
<FilterMenuItem
key={filter.id}
filterKey={filter.id}
selectedFilterKey={selectedFilterKey}
onPress={onFilterSelect}
>
{filter.label}
</FilterMenuItem>
);
})
customFilters.length > 0 ?
<MenuItemSeparator /> :
null
}
{
customFilters
.sort((a, b) => a.label.localeCompare(b.label))
.map((filter) => {
return (
<FilterMenuItem
key={filter.id}
filterKey={filter.id}
selectedFilterKey={selectedFilterKey}
onPress={onFilterSelect}
>
{filter.label}
</FilterMenuItem>
);
})
}
{

@ -216,6 +216,8 @@ class SignalRConnector extends Component {
this.props.dispatchUpdateItem({ section, ...body.resource });
} else if (body.action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: body.resource.id });
repopulatePage('trackFileDeleted');
}
// Repopulate the page to handle recently imported file

@ -15,5 +15,5 @@
"start_url": "../../../../",
"theme_color": "#3a3f51",
"background_color": "#3a3f51",
"display": "standalone"
"display": "minimal-ui"
}

@ -55,6 +55,7 @@ import {
faEye as fasEye,
faFastBackward as fasFastBackward,
faFastForward as fasFastForward,
faFileCircleQuestion as fasFileCircleQuestion,
faFileExport as fasFileExport,
faFileImport as fasFileImport,
faFileInvoice as farFileInvoice,
@ -154,7 +155,8 @@ export const EXPORT = fasFileExport;
export const EXTERNAL_LINK = fasExternalLinkAlt;
export const FATAL = fasTimesCircle;
export const FILE = farFile;
export const FILEIMPORT = fasFileImport;
export const FILE_IMPORT = fasFileImport;
export const FILE_MISSING = fasFileCircleQuestion;
export const FILTER = fasFilter;
export const FOLDER = farFolder;
export const FOLDER_OPEN = fasFolderOpen;

@ -44,9 +44,9 @@ class SelectAlbumRow extends Component {
} = this.props;
const {
trackCount,
trackFileCount,
totalTrackCount
trackCount = 0,
trackFileCount = 0,
totalTrackCount = 0
} = statistics;
const extendedTitle = disambiguation ? `${title} (${disambiguation})` : title;
@ -134,7 +134,8 @@ SelectAlbumRow.propTypes = {
SelectAlbumRow.defaultProps = {
statistics: {
trackCount: 0,
trackFileCount: 0
trackFileCount: 0,
totalTrackCount: 0
}
};

@ -51,11 +51,11 @@ class SelectTrackRow extends Component {
iconKind = kinds.DEFAULT;
iconTip = 'Track missing from library and no import selected.';
} else if (importSelected && hasFile) {
iconName = icons.FILEIMPORT;
iconName = icons.FILE_IMPORT;
iconKind = kinds.WARNING;
iconTip = 'Warning: Existing track will be replaced by download.';
} else if (importSelected && !hasFile) {
iconName = icons.FILEIMPORT;
iconName = icons.FILE_IMPORT;
iconKind = kinds.DEFAULT;
iconTip = 'Track missing from library and selected for import.';
}

@ -22,6 +22,10 @@
text-align: center;
}
.quality {
white-space: nowrap;
}
.customFormatScore {
composes: cell from '~Components/Table/Cells/TableRowCell.css';

@ -178,7 +178,7 @@ class InteractiveSearchRow extends Component {
</TableRowCell>
<TableRowCell className={styles.quality}>
<TrackQuality quality={quality} />
<TrackQuality quality={quality} showRevision={true} />
</TableRowCell>
<TableRowCell className={styles.customFormatScore}>

@ -89,7 +89,7 @@ class AddNewArtistSearchResult extends Component {
const linkProps = isExistingArtist ? { to: `/artist/${foreignArtistId}` } : { onPress: this.onPress };
const endedString = artistType === 'Person' ? 'Deceased' : 'Inactive';
const endedString = artistType === 'Person' ? translate('Deceased') : translate('Inactive');
const height = calculateHeight(230, isSmallScreen);

@ -49,7 +49,7 @@ function EditSpecificationModalContent(props) {
{...otherProps}
>
{
fields && fields.some((x) => x.label === 'Regular Expression') &&
fields && fields.some((x) => x.label === translate('CustomFormatsSpecificationRegularExpression')) &&
<Alert kind={kinds.INFO}>
<div>
<div dangerouslySetInnerHTML={{ __html: 'This condition matches using Regular Expressions. Note that the characters <code>\\^$.|?*+()[{</code> have special meanings and need escaping with a <code>\\</code>' }} />

@ -139,7 +139,7 @@ class EditDownloadClientModalContent extends Component {
<FormInputGroup
type={inputTypes.NUMBER}
name="priority"
helpText={translate('PriorityHelpText')}
helpText={translate('DownloadClientPriorityHelpText')}
min={1}
max={50}
{...priority}

@ -14,9 +14,11 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import {
bulkDeleteDownloadClients,
bulkEditDownloadClients,
setManageDownloadClientsSort,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
@ -80,6 +82,8 @@ const COLUMNS = [
interface ManageDownloadClientsModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageDownloadClientsModalContent(
@ -94,6 +98,8 @@ function ManageDownloadClientsModalContent(
isSaving,
error,
items,
sortKey,
sortDirection,
}: DownloadClientAppState = useSelector(
createClientSideCollectionSelector('settings.downloadClients')
);
@ -114,6 +120,13 @@ function ManageDownloadClientsModalContent(
const selectedCount = selectedIds.length;
const onSortPress = useCallback(
(value: string) => {
dispatch(setManageDownloadClientsSort({ sortKey: value }));
},
[dispatch]
);
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
@ -219,6 +232,9 @@ function ManageDownloadClientsModalContent(
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
>
<TableBody>
{items.map((item) => {

@ -61,10 +61,12 @@ function DownloadClientOptions(props) {
legend={translate('FailedDownloadHandling')}
>
<Form>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>
{translate('Redownload')}
</FormLabel>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('AutoRedownloadFailed')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
@ -74,7 +76,28 @@ function DownloadClientOptions(props) {
{...settings.autoRedownloadFailed}
/>
</FormGroup>
{
settings.autoRedownloadFailed.value ?
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('AutoRedownloadFailedFromInteractiveSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="autoRedownloadFailedFromInteractiveSearch"
helpText={translate('AutoRedownloadFailedFromInteractiveSearchHelpText')}
onChange={onInputChange}
{...settings.autoRedownloadFailedFromInteractiveSearch}
/>
</FormGroup> :
null
}
</Form>
<Alert kind={kinds.INFO}>
{translate('RemoveDownloadsAlert')}
</Alert>

@ -8,11 +8,13 @@
}
.artistName {
flex: 0 0 300px;
@add-mixin truncate;
flex: 0 1 600px;
}
.foreignId {
flex: 0 0 400px;
flex: 0 0 290px;
}
.actions {

@ -4,12 +4,12 @@
font-weight: bold;
}
.host {
flex: 0 0 300px;
.name {
flex: 0 1 600px;
}
.path {
flex: 0 0 400px;
.foreignId {
flex: 0 0 290px;
}
.addImportListExclusion {

@ -3,9 +3,9 @@
interface CssExports {
'addButton': string;
'addImportListExclusion': string;
'host': string;
'foreignId': string;
'importListExclusionsHeader': string;
'path': string;
'name': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -51,8 +51,10 @@ class ImportListExclusions extends Component {
{...otherProps}
>
<div className={styles.importListExclusionsHeader}>
<div className={styles.host}>{translate('Name')}</div>
<div className={styles.path}>
<div className={styles.name}>
{translate('Name')}
</div>
<div className={styles.foreignId}>
{translate('ForeignId')}
</div>
</div>

@ -14,9 +14,11 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import {
bulkDeleteIndexers,
bulkEditIndexers,
setManageIndexersSort,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
@ -80,6 +82,8 @@ const COLUMNS = [
interface ManageIndexersModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
@ -92,6 +96,8 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
isSaving,
error,
items,
sortKey,
sortDirection,
}: IndexerAppState = useSelector(
createClientSideCollectionSelector('settings.indexers')
);
@ -112,6 +118,13 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
const selectedCount = selectedIds.length;
const onSortPress = useCallback(
(value: string) => {
dispatch(setManageIndexersSort({ sortKey: value }));
},
[dispatch]
);
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
@ -214,6 +227,9 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
>
<TableBody>
{items.map((item) => {

@ -265,26 +265,24 @@ class MediaManagement extends Component {
</FormGroup>
{
settings.importExtraFiles.value &&
settings.importExtraFiles.value ?
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>
{translate('ImportExtraFiles')}
</FormLabel>
<FormLabel>{translate('ImportExtraFiles')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="extraFileExtensions"
helpTexts={[
translate('ExtraFileExtensionsHelpTexts1'),
translate('ExtraFileExtensionsHelpTexts2')
translate('ExtraFileExtensionsHelpText'),
translate('ExtraFileExtensionsHelpTextsExamples')
]}
onChange={onInputChange}
{...settings.extraFileExtensions}
/>
</FormGroup>
</FormGroup> : null
}
</FieldSet>
}

@ -60,8 +60,9 @@ class Notification extends Component {
onReleaseImport,
onUpgrade,
onRename,
onAlbumDelete,
onArtistAdd,
onArtistDelete,
onAlbumDelete,
onHealthIssue,
onHealthRestored,
onDownloadFailure,
@ -72,8 +73,9 @@ class Notification extends Component {
supportsOnReleaseImport,
supportsOnUpgrade,
supportsOnRename,
supportsOnAlbumDelete,
supportsOnArtistAdd,
supportsOnArtistDelete,
supportsOnAlbumDelete,
supportsOnHealthIssue,
supportsOnHealthRestored,
supportsOnDownloadFailure,
@ -95,59 +97,75 @@ class Notification extends Component {
</div>
{
supportsOnGrab && onGrab &&
supportsOnGrab && onGrab ?
<Label kind={kinds.SUCCESS}>
{translate('OnGrab')}
</Label>
</Label> :
null
}
{
supportsOnReleaseImport && onReleaseImport &&
supportsOnReleaseImport && onReleaseImport ?
<Label kind={kinds.SUCCESS}>
{translate('OnReleaseImport')}
</Label>
</Label> :
null
}
{
supportsOnUpgrade && onReleaseImport && onUpgrade &&
supportsOnUpgrade && onReleaseImport && onUpgrade ?
<Label kind={kinds.SUCCESS}>
{translate('OnUpgrade')}
</Label>
</Label> :
null
}
{
supportsOnRename && onRename &&
supportsOnRename && onRename ?
<Label kind={kinds.SUCCESS}>
{translate('OnRename')}
</Label>
</Label> :
null
}
{
supportsOnTrackRetag && onTrackRetag &&
supportsOnTrackRetag && onTrackRetag ?
<Label kind={kinds.SUCCESS}>
{translate('OnTrackRetag')}
</Label>
</Label> :
null
}
{
supportsOnAlbumDelete && onAlbumDelete &&
supportsOnArtistAdd && onArtistAdd ?
<Label kind={kinds.SUCCESS}>
{translate('OnAlbumDelete')}
</Label>
{translate('OnArtistAdd')}
</Label> :
null
}
{
supportsOnArtistDelete && onArtistDelete &&
supportsOnArtistDelete && onArtistDelete ?
<Label kind={kinds.SUCCESS}>
{translate('OnArtistDelete')}
</Label>
</Label> :
null
}
{
supportsOnAlbumDelete && onAlbumDelete ?
<Label kind={kinds.SUCCESS}>
{translate('OnAlbumDelete')}
</Label> :
null
}
{
supportsOnHealthIssue && onHealthIssue &&
supportsOnHealthIssue && onHealthIssue ?
<Label kind={kinds.SUCCESS}>
{translate('OnHealthIssue')}
</Label>
</Label> :
null
}
{
@ -159,35 +177,38 @@ class Notification extends Component {
}
{
supportsOnDownloadFailure && onDownloadFailure &&
supportsOnDownloadFailure && onDownloadFailure ?
<Label kind={kinds.SUCCESS} >
{translate('OnDownloadFailure')}
</Label>
</Label> :
null
}
{
supportsOnImportFailure && onImportFailure &&
supportsOnImportFailure && onImportFailure ?
<Label kind={kinds.SUCCESS} >
{translate('OnImportFailure')}
</Label>
</Label> :
null
}
{
supportsOnApplicationUpdate && onApplicationUpdate &&
supportsOnApplicationUpdate && onApplicationUpdate ?
<Label kind={kinds.SUCCESS} >
{translate('OnApplicationUpdate')}
</Label>
</Label> :
null
}
{
!onGrab && !onReleaseImport && !onRename && !onTrackRetag && !onAlbumDelete && !onArtistDelete &&
!onHealthIssue && !onHealthRestored && !onDownloadFailure && !onImportFailure && !onApplicationUpdate &&
<Label
kind={kinds.DISABLED}
outline={true}
>
{translate('Disabled')}
</Label>
!onGrab && !onReleaseImport && !onRename && !onTrackRetag && !onArtistAdd && !onArtistDelete && !onAlbumDelete && !onHealthIssue && !onHealthRestored && !onDownloadFailure && !onImportFailure && !onApplicationUpdate ?
<Label
kind={kinds.DISABLED}
outline={true}
>
{translate('Disabled')}
</Label> :
null
}
<TagList
@ -223,8 +244,9 @@ Notification.propTypes = {
onReleaseImport: PropTypes.bool.isRequired,
onUpgrade: PropTypes.bool.isRequired,
onRename: PropTypes.bool.isRequired,
onAlbumDelete: PropTypes.bool.isRequired,
onArtistAdd: PropTypes.bool.isRequired,
onArtistDelete: PropTypes.bool.isRequired,
onAlbumDelete: PropTypes.bool.isRequired,
onHealthIssue: PropTypes.bool.isRequired,
onHealthRestored: PropTypes.bool.isRequired,
onDownloadFailure: PropTypes.bool.isRequired,
@ -235,8 +257,9 @@ Notification.propTypes = {
supportsOnReleaseImport: PropTypes.bool.isRequired,
supportsOnUpgrade: PropTypes.bool.isRequired,
supportsOnRename: PropTypes.bool.isRequired,
supportsOnAlbumDelete: PropTypes.bool.isRequired,
supportsOnArtistAdd: PropTypes.bool.isRequired,
supportsOnArtistDelete: PropTypes.bool.isRequired,
supportsOnAlbumDelete: PropTypes.bool.isRequired,
supportsOnHealthIssue: PropTypes.bool.isRequired,
supportsOnHealthRestored: PropTypes.bool.isRequired,
supportsOnDownloadFailure: PropTypes.bool.isRequired,

@ -19,8 +19,9 @@ function NotificationEventItems(props) {
onReleaseImport,
onUpgrade,
onRename,
onAlbumDelete,
onArtistAdd,
onArtistDelete,
onAlbumDelete,
onHealthIssue,
onHealthRestored,
onDownloadFailure,
@ -31,8 +32,9 @@ function NotificationEventItems(props) {
supportsOnReleaseImport,
supportsOnUpgrade,
supportsOnRename,
supportsOnAlbumDelete,
supportsOnArtistAdd,
supportsOnArtistDelete,
supportsOnAlbumDelete,
supportsOnHealthIssue,
supportsOnHealthRestored,
includeHealthWarnings,
@ -57,7 +59,7 @@ function NotificationEventItems(props) {
<FormInputGroup
type={inputTypes.CHECK}
name="onGrab"
helpText={translate('OnGrabHelpText')}
helpText={translate('OnGrab')}
isDisabled={!supportsOnGrab.value}
{...onGrab}
onChange={onInputChange}
@ -68,7 +70,7 @@ function NotificationEventItems(props) {
<FormInputGroup
type={inputTypes.CHECK}
name="onReleaseImport"
helpText={translate('OnReleaseImportHelpText')}
helpText={translate('OnReleaseImport')}
isDisabled={!supportsOnReleaseImport.value}
{...onReleaseImport}
onChange={onInputChange}
@ -81,7 +83,7 @@ function NotificationEventItems(props) {
<FormInputGroup
type={inputTypes.CHECK}
name="onUpgrade"
helpText={translate('OnUpgradeHelpText')}
helpText={translate('OnUpgrade')}
isDisabled={!supportsOnUpgrade.value}
{...onUpgrade}
onChange={onInputChange}
@ -93,7 +95,7 @@ function NotificationEventItems(props) {
<FormInputGroup
type={inputTypes.CHECK}
name="onDownloadFailure"
helpText={translate('OnDownloadFailureHelpText')}
helpText={translate('OnDownloadFailure')}
isDisabled={!supportsOnDownloadFailure.value}
{...onDownloadFailure}
onChange={onInputChange}
@ -104,7 +106,7 @@ function NotificationEventItems(props) {
<FormInputGroup
type={inputTypes.CHECK}
name="onImportFailure"
helpText={translate('OnImportFailureHelpText')}
helpText={translate('OnImportFailure')}
isDisabled={!supportsOnImportFailure.value}
{...onImportFailure}
onChange={onInputChange}
@ -115,7 +117,7 @@ function NotificationEventItems(props) {
<FormInputGroup
type={inputTypes.CHECK}
name="onRename"
helpText={translate('OnRenameHelpText')}
helpText={translate('OnRename')}
isDisabled={!supportsOnRename.value}
{...onRename}
onChange={onInputChange}
@ -126,7 +128,7 @@ function NotificationEventItems(props) {
<FormInputGroup
type={inputTypes.CHECK}
name="onTrackRetag"
helpText={translate('OnTrackRetagHelpText')}
helpText={translate('OnTrackRetag')}
isDisabled={!supportsOnTrackRetag.value}
{...onTrackRetag}
onChange={onInputChange}
@ -136,10 +138,10 @@ function NotificationEventItems(props) {
<div>
<FormInputGroup
type={inputTypes.CHECK}
name="onAlbumDelete"
helpText={translate('OnAlbumDeleteHelpText')}
isDisabled={!supportsOnAlbumDelete.value}
{...onAlbumDelete}
name="onArtistAdd"
helpText={translate('OnArtistAdd')}
isDisabled={!supportsOnArtistAdd.value}
{...onArtistAdd}
onChange={onInputChange}
/>
</div>
@ -148,18 +150,29 @@ function NotificationEventItems(props) {
<FormInputGroup
type={inputTypes.CHECK}
name="onArtistDelete"
helpText={translate('OnArtistDeleteHelpText')}
helpText={translate('OnArtistDelete')}
isDisabled={!supportsOnArtistDelete.value}
{...onArtistDelete}
onChange={onInputChange}
/>
</div>
<div>
<FormInputGroup
type={inputTypes.CHECK}
name="onAlbumDelete"
helpText={translate('OnAlbumDelete')}
isDisabled={!supportsOnAlbumDelete.value}
{...onAlbumDelete}
onChange={onInputChange}
/>
</div>
<div>
<FormInputGroup
type={inputTypes.CHECK}
name="onApplicationUpdate"
helpText={translate('OnApplicationUpdateHelpText')}
helpText={translate('OnApplicationUpdate')}
isDisabled={!supportsOnApplicationUpdate.value}
{...onApplicationUpdate}
onChange={onInputChange}
@ -170,7 +183,7 @@ function NotificationEventItems(props) {
<FormInputGroup
type={inputTypes.CHECK}
name="onHealthIssue"
helpText={translate('OnHealthIssueHelpText')}
helpText={translate('OnHealthIssue')}
isDisabled={!supportsOnHealthIssue.value}
{...onHealthIssue}
onChange={onInputChange}
@ -181,7 +194,7 @@ function NotificationEventItems(props) {
<FormInputGroup
type={inputTypes.CHECK}
name="onHealthRestored"
helpText={translate('OnHealthRestoredHelpText')}
helpText={translate('OnHealthRestored')}
isDisabled={!supportsOnHealthRestored.value}
{...onHealthRestored}
onChange={onInputChange}
@ -194,7 +207,7 @@ function NotificationEventItems(props) {
<FormInputGroup
type={inputTypes.CHECK}
name="includeHealthWarnings"
helpText={translate('IncludeHealthWarningsHelpText')}
helpText={translate('IncludeHealthWarnings')}
isDisabled={!supportsOnHealthIssue.value}
{...includeHealthWarnings}
onChange={onInputChange}

@ -31,7 +31,7 @@
background-color: var(--sliderAccentColor);
box-shadow: 0 0 0 #000;
&:nth-child(odd) {
&:nth-child(3n+1) {
background-color: #ddd;
}
}
@ -56,7 +56,7 @@
.kilobitsPerSecond {
display: flex;
justify-content: space-between;
flex: 0 0 250px;
flex: 0 0 400px;
}
.sizeInput {

@ -50,21 +50,24 @@ class QualityDefinition extends Component {
this.state = {
sliderMinSize: getSliderValue(props.minSize, slider.min),
sliderMaxSize: getSliderValue(props.maxSize, slider.max)
sliderMaxSize: getSliderValue(props.maxSize, slider.max),
sliderPreferredSize: getSliderValue(props.preferredSize, (slider.max - 3))
};
}
//
// Listeners
onSliderChange = ([sliderMinSize, sliderMaxSize]) => {
onSliderChange = ([sliderMinSize, sliderPreferredSize, sliderMaxSize]) => {
this.setState({
sliderMinSize,
sliderMaxSize
sliderMaxSize,
sliderPreferredSize
});
this.props.onSizeChange({
minSize: roundNumber(Math.pow(sliderMinSize, 1.1)),
preferredSize: sliderPreferredSize === (slider.max - 3) ? null : roundNumber(Math.pow(sliderPreferredSize, 1.1)),
maxSize: sliderMaxSize === slider.max ? null : roundNumber(Math.pow(sliderMaxSize, 1.1))
});
};
@ -72,12 +75,14 @@ class QualityDefinition extends Component {
onAfterSliderChange = () => {
const {
minSize,
maxSize
maxSize,
preferredSize
} = this.props;
this.setState({
sliderMiSize: getSliderValue(minSize, slider.min),
sliderMaxSize: getSliderValue(maxSize, slider.max)
sliderMaxSize: getSliderValue(maxSize, slider.max),
sliderPreferredSize: getSliderValue(preferredSize, (slider.max - 3)) // fix
});
};
@ -90,7 +95,22 @@ class QualityDefinition extends Component {
this.props.onSizeChange({
minSize,
maxSize: this.props.maxSize
maxSize: this.props.maxSize,
preferredSize: this.props.preferredSize
});
};
onPreferredSizeChange = ({ value }) => {
const preferredSize = value === (MAX - 3) ? null : getValue(value);
this.setState({
sliderPreferredSize: getSliderValue(preferredSize, slider.preferred)
});
this.props.onSizeChange({
minSize: this.props.minSize,
maxSize: this.props.maxSize,
preferredSize
});
};
@ -103,7 +123,8 @@ class QualityDefinition extends Component {
this.props.onSizeChange({
minSize: this.props.minSize,
maxSize
maxSize,
preferredSize: this.props.preferredSize
});
};
@ -117,20 +138,25 @@ class QualityDefinition extends Component {
title,
minSize,
maxSize,
preferredSize,
advancedSettings,
onTitleChange
} = this.props;
const {
sliderMinSize,
sliderMaxSize
sliderMaxSize,
sliderPreferredSize
} = this.state;
const minBytes = minSize * 128;
const maxBytes = maxSize && maxSize * 128;
const minRate = `${formatBytes(minBytes, true)}/s`;
const maxRate = maxBytes ? `${formatBytes(maxBytes, true)}/s` : 'Unlimited';
const preferredBytes = preferredSize * 128;
const preferredRate = preferredBytes ? `${formatBytes(preferredBytes, true)}/s` : translate('Unlimited');
const maxBytes = maxSize && maxSize * 128;
const maxRate = maxBytes ? `${formatBytes(maxBytes, true)}/s` : translate('Unlimited');
return (
<div className={styles.qualityDefinition}>
@ -151,9 +177,10 @@ class QualityDefinition extends Component {
min={slider.min}
max={slider.max}
step={slider.step}
minDistance={MIN_DISTANCE * 5}
value={[sliderMinSize, sliderMaxSize]}
minDistance={3}
value={[sliderMinSize, sliderPreferredSize, sliderMaxSize]}
withTracks={true}
allowCross={false}
snapDragDisabled={true}
className={styles.slider}
trackClassName={styles.bar}
@ -172,7 +199,23 @@ class QualityDefinition extends Component {
body={
<QualityDefinitionLimits
bytes={minBytes}
message={translate('NoMinimumForAnyRuntime')}
message={translate('NoMinimumForAnyDuration')}
/>
}
position={tooltipPositions.BOTTOM}
/>
</div>
<div>
<Popover
anchor={
<Label kind={kinds.SUCCESS}>{preferredRate}</Label>
}
title={translate('PreferredSize')}
body={
<QualityDefinitionLimits
bytes={preferredBytes}
message={translate('NoLimitForAnyDuration')}
/>
}
position={tooltipPositions.BOTTOM}
@ -188,7 +231,7 @@ class QualityDefinition extends Component {
body={
<QualityDefinitionLimits
bytes={maxBytes}
message={translate('NoLimitForAnyRuntime')}
message={translate('NoLimitForAnyDuration')}
/>
}
position={tooltipPositions.BOTTOM}
@ -201,14 +244,14 @@ class QualityDefinition extends Component {
advancedSettings &&
<div className={styles.kilobitsPerSecond}>
<div>
Min
{translate('Min')}
<NumberInput
className={styles.sizeInput}
name={`${id}.min`}
value={minSize || MIN}
min={MIN}
max={maxSize ? maxSize - MIN_DISTANCE : MAX - MIN_DISTANCE}
max={preferredSize ? preferredSize - 5 : MAX - 5}
step={0.1}
isFloat={true}
onChange={this.onMinSizeChange}
@ -216,7 +259,22 @@ class QualityDefinition extends Component {
</div>
<div>
Max
{translate('Preferred')}
<NumberInput
className={styles.sizeInput}
name={`${id}.min`}
value={preferredSize || MAX - 5}
min={MIN}
max={maxSize ? maxSize - 5 : MAX - 5}
step={0.1}
isFloat={true}
onChange={this.onPreferredSizeChange}
/>
</div>
<div>
{translate('Max')}
<NumberInput
className={styles.sizeInput}
@ -242,6 +300,7 @@ QualityDefinition.propTypes = {
title: PropTypes.string.isRequired,
minSize: PropTypes.number,
maxSize: PropTypes.number,
preferredSize: PropTypes.number,
advancedSettings: PropTypes.bool.isRequired,
onTitleChange: PropTypes.func.isRequired,
onSizeChange: PropTypes.func.isRequired

@ -23,11 +23,12 @@ class QualityDefinitionConnector extends Component {
this.props.setQualityDefinitionValue({ id: this.props.id, name: 'title', value });
};
onSizeChange = ({ minSize, maxSize }) => {
onSizeChange = ({ minSize, maxSize, preferredSize }) => {
const {
id,
minSize: currentMinSize,
maxSize: currentMaxSize
maxSize: currentMaxSize,
preferredSize: currentPreferredSize
} = this.props;
if (minSize !== currentMinSize) {
@ -37,6 +38,10 @@ class QualityDefinitionConnector extends Component {
if (maxSize !== currentMaxSize) {
this.props.setQualityDefinitionValue({ id, name: 'maxSize', value: maxSize });
}
if (preferredSize !== currentPreferredSize) {
this.props.setQualityDefinitionValue({ id, name: 'preferredSize', value: preferredSize });
}
};
//
@ -57,6 +62,7 @@ QualityDefinitionConnector.propTypes = {
id: PropTypes.number.isRequired,
minSize: PropTypes.number,
maxSize: PropTypes.number,
preferredSize: PropTypes.number,
setQualityDefinitionValue: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};

@ -1,4 +1,5 @@
import { createAction } from 'redux-actions';
import { sortDirections } from 'Helpers/Props';
import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
@ -7,6 +8,7 @@ import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHand
import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
import createSetClientSideCollectionSortReducer from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer';
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
@ -33,6 +35,7 @@ export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestD
export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients';
export const BULK_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients';
export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients';
export const SET_MANAGE_DOWNLOAD_CLIENTS_SORT = 'settings/downloadClients/setManageDownloadClientsSort';
//
// Action Creators
@ -49,6 +52,7 @@ export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT)
export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS);
export const bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS);
export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS);
export const setManageDownloadClientsSort = createAction(SET_MANAGE_DOWNLOAD_CLIENTS_SORT);
export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => {
return {
@ -88,7 +92,14 @@ export default {
isTesting: false,
isTestingAll: false,
items: [],
pendingChanges: {}
pendingChanges: {},
sortKey: 'name',
sortDirection: sortDirections.ASCENDING,
sortPredicates: {
name: function(item) {
return item.name.toLowerCase();
}
}
},
//
@ -122,7 +133,10 @@ export default {
return selectedSchema;
});
}
},
[SET_MANAGE_DOWNLOAD_CLIENTS_SORT]: createSetClientSideCollectionSortReducer(section)
}
};

@ -1,4 +1,5 @@
import { createAction } from 'redux-actions';
import { sortDirections } from 'Helpers/Props';
import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
@ -7,6 +8,7 @@ import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHand
import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
import createSetClientSideCollectionSortReducer from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer';
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
@ -36,6 +38,7 @@ export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer';
export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers';
export const BULK_EDIT_INDEXERS = 'settings/indexers/bulkEditIndexers';
export const BULK_DELETE_INDEXERS = 'settings/indexers/bulkDeleteIndexers';
export const SET_MANAGE_INDEXERS_SORT = 'settings/indexers/setManageIndexersSort';
//
// Action Creators
@ -53,6 +56,7 @@ export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER);
export const testAllIndexers = createThunk(TEST_ALL_INDEXERS);
export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS);
export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS);
export const setManageIndexersSort = createAction(SET_MANAGE_INDEXERS_SORT);
export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => {
return {
@ -92,7 +96,14 @@ export default {
isTesting: false,
isTestingAll: false,
items: [],
pendingChanges: {}
pendingChanges: {},
sortKey: 'name',
sortDirection: sortDirections.ASCENDING,
sortPredicates: {
name: function(item) {
return item.name.toLowerCase();
}
}
},
//
@ -142,7 +153,13 @@ export default {
delete selectedSchema.name;
selectedSchema.fields = selectedSchema.fields.map((field) => {
return { ...field };
const newField = { ...field };
if (newField.privacy === 'apiKey' || newField.privacy === 'password') {
newField.value = '';
}
return newField;
});
newState.selectedSchema = selectedSchema;
@ -153,7 +170,10 @@ export default {
};
return updateSectionState(state, section, newState);
}
},
[SET_MANAGE_INDEXERS_SORT]: createSetClientSideCollectionSortReducer(section)
}
};

@ -107,6 +107,8 @@ export default {
selectedSchema.onReleaseImport = selectedSchema.supportsOnReleaseImport;
selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade;
selectedSchema.onRename = selectedSchema.supportsOnRename;
selectedSchema.onArtistAdd = selectedSchema.supportsOnArtistAdd;
selectedSchema.onArtistDelete = selectedSchema.supportsOnArtistDelete;
selectedSchema.onHealthIssue = selectedSchema.supportsOnHealthIssue;
selectedSchema.onDownloadFailure = selectedSchema.supportsOnDownloadFailure;
selectedSchema.onImportFailure = selectedSchema.supportsOnImportFailure;

@ -176,7 +176,7 @@ export const defaultState = {
const {
trackCount = 0,
trackFileCount
trackFileCount = 0
} = statistics;
const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
@ -201,7 +201,7 @@ export const defaultState = {
albumCount: function(item) {
const { statistics = {} } = item;
return statistics.albumCount;
return statistics.albumCount || 0;
},
trackCount: function(item) {
@ -229,7 +229,7 @@ export const defaultState = {
const {
trackCount = 0,
trackFileCount
trackFileCount = 0
} = statistics;
const progress = trackCount ?

@ -18,6 +18,7 @@ export const section = 'interactiveImport';
const albumsSection = `${section}.albums`;
const trackFilesSection = `${section}.trackFiles`;
let abortCurrentFetchRequest = null;
let abortCurrentRequest = null;
let currentIds = [];
@ -35,6 +36,8 @@ export const defaultState = {
pendingChanges: {},
sortKey: 'path',
sortDirection: sortDirections.ASCENDING,
secondarySortKey: 'path',
secondarySortDirection: sortDirections.ASCENDING,
recentFolders: [],
importMode: 'chooseImportMode',
sortPredicates: {
@ -75,6 +78,8 @@ export const defaultState = {
};
export const persistState = [
'interactiveImport.sortKey',
'interactiveImport.sortDirection',
'interactiveImport.recentFolders',
'interactiveImport.importMode'
];
@ -123,6 +128,11 @@ export const clearInteractiveImportTrackFiles = createAction(CLEAR_INTERACTIVE_I
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_INTERACTIVE_IMPORT_ITEMS]: function(getState, payload, dispatch) {
if (abortCurrentFetchRequest) {
abortCurrentFetchRequest();
abortCurrentFetchRequest = null;
}
if (!payload.downloadId && !payload.folder) {
dispatch(set({ section, error: { message: '`downloadId` or `folder` is required.' } }));
return;
@ -130,12 +140,14 @@ export const actionHandlers = handleThunks({
dispatch(set({ section, isFetching: true }));
const promise = createAjaxRequest({
const { request, abortRequest } = createAjaxRequest({
url: '/manualimport',
data: payload
}).request;
});
abortCurrentFetchRequest = abortRequest;
promise.done((data) => {
request.done((data) => {
dispatch(batchActions([
update({ section, data }),
@ -148,7 +160,11 @@ export const actionHandlers = handleThunks({
]));
});
promise.fail((xhr) => {
request.fail((xhr) => {
if (xhr.aborted) {
return;
}
dispatch(set({
section,
isFetching: false,

@ -146,6 +146,12 @@ export const defaultState = {
isSortable: true,
isVisible: true
},
{
name: 'added',
label: () => translate('Added'),
isSortable: true,
isVisible: false
},
{
name: 'progress',
label: () => translate('Progress'),
@ -406,13 +412,14 @@ export const actionHandlers = handleThunks({
id,
removeFromClient,
blocklist,
skipRedownload
skipRedownload,
changeCategory
} = payload;
dispatch(updateItem({ section: paged, id, isRemoving: true }));
const promise = createAjaxRequest({
url: `/queue/${id}?removeFromClient=${removeFromClient}&blocklist=${blocklist}&skipRedownload=${skipRedownload}`,
url: `/queue/${id}?removeFromClient=${removeFromClient}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`,
method: 'DELETE'
}).request;
@ -430,7 +437,8 @@ export const actionHandlers = handleThunks({
ids,
removeFromClient,
blocklist,
skipRedownload
skipRedownload,
changeCategory
} = payload;
dispatch(batchActions([
@ -446,7 +454,7 @@ export const actionHandlers = handleThunks({
]));
const promise = createAjaxRequest({
url: `/queue/bulk?removeFromClient=${removeFromClient}&blocklist=${blocklist}&skipRedownload=${skipRedownload}`,
url: `/queue/bulk?removeFromClient=${removeFromClient}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`,
method: 'DELETE',
dataType: 'json',
contentType: 'application/json',

@ -58,6 +58,11 @@ export const defaultState = {
label: () => translate('AudioInfo'),
isVisible: true
},
{
name: 'size',
label: () => translate('Size'),
isVisible: false
},
{
name: 'customFormats',
label: 'Formats',

@ -1,8 +1,9 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createDimensionsSelector() {
return createSelector(
(state) => state.app.dimensions,
(state: AppState) => state.app.dimensions,
(dimensions) => {
return dimensions;
}

@ -22,9 +22,9 @@ class About extends Component {
isNetCore,
isDocker,
runtimeVersion,
migrationVersion,
databaseVersion,
databaseType,
migrationVersion,
appData,
startupPath,
mode,
@ -66,13 +66,13 @@ class About extends Component {
}
<DescriptionListItem
title={translate('DBMigration')}
data={migrationVersion}
title={translate('Database')}
data={`${titleCase(databaseType)} ${databaseVersion}`}
/>
<DescriptionListItem
title={translate('Database')}
data={`${titleCase(databaseType)} ${databaseVersion}`}
title={translate('DatabaseMigration')}
data={migrationVersion}
/>
<DescriptionListItem
@ -114,9 +114,9 @@ About.propTypes = {
isNetCore: PropTypes.bool.isRequired,
runtimeVersion: PropTypes.string.isRequired,
isDocker: PropTypes.bool.isRequired,
migrationVersion: PropTypes.number.isRequired,
databaseType: PropTypes.string.isRequired,
databaseVersion: PropTypes.string.isRequired,
migrationVersion: PropTypes.number.isRequired,
appData: PropTypes.string.isRequired,
startupPath: PropTypes.string.isRequired,
mode: PropTypes.string.isRequired,

@ -53,7 +53,7 @@ class CutoffUnmetConnector extends Component {
gotoCutoffUnmetFirstPage
} = this.props;
registerPagePopulator(this.repopulate, ['trackFileUpdated']);
registerPagePopulator(this.repopulate, ['trackFileUpdated', 'trackFileDeleted']);
if (useCurrentPage) {
fetchCutoffUnmet();

@ -50,7 +50,7 @@ class MissingConnector extends Component {
gotoMissingFirstPage
} = this.props;
registerPagePopulator(this.repopulate, ['trackFileUpdated']);
registerPagePopulator(this.repopulate, ['trackFileUpdated', 'trackFileDeleted']);
if (useCurrentPage) {
fetchMissing();

@ -0,0 +1,32 @@
import ModelBase from 'App/ModelBase';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
export interface StatusMessage {
title: string;
messages: string[];
}
interface Queue extends ModelBase {
quality: QualityModel;
customFormats: CustomFormat[];
size: number;
title: string;
sizeleft: number;
timeleft: string;
estimatedCompletionTime: string;
added?: string;
status: string;
trackedDownloadStatus: string;
trackedDownloadState: string;
statusMessages: StatusMessage[];
errorMessage: string;
downloadId: string;
protocol: string;
downloadClient: string;
outputPath: string;
artistId?: number;
albumId?: number;
}
export default Queue;

@ -145,7 +145,7 @@ namespace Lidarr.Api.V1.Artist
MapCoversToLocal(artistsResources.ToArray());
LinkNextPreviousAlbums(artistsResources.ToArray());
LinkArtistStatistics(artistsResources, artistStats);
LinkArtistStatistics(artistsResources, artistStats.ToDictionary(x => x.ArtistId));
artistsResources.ForEach(LinkRootFolderPath);
// PopulateAlternateTitles(seriesResources);
@ -219,17 +219,14 @@ namespace Lidarr.Api.V1.Artist
LinkArtistStatistics(resource, _artistStatisticsService.ArtistStatistics(resource.Id));
}
private void LinkArtistStatistics(List<ArtistResource> resources, List<ArtistStatistics> artistStatistics)
private void LinkArtistStatistics(List<ArtistResource> resources, Dictionary<int, ArtistStatistics> artistStatistics)
{
foreach (var artist in resources)
{
var stats = artistStatistics.SingleOrDefault(ss => ss.ArtistId == artist.Id);
if (stats == null)
if (artistStatistics.TryGetValue(artist.Id, out var stats))
{
continue;
LinkArtistStatistics(artist, stats);
}
LinkArtistStatistics(artist, stats);
}
}

@ -1,4 +1,4 @@
using Lidarr.Http.REST;
using Lidarr.Http.REST;
using NzbDrone.Core.Configuration;
namespace Lidarr.Api.V1.Config
@ -8,6 +8,7 @@ namespace Lidarr.Api.V1.Config
public string DownloadClientWorkingFolders { get; set; }
public bool EnableCompletedDownloadHandling { get; set; }
public bool AutoRedownloadFailed { get; set; }
public bool AutoRedownloadFailedFromInteractiveSearch { get; set; }
}
public static class DownloadClientConfigResourceMapper
@ -19,7 +20,8 @@ namespace Lidarr.Api.V1.Config
DownloadClientWorkingFolders = model.DownloadClientWorkingFolders,
EnableCompletedDownloadHandling = model.EnableCompletedDownloadHandling,
AutoRedownloadFailed = model.AutoRedownloadFailed
AutoRedownloadFailed = model.AutoRedownloadFailed,
AutoRedownloadFailedFromInteractiveSearch = model.AutoRedownloadFailedFromInteractiveSearch
};
}
}

@ -68,15 +68,14 @@ namespace Lidarr.Api.V1.History
[HttpGet]
[Produces("application/json")]
public PagingResource<HistoryResource> GetHistory([FromQuery] PagingRequestResource paging, bool includeArtist, bool includeAlbum, bool includeTrack, int? eventType, int? albumId, string downloadId, [FromQuery] int[] artistIds = null, [FromQuery] int[] quality = null)
public PagingResource<HistoryResource> GetHistory([FromQuery] PagingRequestResource paging, bool includeArtist, bool includeAlbum, bool includeTrack, [FromQuery(Name = "eventType")] int[] eventTypes, int? albumId, string downloadId, [FromQuery] int[] artistIds = null, [FromQuery] int[] quality = null)
{
var pagingResource = new PagingResource<HistoryResource>(paging);
var pagingSpec = pagingResource.MapToPagingSpec<HistoryResource, EntityHistory>("date", SortDirection.Descending);
if (eventType.HasValue)
if (eventTypes != null && eventTypes.Any())
{
var filterValue = (EntityHistoryEventType)eventType.Value;
pagingSpec.FilterExpressions.Add(v => v.EventType == filterValue);
pagingSpec.FilterExpressions.Add(v => eventTypes.Contains((int)v.EventType));
}
if (albumId.HasValue)

@ -127,7 +127,7 @@ namespace Lidarr.Api.V1.Indexers
throw new NzbDroneClientException(HttpStatusCode.NotFound, "Unable to parse albums in the release");
}
await _downloadService.DownloadReport(remoteAlbum);
await _downloadService.DownloadReport(remoteAlbum, release.DownloadClientId);
}
catch (ReleaseDownloadException ex)
{

@ -21,6 +21,7 @@ namespace Lidarr.Api.V1.Indexers
private readonly IMakeDownloadDecision _downloadDecisionMaker;
private readonly IProcessDownloadDecisions _downloadDecisionProcessor;
private readonly IIndexerFactory _indexerFactory;
private readonly IDownloadClientFactory _downloadClientFactory;
private readonly Logger _logger;
private static readonly object PushLock = new object();
@ -28,6 +29,7 @@ namespace Lidarr.Api.V1.Indexers
public ReleasePushController(IMakeDownloadDecision downloadDecisionMaker,
IProcessDownloadDecisions downloadDecisionProcessor,
IIndexerFactory indexerFactory,
IDownloadClientFactory downloadClientFactory,
IQualityProfileService qualityProfileService,
Logger logger)
: base(qualityProfileService)
@ -35,6 +37,7 @@ namespace Lidarr.Api.V1.Indexers
_downloadDecisionMaker = downloadDecisionMaker;
_downloadDecisionProcessor = downloadDecisionProcessor;
_indexerFactory = indexerFactory;
_downloadClientFactory = downloadClientFactory;
_logger = logger;
PostValidator.RuleFor(s => s.Title).NotEmpty();
@ -44,6 +47,7 @@ namespace Lidarr.Api.V1.Indexers
}
[HttpPost]
[Consumes("application/json")]
public ActionResult<ReleaseResource> Create(ReleaseResource release)
{
_logger.Info("Release pushed: {0} - {1}", release.Title, release.DownloadUrl);
@ -56,22 +60,25 @@ namespace Lidarr.Api.V1.Indexers
ResolveIndexer(info);
List<DownloadDecision> decisions;
var downloadClientId = ResolveDownloadClientId(release);
DownloadDecision decision;
lock (PushLock)
{
decisions = _downloadDecisionMaker.GetRssDecision(new List<ReleaseInfo> { info });
_downloadDecisionProcessor.ProcessDecisions(decisions).GetAwaiter().GetResult();
}
var decisions = _downloadDecisionMaker.GetRssDecision(new List<ReleaseInfo> { info }, true);
var firstDecision = decisions.FirstOrDefault();
decision = decisions.FirstOrDefault();
_downloadDecisionProcessor.ProcessDecision(decision, downloadClientId).GetAwaiter().GetResult();
}
if (firstDecision?.RemoteAlbum.ParsedAlbumInfo == null)
if (decision?.RemoteAlbum.ParsedAlbumInfo == null)
{
throw new ValidationException(new List<ValidationFailure> { new ValidationFailure("Title", "Unable to parse", release.Title) });
throw new ValidationException(new List<ValidationFailure> { new ("Title", "Unable to parse", release.Title) });
}
return MapDecisions(new[] { firstDecision }).First();
return MapDecisions(new[] { decision }).First();
}
private void ResolveIndexer(ReleaseInfo release)
@ -86,7 +93,7 @@ namespace Lidarr.Api.V1.Indexers
}
else
{
_logger.Debug("Push Release {0} not associated with unknown indexer {1}.", release.Title, release.Indexer);
_logger.Debug("Push Release {0} not associated with known indexer {1}.", release.Title, release.Indexer);
}
}
else if (release.IndexerId != 0 && release.Indexer.IsNullOrWhiteSpace())
@ -99,7 +106,7 @@ namespace Lidarr.Api.V1.Indexers
}
catch (ModelNotFoundException)
{
_logger.Debug("Push Release {0} not associated with unknown indexer {1}.", release.Title, release.IndexerId);
_logger.Debug("Push Release {0} not associated with known indexer {1}.", release.Title, release.IndexerId);
release.IndexerId = 0;
}
}
@ -108,5 +115,26 @@ namespace Lidarr.Api.V1.Indexers
_logger.Debug("Push Release {0} not associated with an indexer.", release.Title);
}
}
private int? ResolveDownloadClientId(ReleaseResource release)
{
var downloadClientId = release.DownloadClientId.GetValueOrDefault();
if (downloadClientId == 0 && release.DownloadClient.IsNotNullOrWhiteSpace())
{
var downloadClient = _downloadClientFactory.All().FirstOrDefault(v => v.Name.EqualsIgnoreCase(release.DownloadClient));
if (downloadClient != null)
{
_logger.Debug("Push Release {0} associated with download client {1} - {2}.", release.Title, downloadClientId, release.DownloadClient);
return downloadClient.Id;
}
_logger.Debug("Push Release {0} not associated with known download client {1}.", release.Title, release.DownloadClient);
}
return release.DownloadClientId;
}
}
}

@ -60,6 +60,12 @@ namespace Lidarr.Api.V1.Indexers
// [JsonIgnore]
public int? AlbumId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int? DownloadClientId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public string DownloadClient { get; set; }
}
public static class ReleaseResourceMapper

@ -9,8 +9,9 @@ namespace Lidarr.Api.V1.Notifications
public bool OnReleaseImport { get; set; }
public bool OnUpgrade { get; set; }
public bool OnRename { get; set; }
public bool OnAlbumDelete { get; set; }
public bool OnArtistAdd { get; set; }
public bool OnArtistDelete { get; set; }
public bool OnAlbumDelete { get; set; }
public bool OnHealthIssue { get; set; }
public bool OnHealthRestored { get; set; }
public bool OnDownloadFailure { get; set; }
@ -21,8 +22,9 @@ namespace Lidarr.Api.V1.Notifications
public bool SupportsOnReleaseImport { get; set; }
public bool SupportsOnUpgrade { get; set; }
public bool SupportsOnRename { get; set; }
public bool SupportsOnAlbumDelete { get; set; }
public bool SupportsOnArtistAdd { get; set; }
public bool SupportsOnArtistDelete { get; set; }
public bool SupportsOnAlbumDelete { get; set; }
public bool SupportsOnHealthIssue { get; set; }
public bool SupportsOnHealthRestored { get; set; }
public bool IncludeHealthWarnings { get; set; }
@ -48,8 +50,9 @@ namespace Lidarr.Api.V1.Notifications
resource.OnReleaseImport = definition.OnReleaseImport;
resource.OnUpgrade = definition.OnUpgrade;
resource.OnRename = definition.OnRename;
resource.OnAlbumDelete = definition.OnAlbumDelete;
resource.OnArtistAdd = definition.OnArtistAdd;
resource.OnArtistDelete = definition.OnArtistDelete;
resource.OnAlbumDelete = definition.OnAlbumDelete;
resource.OnHealthIssue = definition.OnHealthIssue;
resource.OnHealthRestored = definition.OnHealthRestored;
resource.OnDownloadFailure = definition.OnDownloadFailure;
@ -60,8 +63,9 @@ namespace Lidarr.Api.V1.Notifications
resource.SupportsOnReleaseImport = definition.SupportsOnReleaseImport;
resource.SupportsOnUpgrade = definition.SupportsOnUpgrade;
resource.SupportsOnRename = definition.SupportsOnRename;
resource.SupportsOnAlbumDelete = definition.SupportsOnAlbumDelete;
resource.SupportsOnArtistAdd = definition.SupportsOnArtistAdd;
resource.SupportsOnArtistDelete = definition.SupportsOnArtistDelete;
resource.SupportsOnAlbumDelete = definition.SupportsOnAlbumDelete;
resource.SupportsOnHealthIssue = definition.SupportsOnHealthIssue;
resource.SupportsOnHealthRestored = definition.SupportsOnHealthRestored;
resource.IncludeHealthWarnings = definition.IncludeHealthWarnings;
@ -86,8 +90,9 @@ namespace Lidarr.Api.V1.Notifications
definition.OnReleaseImport = resource.OnReleaseImport;
definition.OnUpgrade = resource.OnUpgrade;
definition.OnRename = resource.OnRename;
definition.OnAlbumDelete = resource.OnAlbumDelete;
definition.OnArtistAdd = resource.OnArtistAdd;
definition.OnArtistDelete = resource.OnArtistDelete;
definition.OnAlbumDelete = resource.OnAlbumDelete;
definition.OnHealthIssue = resource.OnHealthIssue;
definition.OnHealthRestored = resource.OnHealthRestored;
definition.OnDownloadFailure = resource.OnDownloadFailure;
@ -98,8 +103,9 @@ namespace Lidarr.Api.V1.Notifications
definition.SupportsOnReleaseImport = resource.SupportsOnReleaseImport;
definition.SupportsOnUpgrade = resource.SupportsOnUpgrade;
definition.SupportsOnRename = resource.SupportsOnRename;
definition.SupportsOnAlbumDelete = resource.SupportsOnAlbumDelete;
definition.SupportsOnArtistAdd = resource.SupportsOnArtistAdd;
definition.SupportsOnArtistDelete = resource.SupportsOnArtistDelete;
definition.SupportsOnAlbumDelete = resource.SupportsOnAlbumDelete;
definition.SupportsOnHealthIssue = resource.SupportsOnHealthIssue;
definition.SupportsOnHealthRestored = resource.SupportsOnHealthRestored;
definition.IncludeHealthWarnings = resource.IncludeHealthWarnings;

@ -1,7 +1,11 @@
using Lidarr.Api.V1.Albums;
using Lidarr.Api.V1.Artist;
using Lidarr.Api.V1.CustomFormats;
using Lidarr.Http;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Download.Aggregation;
using NzbDrone.Core.Parser;
namespace Lidarr.Api.V1.Parse
@ -10,16 +14,27 @@ namespace Lidarr.Api.V1.Parse
public class ParseController : Controller
{
private readonly IParsingService _parsingService;
private readonly IRemoteAlbumAggregationService _aggregationService;
private readonly ICustomFormatCalculationService _formatCalculator;
public ParseController(IParsingService parsingService)
public ParseController(IParsingService parsingService,
IRemoteAlbumAggregationService aggregationService,
ICustomFormatCalculationService formatCalculator)
{
_parsingService = parsingService;
_aggregationService = aggregationService;
_formatCalculator = formatCalculator;
}
[HttpGet]
[Produces("application/json")]
public ParseResource Parse(string title)
{
if (title.IsNullOrWhiteSpace())
{
return null;
}
var parsedAlbumInfo = Parser.ParseAlbumTitle(title);
if (parsedAlbumInfo == null)
@ -34,12 +49,19 @@ namespace Lidarr.Api.V1.Parse
if (remoteAlbum != null)
{
_aggregationService.Augment(remoteAlbum);
remoteAlbum.CustomFormats = _formatCalculator.ParseCustomFormat(remoteAlbum, 0);
remoteAlbum.CustomFormatScore = remoteAlbum?.Artist?.QualityProfile?.Value.CalculateCustomFormatScore(remoteAlbum.CustomFormats) ?? 0;
return new ParseResource
{
Title = title,
ParsedAlbumInfo = remoteAlbum.ParsedAlbumInfo,
Artist = remoteAlbum.Artist.ToResource(),
Albums = remoteAlbum.Albums.ToResource()
Albums = remoteAlbum.Albums.ToResource(),
CustomFormats = remoteAlbum.CustomFormats?.ToResource(false),
CustomFormatScore = remoteAlbum.CustomFormatScore
};
}
else

@ -1,6 +1,7 @@
using System.Collections.Generic;
using Lidarr.Api.V1.Albums;
using Lidarr.Api.V1.Artist;
using Lidarr.Api.V1.CustomFormats;
using Lidarr.Http.REST;
using NzbDrone.Core.Parser.Model;
@ -12,5 +13,7 @@ namespace Lidarr.Api.V1.Parse
public ParsedAlbumInfo ParsedAlbumInfo { get; set; }
public ArtistResource Artist { get; set; }
public List<AlbumResource> Albums { get; set; }
public List<CustomFormatResource> CustomFormats { get; set; }
public int CustomFormatScore { get; set; }
}
}

@ -15,6 +15,7 @@ namespace Lidarr.Api.V1.Qualities
public double? MinSize { get; set; }
public double? MaxSize { get; set; }
public double? PreferredSize { get; set; }
}
public static class QualityDefinitionResourceMapper
@ -33,7 +34,8 @@ namespace Lidarr.Api.V1.Qualities
Title = model.Title,
Weight = model.Weight,
MinSize = model.MinSize,
MaxSize = model.MaxSize
MaxSize = model.MaxSize,
PreferredSize = model.PreferredSize
};
}
@ -51,7 +53,8 @@ namespace Lidarr.Api.V1.Qualities
Title = resource.Title,
Weight = resource.Weight,
MinSize = resource.MinSize,
MaxSize = resource.MaxSize
MaxSize = resource.MaxSize,
PreferredSize = resource.PreferredSize
};
}

@ -30,7 +30,7 @@ namespace Lidarr.Api.V1.Queue
throw new NotFoundException();
}
await _downloadService.DownloadReport(pendingRelease.RemoteAlbum);
await _downloadService.DownloadReport(pendingRelease.RemoteAlbum, null);
return new { };
}
@ -48,7 +48,7 @@ namespace Lidarr.Api.V1.Queue
throw new NotFoundException();
}
await _downloadService.DownloadReport(pendingRelease.RemoteAlbum);
await _downloadService.DownloadReport(pendingRelease.RemoteAlbum, null);
}
return new { };

@ -65,7 +65,7 @@ namespace Lidarr.Api.V1.Queue
}
[RestDeleteById]
public void RemoveAction(int id, bool removeFromClient = true, bool blocklist = false, bool skipRedownload = false)
public void RemoveAction(int id, bool removeFromClient = true, bool blocklist = false, bool skipRedownload = false, bool changeCategory = false)
{
var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id);
@ -83,12 +83,12 @@ namespace Lidarr.Api.V1.Queue
throw new NotFoundException();
}
Remove(trackedDownload, removeFromClient, blocklist, skipRedownload);
Remove(trackedDownload, removeFromClient, blocklist, skipRedownload, changeCategory);
_trackedDownloadService.StopTracking(trackedDownload.DownloadItem.DownloadId);
}
[HttpDelete("bulk")]
public object RemoveMany([FromBody] QueueBulkResource resource, [FromQuery] bool removeFromClient = true, [FromQuery] bool blocklist = false, [FromQuery] bool skipRedownload = false)
public object RemoveMany([FromBody] QueueBulkResource resource, [FromQuery] bool removeFromClient = true, [FromQuery] bool blocklist = false, [FromQuery] bool skipRedownload = false, [FromQuery] bool changeCategory = false)
{
var trackedDownloadIds = new List<string>();
var pendingToRemove = new List<NzbDrone.Core.Queue.Queue>();
@ -119,7 +119,7 @@ namespace Lidarr.Api.V1.Queue
foreach (var trackedDownload in trackedToRemove.DistinctBy(t => t.DownloadItem.DownloadId))
{
Remove(trackedDownload, removeFromClient, blocklist, skipRedownload);
Remove(trackedDownload, removeFromClient, blocklist, skipRedownload, changeCategory);
trackedDownloadIds.Add(trackedDownload.DownloadItem.DownloadId);
}
@ -181,9 +181,16 @@ namespace Lidarr.Api.V1.Queue
else if (pagingSpec.SortKey == "estimatedCompletionTime")
{
ordered = ascending
? fullQueue.OrderBy(q => q.EstimatedCompletionTime, new EstimatedCompletionTimeComparer())
? fullQueue.OrderBy(q => q.EstimatedCompletionTime, new DatetimeComparer())
: fullQueue.OrderByDescending(q => q.EstimatedCompletionTime,
new EstimatedCompletionTimeComparer());
new DatetimeComparer());
}
else if (pagingSpec.SortKey == "added")
{
ordered = ascending
? fullQueue.OrderBy(q => q.Added, new DatetimeComparer())
: fullQueue.OrderByDescending(q => q.Added,
new DatetimeComparer());
}
else if (pagingSpec.SortKey == "protocol")
{
@ -262,7 +269,7 @@ namespace Lidarr.Api.V1.Queue
_pendingReleaseService.RemovePendingQueueItems(pendingRelease.Id);
}
private TrackedDownload Remove(TrackedDownload trackedDownload, bool removeFromClient, bool blocklist, bool skipRedownload)
private TrackedDownload Remove(TrackedDownload trackedDownload, bool removeFromClient, bool blocklist, bool skipRedownload, bool changeCategory)
{
if (removeFromClient)
{
@ -275,13 +282,24 @@ namespace Lidarr.Api.V1.Queue
downloadClient.RemoveItem(trackedDownload.DownloadItem, true);
}
else if (changeCategory)
{
var downloadClient = _downloadClientProvider.Get(trackedDownload.DownloadClient);
if (downloadClient == null)
{
throw new BadRequestException();
}
downloadClient.MarkItemAsImported(trackedDownload.DownloadItem);
}
if (blocklist)
{
_failedDownloadService.MarkAsFailed(trackedDownload.DownloadItem.DownloadId, skipRedownload);
}
if (!removeFromClient && !blocklist)
if (!removeFromClient && !blocklist && !changeCategory)
{
if (!_ignoredDownloadService.IgnoreDownload(trackedDownload))
{

@ -26,6 +26,7 @@ namespace Lidarr.Api.V1.Queue
public decimal Sizeleft { get; set; }
public TimeSpan? Timeleft { get; set; }
public DateTime? EstimatedCompletionTime { get; set; }
public DateTime? Added { get; set; }
public string Status { get; set; }
public TrackedDownloadStatus? TrackedDownloadStatus { get; set; }
public TrackedDownloadState? TrackedDownloadState { get; set; }
@ -34,6 +35,7 @@ namespace Lidarr.Api.V1.Queue
public string DownloadId { get; set; }
public DownloadProtocol Protocol { get; set; }
public string DownloadClient { get; set; }
public bool DownloadClientHasPostImportCategory { get; set; }
public string Indexer { get; set; }
public string OutputPath { get; set; }
public bool DownloadForced { get; set; }
@ -66,6 +68,7 @@ namespace Lidarr.Api.V1.Queue
Sizeleft = model.Sizeleft,
Timeleft = model.Timeleft,
EstimatedCompletionTime = model.EstimatedCompletionTime,
Added = model.Added,
Status = model.Status.FirstCharToLower(),
TrackedDownloadStatus = model.TrackedDownloadStatus,
TrackedDownloadState = model.TrackedDownloadState,
@ -74,6 +77,7 @@ namespace Lidarr.Api.V1.Queue
DownloadId = model.DownloadId,
Protocol = model.Protocol,
DownloadClient = model.DownloadClient,
DownloadClientHasPostImportCategory = model.DownloadClientHasPostImportCategory,
Indexer = model.Indexer,
OutputPath = model.OutputPath,
DownloadForced = model.DownloadForced

@ -90,7 +90,7 @@ namespace Lidarr.Api.V1.System.Backup
}
[HttpPost("restore/upload")]
[RequestFormLimits(MultipartBodyLengthLimit = 500000000)]
[RequestFormLimits(MultipartBodyLengthLimit = 1000000000)]
public object UploadAndRestore()
{
var files = Request.Form.Files;

@ -2638,8 +2638,11 @@
"name": "eventType",
"in": "query",
"schema": {
"type": "integer",
"format": "int32"
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
}
},
{
@ -6031,6 +6034,14 @@
"type": "boolean",
"default": false
}
},
{
"name": "changeCategory",
"in": "query",
"schema": {
"type": "boolean",
"default": false
}
}
],
"responses": {
@ -6069,6 +6080,14 @@
"type": "boolean",
"default": false
}
},
{
"name": "changeCategory",
"in": "query",
"schema": {
"type": "boolean",
"default": false
}
}
],
"requestBody": {
@ -6636,16 +6655,6 @@
"schema": {
"$ref": "#/components/schemas/ReleaseResource"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ReleaseResource"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/ReleaseResource"
}
}
}
},
@ -9407,6 +9416,9 @@
},
"autoRedownloadFailed": {
"type": "boolean"
},
"autoRedownloadFailedFromInteractiveSearch": {
"type": "boolean"
}
},
"additionalProperties": false
@ -11100,6 +11112,17 @@
"$ref": "#/components/schemas/AlbumResource"
},
"nullable": true
},
"customFormats": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CustomFormatResource"
},
"nullable": true
},
"customFormatScore": {
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false
@ -11437,6 +11460,11 @@
"type": "number",
"format": "double",
"nullable": true
},
"preferredSize": {
"type": "number",
"format": "double",
"nullable": true
}
},
"additionalProperties": false
@ -11594,6 +11622,11 @@
"format": "date-time",
"nullable": true
},
"added": {
"type": "string",
"format": "date-time",
"nullable": true
},
"status": {
"type": "string",
"nullable": true
@ -11626,6 +11659,9 @@
"type": "string",
"nullable": true
},
"downloadClientHasPostImportCategory": {
"type": "boolean"
},
"indexer": {
"type": "string",
"nullable": true
@ -11936,6 +11972,15 @@
"type": "integer",
"format": "int32",
"nullable": true
},
"downloadClientId": {
"type": "integer",
"format": "int32",
"nullable": true
},
"downloadClient": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false

@ -3,11 +3,13 @@ using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using DryIoc;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Reflection;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Localization;
namespace Lidarr.Http.ClientSchema
{
@ -15,6 +17,12 @@ namespace Lidarr.Http.ClientSchema
{
private const string PRIVATE_VALUE = "********";
private static Dictionary<Type, FieldMapping[]> _mappings = new Dictionary<Type, FieldMapping[]>();
private static ILocalizationService _localizationService;
public static void Initialize(IContainer container)
{
_localizationService = container.Resolve<ILocalizationService>();
}
public static List<Field> ToSchema(object model)
{
@ -106,13 +114,27 @@ namespace Lidarr.Http.ClientSchema
if (propertyInfo.PropertyType.IsSimpleType())
{
var fieldAttribute = property.Item2;
var label = fieldAttribute.Label.IsNotNullOrWhiteSpace()
? _localizationService.GetLocalizedString(fieldAttribute.Label,
GetTokens(type, fieldAttribute.Label, TokenField.Label))
: fieldAttribute.Label;
var helpText = fieldAttribute.HelpText.IsNotNullOrWhiteSpace()
? _localizationService.GetLocalizedString(fieldAttribute.HelpText,
GetTokens(type, fieldAttribute.Label, TokenField.HelpText))
: fieldAttribute.HelpText;
var helpTextWarning = fieldAttribute.HelpTextWarning.IsNotNullOrWhiteSpace()
? _localizationService.GetLocalizedString(fieldAttribute.HelpTextWarning,
GetTokens(type, fieldAttribute.Label, TokenField.HelpTextWarning))
: fieldAttribute.HelpTextWarning;
var field = new Field
{
Name = prefix + GetCamelCaseName(propertyInfo.Name),
Label = fieldAttribute.Label,
Label = label,
Unit = fieldAttribute.Unit,
HelpText = fieldAttribute.HelpText,
HelpTextWarning = fieldAttribute.HelpTextWarning,
HelpText = helpText,
HelpTextWarning = helpTextWarning,
HelpLink = fieldAttribute.HelpLink,
Order = fieldAttribute.Order,
Advanced = fieldAttribute.Advanced,
@ -172,6 +194,24 @@ namespace Lidarr.Http.ClientSchema
.ToArray();
}
private static Dictionary<string, object> GetTokens(Type type, string label, TokenField field)
{
var tokens = new Dictionary<string, object>();
foreach (var propertyInfo in type.GetProperties())
{
foreach (var attribute in propertyInfo.GetCustomAttributes(true))
{
if (attribute is FieldTokenAttribute fieldTokenAttribute && fieldTokenAttribute.Field == field && fieldTokenAttribute.Label == label)
{
tokens.Add(fieldTokenAttribute.Token, fieldTokenAttribute.Value);
}
}
}
return tokens;
}
private static List<SelectOption> GetSelectOptions(Type selectOptions)
{
if (selectOptions.IsEnum)

@ -1,7 +1,10 @@
using System.Collections.Generic;
using FluentAssertions;
using Lidarr.Http.ClientSchema;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Localization;
using NzbDrone.Test.Common;
namespace NzbDrone.Api.Test.ClientSchemaTests
@ -9,6 +12,16 @@ namespace NzbDrone.Api.Test.ClientSchemaTests
[TestFixture]
public class SchemaBuilderFixture : TestBase
{
[SetUp]
public void Setup()
{
Mocker.GetMock<ILocalizationService>()
.Setup(s => s.GetLocalizedString(It.IsAny<string>(), It.IsAny<Dictionary<string, object>>()))
.Returns<string, Dictionary<string, object>>((s, d) => s);
SchemaBuilder.Initialize(Mocker.Container);
}
[Test]
public void should_return_field_for_every_property()
{

@ -92,6 +92,10 @@ namespace NzbDrone.Common.Http
{
data = new XElement("base64", Convert.ToBase64String(bytes));
}
else if (value is Dictionary<string, string> d)
{
data = new XElement("struct", d.Select(p => new XElement("member", new XElement("name", p.Key), new XElement("value", p.Value))));
}
else
{
throw new InvalidOperationException($"Unhandled argument type {value.GetType().Name}");

@ -7,7 +7,7 @@
<ItemGroup>
<PackageReference Include="DryIoc.dll" Version="5.4.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NLog" Version="5.2.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" />
<PackageReference Include="Sentry" Version="3.25.0" />

@ -25,12 +25,30 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
public void Setup()
{
GivenPreferredDownloadProtocol(DownloadProtocol.Usenet);
Mocker.GetMock<IQualityDefinitionService>()
.Setup(s => s.Get(It.IsAny<Quality>()))
.Returns(new QualityDefinition { PreferredSize = null });
}
private void GivenPreferredSize(double? size)
{
Mocker.GetMock<IQualityDefinitionService>()
.Setup(s => s.Get(It.IsAny<Quality>()))
.Returns(new QualityDefinition { PreferredSize = size });
}
private Album GivenAlbum(int id)
{
var release = Builder<AlbumRelease>.CreateNew()
.With(e => e.AlbumId = id)
.With(e => e.Monitored = true)
.With(e => e.Duration = 3600000)
.Build();
return Builder<Album>.CreateNew()
.With(e => e.Id = id)
.With(e => e.AlbumReleases = new List<AlbumRelease> { release })
.Build();
}
@ -130,6 +148,44 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
qualifiedReports.First().RemoteAlbum.Should().Be(remoteAlbumHdLargeYoung);
}
[Test]
public void should_order_by_closest_to_preferred_size_if_both_under()
{
// 1000 Kibit/Sec * 60 Min Duration = 439.5 MiB
GivenPreferredSize(1000);
var remoteAlbumSmall = GivenRemoteAlbum(new List<Album> { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), size: 120.Megabytes(), age: 1);
var remoteAlbumLarge = GivenRemoteAlbum(new List<Album> { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), size: 400.Megabytes(), age: 1);
var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteAlbumSmall));
decisions.Add(new DownloadDecision(remoteAlbumLarge));
var qualifiedReports = Subject.PrioritizeDecisions(decisions);
qualifiedReports.First().RemoteAlbum.Should().Be(remoteAlbumLarge);
}
[Test]
public void should_order_by_closest_to_preferred_size_if_preferred_is_in_between()
{
// 700 Kibit/Sec * 60 Min Duration = 307.6 MiB
GivenPreferredSize(700);
var remoteAlbum1 = GivenRemoteAlbum(new List<Album> { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), size: 100.Megabytes(), age: 1);
var remoteAlbum2 = GivenRemoteAlbum(new List<Album> { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), size: 200.Megabytes(), age: 1);
var remoteAlbum3 = GivenRemoteAlbum(new List<Album> { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), size: 300.Megabytes(), age: 1);
var remoteAlbum4 = GivenRemoteAlbum(new List<Album> { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), size: 500.Megabytes(), age: 1);
var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteAlbum1));
decisions.Add(new DownloadDecision(remoteAlbum2));
decisions.Add(new DownloadDecision(remoteAlbum3));
decisions.Add(new DownloadDecision(remoteAlbum4));
var qualifiedReports = Subject.PrioritizeDecisions(decisions);
qualifiedReports.First().RemoteAlbum.Should().Be(remoteAlbum3);
}
[Test]
public void should_order_by_youngest()
{

@ -4,6 +4,7 @@ using FizzWare.NBuilder;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Download.TrackedDownloads;
@ -369,5 +370,31 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue();
}
[Test]
public void should_return_false_if_same_quality_non_proper_in_queue_and_download_propers_is_do_not_upgrade()
{
_remoteAlbum.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_008, new Revision(2));
_artist.QualityProfile.Value.Cutoff = _remoteAlbum.ParsedAlbumInfo.Quality.Quality.Id;
Mocker.GetMock<IConfigService>()
.Setup(s => s.DownloadPropersAndRepacks)
.Returns(ProperDownloadTypes.DoNotUpgrade);
var remoteAlbum = Builder<RemoteAlbum>.CreateNew()
.With(r => r.Artist = _artist)
.With(r => r.Albums = new List<Album> { _album })
.With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo
{
Quality = new QualityModel(Quality.MP3_008)
})
.With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build();
GivenQueue(new List<RemoteAlbum> { remoteAlbum });
Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse();
}
}
}

@ -68,7 +68,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteAlbum));
await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteAlbum>()), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteAlbum>(), null), Times.Once());
}
[Test]
@ -82,7 +82,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteAlbum));
await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteAlbum>()), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteAlbum>(), null), Times.Once());
}
[Test]
@ -101,7 +101,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteAlbum2));
await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteAlbum>()), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteAlbum>(), null), Times.Once());
}
[Test]
@ -172,7 +172,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteAlbum));
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteAlbum>())).Throws(new Exception());
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteAlbum>(), null)).Throws(new Exception());
var result = await Subject.ProcessDecisions(decisions);
@ -201,7 +201,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteAlbum, new Rejection("Failure!", RejectionType.Temporary)));
await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteAlbum>()), Times.Never());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteAlbum>(), null), Times.Never());
}
[Test]
@ -242,11 +242,11 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteAlbum));
decisions.Add(new DownloadDecision(remoteAlbum));
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteAlbum>()))
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteAlbum>(), null))
.Throws(new DownloadClientUnavailableException("Download client failed"));
await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteAlbum>()), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteAlbum>(), null), Times.Once());
}
[Test]
@ -260,12 +260,12 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteAlbum));
decisions.Add(new DownloadDecision(remoteAlbum2));
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.Is<RemoteAlbum>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet)))
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.Is<RemoteAlbum>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet), null))
.Throws(new DownloadClientUnavailableException("Download client failed"));
await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteAlbum>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet)), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteAlbum>(r => r.Release.DownloadProtocol == DownloadProtocol.Torrent)), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteAlbum>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet), null), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteAlbum>(r => r.Release.DownloadProtocol == DownloadProtocol.Torrent), null), Times.Once());
}
[Test]
@ -278,7 +278,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteAlbum));
Mocker.GetMock<IDownloadService>()
.Setup(s => s.DownloadReport(It.IsAny<RemoteAlbum>()))
.Setup(s => s.DownloadReport(It.IsAny<RemoteAlbum>(), null))
.Throws(new ReleaseUnavailableException(remoteAlbum.Release, "That 404 Error is not just a Quirk"));
var result = await Subject.ProcessDecisions(decisions);

@ -454,6 +454,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests
[TestCase("0")]
[TestCase("15d")]
[TestCase("")]
[TestCase(null)]
public void should_set_history_removes_completed_downloads_false(string historyRetention)
{
_config.Misc.history_retention = historyRetention;

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save