Merge branch 'develop' into mka-support

This commit is contained in:
sharinganthief 2024-01-31 12:40:57 -05:00 committed by GitHub
commit e4960194dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
279 changed files with 4487 additions and 2428 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -70,6 +70,12 @@ function createMapStateToProps() {
isCommandExecuting(isSearchingCommand) && isCommandExecuting(isSearchingCommand) &&
isSearchingCommand.body.albumIds.indexOf(album.id) > -1 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 isFetching = tracks.isFetching || isTrackFilesFetching;
const isPopulated = tracks.isPopulated && isTrackFilesPopulated; const isPopulated = tracks.isPopulated && isTrackFilesPopulated;
@ -80,6 +86,8 @@ function createMapStateToProps() {
shortDateFormat: uiSettings.shortDateFormat, shortDateFormat: uiSettings.shortDateFormat,
artist, artist,
isSearching, isSearching,
isRenamingFiles,
isRenamingArtist,
isFetching, isFetching,
isPopulated, isPopulated,
tracksError, tracksError,
@ -113,8 +121,27 @@ class AlbumDetailsConnector extends Component {
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (!_.isEqual(getMonitoredReleases(prevProps), getMonitoredReleases(this.props)) || const {
(prevProps.anyReleaseOk === false && this.props.anyReleaseOk === true)) { 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.unpopulate();
this.populate(); this.populate();
} }
@ -174,6 +201,8 @@ class AlbumDetailsConnector extends Component {
AlbumDetailsConnector.propTypes = { AlbumDetailsConnector.propTypes = {
id: PropTypes.number, id: PropTypes.number,
anyReleaseOk: PropTypes.bool, anyReleaseOk: PropTypes.bool,
isRenamingFiles: PropTypes.bool.isRequired,
isRenamingArtist: PropTypes.bool.isRequired,
isAlbumFetching: PropTypes.bool, isAlbumFetching: PropTypes.bool,
isAlbumPopulated: PropTypes.bool, isAlbumPopulated: PropTypes.bool,
foreignAlbumId: PropTypes.string.isRequired, foreignAlbumId: PropTypes.string.isRequired,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,38 +1,10 @@
import ModelBase from 'App/ModelBase'; import Queue from 'typings/Queue';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
import AppSectionState, { import AppSectionState, {
AppSectionFilterState, AppSectionFilterState,
AppSectionItemState, AppSectionItemState,
Error, Error,
} from './AppSectionState'; } 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> { export interface QueueDetailsAppState extends AppSectionState<Queue> {
params: unknown; params: unknown;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -89,7 +89,7 @@ class AddNewArtistSearchResult extends Component {
const linkProps = isExistingArtist ? { to: `/artist/${foreignArtistId}` } : { onPress: this.onPress }; 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); const height = calculateHeight(230, isSmallScreen);

View file

@ -49,7 +49,7 @@ function EditSpecificationModalContent(props) {
{...otherProps} {...otherProps}
> >
{ {
fields && fields.some((x) => x.label === 'Regular Expression') && fields && fields.some((x) => x.label === translate('CustomFormatsSpecificationRegularExpression')) &&
<Alert kind={kinds.INFO}> <Alert kind={kinds.INFO}>
<div> <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>' }} /> <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>' }} />

View file

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

View file

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

View file

@ -61,10 +61,12 @@ function DownloadClientOptions(props) {
legend={translate('FailedDownloadHandling')} legend={translate('FailedDownloadHandling')}
> >
<Form> <Form>
<FormGroup size={sizes.MEDIUM}> <FormGroup
<FormLabel> advancedSettings={advancedSettings}
{translate('Redownload')} isAdvanced={true}
</FormLabel> size={sizes.MEDIUM}
>
<FormLabel>{translate('AutoRedownloadFailed')}</FormLabel>
<FormInputGroup <FormInputGroup
type={inputTypes.CHECK} type={inputTypes.CHECK}
@ -74,7 +76,28 @@ function DownloadClientOptions(props) {
{...settings.autoRedownloadFailed} {...settings.autoRedownloadFailed}
/> />
</FormGroup> </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> </Form>
<Alert kind={kinds.INFO}> <Alert kind={kinds.INFO}>
{translate('RemoveDownloadsAlert')} {translate('RemoveDownloadsAlert')}
</Alert> </Alert>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import { sortDirections } from 'Helpers/Props';
import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler'; import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler'; import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; 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 createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; 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 createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks'; 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 TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers';
export const BULK_EDIT_INDEXERS = 'settings/indexers/bulkEditIndexers'; export const BULK_EDIT_INDEXERS = 'settings/indexers/bulkEditIndexers';
export const BULK_DELETE_INDEXERS = 'settings/indexers/bulkDeleteIndexers'; export const BULK_DELETE_INDEXERS = 'settings/indexers/bulkDeleteIndexers';
export const SET_MANAGE_INDEXERS_SORT = 'settings/indexers/setManageIndexersSort';
// //
// Action Creators // Action Creators
@ -53,6 +56,7 @@ export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER);
export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); export const testAllIndexers = createThunk(TEST_ALL_INDEXERS);
export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS); export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS);
export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS); export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS);
export const setManageIndexersSort = createAction(SET_MANAGE_INDEXERS_SORT);
export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => {
return { return {
@ -92,7 +96,14 @@ export default {
isTesting: false, isTesting: false,
isTestingAll: false, isTestingAll: false,
items: [], 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; delete selectedSchema.name;
selectedSchema.fields = selectedSchema.fields.map((field) => { 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; newState.selectedSchema = selectedSchema;
@ -153,7 +170,10 @@ export default {
}; };
return updateSectionState(state, section, newState); return updateSectionState(state, section, newState);
} },
[SET_MANAGE_INDEXERS_SORT]: createSetClientSideCollectionSortReducer(section)
} }
}; };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -68,15 +68,14 @@ namespace Lidarr.Api.V1.History
[HttpGet] [HttpGet]
[Produces("application/json")] [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 pagingResource = new PagingResource<HistoryResource>(paging);
var pagingSpec = pagingResource.MapToPagingSpec<HistoryResource, EntityHistory>("date", SortDirection.Descending); 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 => eventTypes.Contains((int)v.EventType));
pagingSpec.FilterExpressions.Add(v => v.EventType == filterValue);
} }
if (albumId.HasValue) if (albumId.HasValue)

View file

@ -127,7 +127,7 @@ namespace Lidarr.Api.V1.Indexers
throw new NzbDroneClientException(HttpStatusCode.NotFound, "Unable to parse albums in the release"); 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) catch (ReleaseDownloadException ex)
{ {

View file

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

View file

@ -60,6 +60,12 @@ namespace Lidarr.Api.V1.Indexers
// [JsonIgnore] // [JsonIgnore]
public int? AlbumId { get; set; } 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 public static class ReleaseResourceMapper

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -65,7 +65,7 @@ namespace Lidarr.Api.V1.Queue
} }
[RestDeleteById] [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); var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id);
@ -83,12 +83,12 @@ namespace Lidarr.Api.V1.Queue
throw new NotFoundException(); throw new NotFoundException();
} }
Remove(trackedDownload, removeFromClient, blocklist, skipRedownload); Remove(trackedDownload, removeFromClient, blocklist, skipRedownload, changeCategory);
_trackedDownloadService.StopTracking(trackedDownload.DownloadItem.DownloadId); _trackedDownloadService.StopTracking(trackedDownload.DownloadItem.DownloadId);
} }
[HttpDelete("bulk")] [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 trackedDownloadIds = new List<string>();
var pendingToRemove = new List<NzbDrone.Core.Queue.Queue>(); 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)) 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); trackedDownloadIds.Add(trackedDownload.DownloadItem.DownloadId);
} }
@ -181,9 +181,16 @@ namespace Lidarr.Api.V1.Queue
else if (pagingSpec.SortKey == "estimatedCompletionTime") else if (pagingSpec.SortKey == "estimatedCompletionTime")
{ {
ordered = ascending ordered = ascending
? fullQueue.OrderBy(q => q.EstimatedCompletionTime, new EstimatedCompletionTimeComparer()) ? fullQueue.OrderBy(q => q.EstimatedCompletionTime, new DatetimeComparer())
: fullQueue.OrderByDescending(q => q.EstimatedCompletionTime, : 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") else if (pagingSpec.SortKey == "protocol")
{ {
@ -262,7 +269,7 @@ namespace Lidarr.Api.V1.Queue
_pendingReleaseService.RemovePendingQueueItems(pendingRelease.Id); _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) if (removeFromClient)
{ {
@ -275,13 +282,24 @@ namespace Lidarr.Api.V1.Queue
downloadClient.RemoveItem(trackedDownload.DownloadItem, true); 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) if (blocklist)
{ {
_failedDownloadService.MarkAsFailed(trackedDownload.DownloadItem.DownloadId, skipRedownload); _failedDownloadService.MarkAsFailed(trackedDownload.DownloadItem.DownloadId, skipRedownload);
} }
if (!removeFromClient && !blocklist) if (!removeFromClient && !blocklist && !changeCategory)
{ {
if (!_ignoredDownloadService.IgnoreDownload(trackedDownload)) if (!_ignoredDownloadService.IgnoreDownload(trackedDownload))
{ {

View file

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

View file

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

View file

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

View file

@ -3,11 +3,13 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Text.Json; using System.Text.Json;
using DryIoc;
using NzbDrone.Common.EnsureThat; using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Reflection; using NzbDrone.Common.Reflection;
using NzbDrone.Common.Serializer; using NzbDrone.Common.Serializer;
using NzbDrone.Core.Annotations; using NzbDrone.Core.Annotations;
using NzbDrone.Core.Localization;
namespace Lidarr.Http.ClientSchema namespace Lidarr.Http.ClientSchema
{ {
@ -15,6 +17,12 @@ namespace Lidarr.Http.ClientSchema
{ {
private const string PRIVATE_VALUE = "********"; private const string PRIVATE_VALUE = "********";
private static Dictionary<Type, FieldMapping[]> _mappings = new Dictionary<Type, FieldMapping[]>(); 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) public static List<Field> ToSchema(object model)
{ {
@ -106,13 +114,27 @@ namespace Lidarr.Http.ClientSchema
if (propertyInfo.PropertyType.IsSimpleType()) if (propertyInfo.PropertyType.IsSimpleType())
{ {
var fieldAttribute = property.Item2; 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 var field = new Field
{ {
Name = prefix + GetCamelCaseName(propertyInfo.Name), Name = prefix + GetCamelCaseName(propertyInfo.Name),
Label = fieldAttribute.Label, Label = label,
Unit = fieldAttribute.Unit, Unit = fieldAttribute.Unit,
HelpText = fieldAttribute.HelpText, HelpText = helpText,
HelpTextWarning = fieldAttribute.HelpTextWarning, HelpTextWarning = helpTextWarning,
HelpLink = fieldAttribute.HelpLink, HelpLink = fieldAttribute.HelpLink,
Order = fieldAttribute.Order, Order = fieldAttribute.Order,
Advanced = fieldAttribute.Advanced, Advanced = fieldAttribute.Advanced,
@ -172,6 +194,24 @@ namespace Lidarr.Http.ClientSchema
.ToArray(); .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) private static List<SelectOption> GetSelectOptions(Type selectOptions)
{ {
if (selectOptions.IsEnum) if (selectOptions.IsEnum)

View file

@ -1,7 +1,10 @@
using System.Collections.Generic;
using FluentAssertions; using FluentAssertions;
using Lidarr.Http.ClientSchema; using Lidarr.Http.ClientSchema;
using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.Annotations; using NzbDrone.Core.Annotations;
using NzbDrone.Core.Localization;
using NzbDrone.Test.Common; using NzbDrone.Test.Common;
namespace NzbDrone.Api.Test.ClientSchemaTests namespace NzbDrone.Api.Test.ClientSchemaTests
@ -9,6 +12,16 @@ namespace NzbDrone.Api.Test.ClientSchemaTests
[TestFixture] [TestFixture]
public class SchemaBuilderFixture : TestBase 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] [Test]
public void should_return_field_for_every_property() public void should_return_field_for_every_property()
{ {

View file

@ -92,6 +92,10 @@ namespace NzbDrone.Common.Http
{ {
data = new XElement("base64", Convert.ToBase64String(bytes)); 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 else
{ {
throw new InvalidOperationException($"Unhandled argument type {value.GetType().Name}"); throw new InvalidOperationException($"Unhandled argument type {value.GetType().Name}");

View file

@ -7,7 +7,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="DryIoc.dll" Version="5.4.3" /> <PackageReference Include="DryIoc.dll" Version="5.4.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" /> <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="NLog" Version="5.2.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" /> <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" />
<PackageReference Include="Sentry" Version="3.25.0" /> <PackageReference Include="Sentry" Version="3.25.0" />

View file

@ -25,12 +25,30 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
public void Setup() public void Setup()
{ {
GivenPreferredDownloadProtocol(DownloadProtocol.Usenet); 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) 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() return Builder<Album>.CreateNew()
.With(e => e.Id = id) .With(e => e.Id = id)
.With(e => e.AlbumReleases = new List<AlbumRelease> { release })
.Build(); .Build();
} }
@ -130,6 +148,44 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
qualifiedReports.First().RemoteAlbum.Should().Be(remoteAlbumHdLargeYoung); 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] [Test]
public void should_order_by_youngest() public void should_order_by_youngest()
{ {

View file

@ -4,6 +4,7 @@ using FizzWare.NBuilder;
using FluentAssertions; using FluentAssertions;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.CustomFormats; using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.Download.TrackedDownloads;
@ -369,5 +370,31 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); 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();
}
} }
} }

View file

@ -68,7 +68,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteAlbum)); decisions.Add(new DownloadDecision(remoteAlbum));
await Subject.ProcessDecisions(decisions); 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] [Test]
@ -82,7 +82,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteAlbum)); decisions.Add(new DownloadDecision(remoteAlbum));
await Subject.ProcessDecisions(decisions); 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] [Test]
@ -101,7 +101,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteAlbum2)); decisions.Add(new DownloadDecision(remoteAlbum2));
await Subject.ProcessDecisions(decisions); 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] [Test]
@ -172,7 +172,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
var decisions = new List<DownloadDecision>(); var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteAlbum)); 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); 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))); decisions.Add(new DownloadDecision(remoteAlbum, new Rejection("Failure!", RejectionType.Temporary)));
await Subject.ProcessDecisions(decisions); 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] [Test]
@ -242,11 +242,11 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteAlbum)); decisions.Add(new DownloadDecision(remoteAlbum));
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")); .Throws(new DownloadClientUnavailableException("Download client failed"));
await Subject.ProcessDecisions(decisions); 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] [Test]
@ -260,12 +260,12 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteAlbum)); decisions.Add(new DownloadDecision(remoteAlbum));
decisions.Add(new DownloadDecision(remoteAlbum2)); 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")); .Throws(new DownloadClientUnavailableException("Download client failed"));
await Subject.ProcessDecisions(decisions); 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.Usenet), null), 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.Torrent), null), Times.Once());
} }
[Test] [Test]
@ -278,7 +278,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteAlbum)); decisions.Add(new DownloadDecision(remoteAlbum));
Mocker.GetMock<IDownloadService>() 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")); .Throws(new ReleaseUnavailableException(remoteAlbum.Release, "That 404 Error is not just a Quirk"));
var result = await Subject.ProcessDecisions(decisions); var result = await Subject.ProcessDecisions(decisions);

View file

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

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