Fixed: UI and Command manager updates

Co-Authored-By: Mark McDowall <markus101@users.noreply.github.com>
pull/6/head
Qstick 6 years ago
parent d9a51a1d02
commit ba96dad8c7

@ -116,13 +116,12 @@ class AddNewArtistSearchResult extends Component {
{ {
isExistingArtist && isExistingArtist &&
<span title="Already in your library"> <Icon
<Icon className={styles.alreadyExistsIcon}
className={styles.alreadyExistsIcon} name={icons.CHECK_CIRCLE}
name={icons.CHECK_CIRCLE} size={36}
size={36} title="Already in your library"
/> />
</span>
} }
</div> </div>

@ -134,13 +134,12 @@ class AlbumDetailsMedium extends Component {
className={styles.expandButton} className={styles.expandButton}
onPress={this.onExpandPress} onPress={this.onExpandPress}
> >
<span title={isExpanded ? 'Hide tracks' : 'Show tracks'}> <Icon
<Icon className={styles.expandButtonIcon}
className={styles.expandButtonIcon} name={isExpanded ? icons.COLLAPSE : icons.EXPAND}
name={isExpanded ? icons.COLLAPSE : icons.EXPAND} title={isExpanded ? 'Hide tracks' : 'Show tracks'}
size={24} size={24}
/> />
</span>
{ {
!isSmallScreen && !isSmallScreen &&
<span>&nbsp;</span> <span>&nbsp;</span>

@ -48,9 +48,10 @@ function EpisodeStatus(props) {
if (grabbed) { if (grabbed) {
return ( return (
<div className={styles.center} title="Album is downloading"> <div className={styles.center}>
<Icon <Icon
name={icons.DOWNLOADING} name={icons.DOWNLOADING}
title="Album is downloading"
/> />
</div> </div>
); );
@ -74,9 +75,10 @@ function EpisodeStatus(props) {
if (!airDateUtc) { if (!airDateUtc) {
return ( return (
<div className={styles.center} title="TBA"> <div className={styles.center}>
<Icon <Icon
name={icons.TBA} name={icons.TBA}
title="TBA"
/> />
</div> </div>
); );
@ -84,9 +86,10 @@ function EpisodeStatus(props) {
if (!monitored) { if (!monitored) {
return ( return (
<div className={styles.center} title="Album is not monitored"> <div className={styles.center}>
<Icon <Icon
name={icons.UNMONITORED} name={icons.UNMONITORED}
title="Album is not monitored"
/> />
</div> </div>
); );
@ -94,18 +97,20 @@ function EpisodeStatus(props) {
if (hasAired) { if (hasAired) {
return ( return (
<div className={styles.center} title="Track missing from disk"> <div className={styles.center}>
<Icon <Icon
name={icons.MISSING} name={icons.MISSING}
title="Track missing from disk"
/> />
</div> </div>
); );
} }
return ( return (
<div className={styles.center} title="Album has not aired"> <div className={styles.center}>
<Icon <Icon
name={icons.NOT_AIRED} name={icons.NOT_AIRED}
title="Album has not aired"
/> />
</div> </div>
); );

@ -39,12 +39,11 @@ class AlbumStudioRow extends Component {
/> />
<TableRowCell className={styles.status}> <TableRowCell className={styles.status}>
<span title={status === 'ended' ? 'Ended' : 'Continuing'}> <Icon
<Icon className={styles.statusIcon}
className={styles.statusIcon} name={status === 'ended' ? icons.ARTIST_ENDED : icons.ARTIST_CONTINUING}
name={status === 'ended' ? icons.ARTIST_ENDED : icons.ARTIST_CONTINUING} title={status === 'ended' ? 'Ended' : 'Continuing'}
/> />
</span>
</TableRowCell> </TableRowCell>
<TableRowCell className={styles.title}> <TableRowCell className={styles.title}>

@ -580,7 +580,9 @@ class ArtistDetails extends Component {
<InteractiveImportModal <InteractiveImportModal
isOpen={isInteractiveImportModalOpen} isOpen={isInteractiveImportModalOpen}
folder={path} folder={path}
allowArtistChange={false}
showFilterExistingFiles={true} showFilterExistingFiles={true}
showImportMode={false}
onModalClose={this.onInteractiveImportModalClose} onModalClose={this.onInteractiveImportModalClose}
/> />
</PageContentBodyConnector> </PageContentBodyConnector>

@ -150,13 +150,12 @@ class ArtistDetailsSeason extends Component {
</div> </div>
<span title={isExpanded ? 'Hide albums' : 'Show albums'}> <Icon
<Icon className={styles.expandButtonIcon}
className={styles.expandButtonIcon} name={isExpanded ? icons.COLLAPSE : icons.EXPAND}
name={isExpanded ? icons.COLLAPSE : icons.EXPAND} title={isExpanded ? 'Hide albums' : 'Show albums'}
size={24} size={24}
/> />
</span>
{ {
!isSmallScreen && !isSmallScreen &&

@ -19,19 +19,17 @@ function ArtistStatusCell(props) {
className={className} className={className}
{...otherProps} {...otherProps}
> >
<span title={monitored ? 'Artist is monitored' : 'Artist is unmonitored'}> <Icon
<Icon className={styles.statusIcon}
className={styles.statusIcon} name={monitored ? icons.MONITORED : icons.UNMONITORED}
name={monitored ? icons.MONITORED : icons.UNMONITORED} title={monitored ? 'Artist is monitored' : 'Artist is unmonitored'}
/> />
</span>
<span title={status === 'ended' ? 'Ended' : 'Continuing'}> <Icon
<Icon className={styles.statusIcon}
className={styles.statusIcon} name={status === 'ended' ? icons.ARTIST_ENDED : icons.ARTIST_CONTINUING}
name={status === 'ended' ? icons.ARTIST_ENDED : icons.ARTIST_CONTINUING} title={status === 'ended' ? 'Ended' : 'Continuing'}
/> />
</span>
</Component> </Component>
); );
} }

@ -107,11 +107,10 @@ class AgendaEvent extends Component {
{ {
!queueItem && grabbed && !queueItem && grabbed &&
<span title="Album is downloading"> <Icon
<Icon name={icons.DOWNLOADING}
name={icons.DOWNLOADING} title="Album is downloading"
/> />
</span>
} }
</Link> </Link>
</div> </div>

@ -4,15 +4,14 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import CalendarDay from './CalendarDay'; import CalendarDay from './CalendarDay';
function createCalendarEventsConnector() { function createCalendarEventsConnector() {
return createSelector( return createSelector(
(state, { date }) => date, (state, { date }) => date,
createClientSideCollectionSelector('calendar'), (state) => state.calendar.items,
(date, calendar) => { (date, items) => {
const filtered = _.filter(calendar.items, (item) => { const filtered = _.filter(items, (item) => {
return moment(date).isSame(moment(item.releaseDate), 'day'); return moment(date).isSame(moment(item.releaseDate), 'day');
}); });

@ -91,12 +91,11 @@ class CalendarEvent extends Component {
{ {
!queueItem && grabbed && !queueItem && grabbed &&
<span title="Album is downloading"> <Icon
<Icon className={styles.statusIcon}
className={styles.statusIcon} name={icons.DOWNLOADING}
name={icons.DOWNLOADING} title="Album is downloading"
/> />
</span>
} }
</div> </div>

@ -7,6 +7,7 @@ import styles from './Icon.css';
function Icon(props) { function Icon(props) {
const { const {
containerClassName,
className, className,
name, name,
kind, kind,
@ -16,11 +17,7 @@ function Icon(props) {
...otherProps ...otherProps
} = props; } = props;
if (title && !window.Lidarr.isProduction) { const icon = (
console.error('Icons cannot have a title');
}
return (
<FontAwesomeIcon <FontAwesomeIcon
className={classNames( className={classNames(
className, className,
@ -34,9 +31,23 @@ function Icon(props) {
{...otherProps} {...otherProps}
/> />
); );
if (title) {
return (
<span
className={containerClassName}
title={title}
>
{icon}
</span>
);
}
return icon;
} }
Icon.propTypes = { Icon.propTypes = {
containerClassName: PropTypes.string,
className: PropTypes.string, className: PropTypes.string,
name: PropTypes.object.isRequired, name: PropTypes.object.isRequired,
kind: PropTypes.string.isRequired, kind: PropTypes.string.isRequired,

@ -45,9 +45,10 @@ function Message(props) {
styles[type] styles[type]
)} )}
> >
<div className={styles.iconContainer} title={name}> <div className={styles.iconContainer}>
<Icon <Icon
name={getIconName(name)} name={getIconName(name)}
title={name}
/> />
</div> </div>

@ -6,7 +6,6 @@
.filterText { .filterText {
margin-left: 5px; margin-left: 5px;
font-size: $largeFontSize;
} }
.footer { .footer {

@ -169,7 +169,9 @@ class InteractiveImportModalContent extends Component {
render() { render() {
const { const {
downloadId, downloadId,
allowArtistChange,
showFilterExistingFiles, showFilterExistingFiles,
showImportMode,
filterExistingFiles, filterExistingFiles,
title, title,
folder, folder,
@ -211,17 +213,7 @@ class InteractiveImportModalContent extends Component {
<ModalBody> <ModalBody>
{ {
isFetching && showFilterExistingFiles &&
<LoadingIndicator />
}
{
error &&
<div>{errorMessage}</div>
}
{
isPopulated && showFilterExistingFiles && !isFetching &&
<div className={styles.filterContainer}> <div className={styles.filterContainer}>
<Menu alignMenu={align.RIGHT}> <Menu alignMenu={align.RIGHT}>
<MenuButton> <MenuButton>
@ -258,6 +250,16 @@ class InteractiveImportModalContent extends Component {
</div> </div>
} }
{
isFetching &&
<LoadingIndicator />
}
{
error &&
<div>{errorMessage}</div>
}
{ {
isPopulated && !!items.length && !isFetching && !isFetching && isPopulated && !!items.length && !isFetching && !isFetching &&
<Table <Table
@ -278,6 +280,7 @@ class InteractiveImportModalContent extends Component {
key={item.id} key={item.id}
isSelected={selectedState[item.id]} isSelected={selectedState[item.id]}
{...item} {...item}
allowArtistChange={allowArtistChange}
onSelectedChange={this.onSelectedChange} onSelectedChange={this.onSelectedChange}
onValidRowChange={this.onValidRowChange} onValidRowChange={this.onValidRowChange}
/> />
@ -295,9 +298,9 @@ class InteractiveImportModalContent extends Component {
</ModalBody> </ModalBody>
<ModalFooter className={styles.footer}> <ModalFooter className={styles.footer}>
{ <div className={styles.leftButtons}>
!downloadId && {
<div className={styles.leftButtons}> !downloadId && showImportMode &&
<SelectInput <SelectInput
className={styles.importMode} className={styles.importMode}
name="importMode" name="importMode"
@ -305,13 +308,16 @@ class InteractiveImportModalContent extends Component {
values={importModeOptions} values={importModeOptions}
onChange={this.onImportModeChange} onChange={this.onImportModeChange}
/> />
</div> }
} </div>
<div className={downloadId ? styles.leftButtons : styles.centerButtons}> <div className={styles.centerButtons}>
<Button onPress={this.onSelectArtistPress}> {
Select Artist allowArtistChange &&
</Button> <Button onPress={this.onSelectArtistPress}>
Select Artist
</Button>
}
<Button onPress={this.onSelectAlbumPress}> <Button onPress={this.onSelectAlbumPress}>
Select Album Select Album
@ -357,6 +363,8 @@ class InteractiveImportModalContent extends Component {
InteractiveImportModalContent.propTypes = { InteractiveImportModalContent.propTypes = {
downloadId: PropTypes.string, downloadId: PropTypes.string,
allowArtistChange: PropTypes.bool.isRequired,
showImportMode: PropTypes.bool.isRequired,
showFilterExistingFiles: PropTypes.bool.isRequired, showFilterExistingFiles: PropTypes.bool.isRequired,
filterExistingFiles: PropTypes.bool.isRequired, filterExistingFiles: PropTypes.bool.isRequired,
importMode: PropTypes.string.isRequired, importMode: PropTypes.string.isRequired,
@ -377,7 +385,9 @@ InteractiveImportModalContent.propTypes = {
}; };
InteractiveImportModalContent.defaultProps = { InteractiveImportModalContent.defaultProps = {
allowArtistChange: true,
showFilterExistingFiles: false, showFilterExistingFiles: false,
showImportMode: true,
importMode: 'move' importMode: 'move'
}; };

@ -163,6 +163,7 @@ class InteractiveImportRow extends Component {
render() { render() {
const { const {
id, id,
allowArtistChange,
relativePath, relativePath,
artist, artist,
album, album,
@ -210,6 +211,7 @@ class InteractiveImportRow extends Component {
</TableRowCell> </TableRowCell>
<TableRowCellButton <TableRowCellButton
isDisabled={!allowArtistChange}
onPress={this.onSelectArtistPress} onPress={this.onSelectArtistPress}
> >
{ {
@ -348,6 +350,7 @@ class InteractiveImportRow extends Component {
InteractiveImportRow.propTypes = { InteractiveImportRow.propTypes = {
id: PropTypes.number.isRequired, id: PropTypes.number.isRequired,
allowArtistChange: PropTypes.bool.isRequired,
relativePath: PropTypes.string.isRequired, relativePath: PropTypes.string.isRequired,
artist: PropTypes.object, artist: PropTypes.object,
album: PropTypes.object, album: PropTypes.object,

@ -92,9 +92,10 @@ class QualityProfileItem extends Component {
{ {
connectDragSource( connectDragSource(
<div className={styles.dragHandle} title="Create group"> <div className={styles.dragHandle}>
<Icon <Icon
className={styles.dragIcon} className={styles.dragIcon}
title="Create group"
name={icons.REORDER} name={icons.REORDER}
/> />
</div> </div>

@ -129,10 +129,11 @@ class QualityProfileItemGroup extends Component {
{ {
connectDragSource( connectDragSource(
<div className={styles.dragHandle} title="Reorder"> <div className={styles.dragHandle}>
<Icon <Icon
className={styles.dragIcon} className={styles.dragIcon}
name={icons.REORDER} name={icons.REORDER}
title="Reorder"
/> />
</div> </div>
) )

@ -45,7 +45,7 @@ export const defaultState = {
filters: [ filters: [
{ {
key: 'monitored', key: 'monitored',
value: false || true, value: false,
type: filterTypes.EQUAL type: filterTypes.EQUAL
} }
] ]
@ -66,7 +66,8 @@ export const defaultState = {
export const persistState = [ export const persistState = [
'calendar.view', 'calendar.view',
'calendar.selectedFilterKey' 'calendar.selectedFilterKey',
'calendar.showUpcoming'
]; ];
// //

@ -57,7 +57,7 @@ function showCommandMessage(payload, dispatch) {
const { const {
id, id,
name, name,
manual, trigger,
message, message,
body = {}, body = {},
state state
@ -80,7 +80,7 @@ function showCommandMessage(payload, dispatch) {
hideAfter = 4; hideAfter = 4;
} else if (state === 'failed') { } else if (state === 'failed') {
type = messageTypes.ERROR; type = messageTypes.ERROR;
hideAfter = manual ? 10 : 4; hideAfter = trigger === 'manual' ? 10 : 4;
} }
dispatch(showMessage({ dispatch(showMessage({
@ -95,10 +95,11 @@ function showCommandMessage(payload, dispatch) {
function scheduleRemoveCommand(command, dispatch) { function scheduleRemoveCommand(command, dispatch) {
const { const {
id, id,
state status,
body
} = command; } = command;
if (state === 'queued') { if (status === 'queued') {
return; return;
} }
@ -108,6 +109,12 @@ function scheduleRemoveCommand(command, dispatch) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
// 5 minute timeout for executing disk access commands and
// 30 seconds for all other commands.
const timeout = body.requiresDiskAccess && status === 'started' ?
60000 * 5 :
30000;
removeCommandTimeoutIds[id] = setTimeout(() => { removeCommandTimeoutIds[id] = setTimeout(() => {
dispatch(batchActions([ dispatch(batchActions([
removeCommand({ section: 'commands', id }), removeCommand({ section: 'commands', id }),
@ -115,7 +122,7 @@ function scheduleRemoveCommand(command, dispatch) {
])); ]));
delete removeCommandTimeoutIds[id]; delete removeCommandTimeoutIds[id];
}, 30000); }, timeout);
} }
// //

@ -87,11 +87,10 @@ class BackupRow extends Component {
<TableRow key={id}> <TableRow key={id}>
<TableRowCell className={styles.type}> <TableRowCell className={styles.type}>
{ {
<span title={iconTooltip}> <Icon
<Icon name={iconClassName}
name={iconClassName} title={iconTooltip}
/> />
</span>
} }
</TableRowCell> </TableRowCell>

@ -125,12 +125,11 @@ class Health extends Component {
return ( return (
<TableRow key={`health${item.message}`}> <TableRow key={`health${item.message}`}>
<TableRowCell> <TableRowCell>
<span title={titleCase(item.type)}> <Icon
<Icon name={icons.DANGER}
name={icons.DANGER} kind={item.type.toLowerCase() === 'error' ? kinds.DANGER : kinds.WARNING}
kind={item.type.toLowerCase() === 'error' ? kinds.DANGER : kinds.WARNING} title={titleCase(item.type)}
/> />
</span>
</TableRowCell> </TableRowCell>
<TableRowCell>{item.message}</TableRowCell> <TableRowCell>{item.message}</TableRowCell>

@ -105,7 +105,6 @@ class CutoffUnmet extends Component {
filters, filters,
columns, columns,
totalRecords, totalRecords,
isSearchingForAlbums,
isSearchingForCutoffUnmetAlbums, isSearchingForCutoffUnmetAlbums,
isSaving, isSaving,
onFilterSelect, onFilterSelect,
@ -129,8 +128,7 @@ class CutoffUnmet extends Component {
<PageToolbarButton <PageToolbarButton
label="Search Selected" label="Search Selected"
iconName={icons.SEARCH} iconName={icons.SEARCH}
isDisabled={!itemsSelected} isDisabled={!itemsSelected || isSearchingForCutoffUnmetAlbums}
isSpinning={isSearchingForAlbums}
onPress={this.onSearchSelectedPress} onPress={this.onSearchSelectedPress}
/> />
@ -255,7 +253,6 @@ CutoffUnmet.propTypes = {
filters: PropTypes.arrayOf(PropTypes.object).isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number, totalRecords: PropTypes.number,
isSearchingForAlbums: PropTypes.bool.isRequired,
isSearchingForCutoffUnmetAlbums: PropTypes.bool.isRequired, isSearchingForCutoffUnmetAlbums: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired,
onFilterSelect: PropTypes.func.isRequired, onFilterSelect: PropTypes.func.isRequired,

@ -20,11 +20,9 @@ function createMapStateToProps() {
(state) => state.wanted.cutoffUnmet, (state) => state.wanted.cutoffUnmet,
createCommandsSelector(), createCommandsSelector(),
(cutoffUnmet, commands) => { (cutoffUnmet, commands) => {
const isSearchingForAlbums = _.some(commands, { name: commandNames.ALBUM_SEARCH });
const isSearchingForCutoffUnmetAlbums = _.some(commands, { name: commandNames.CUTOFF_UNMET_ALBUM_SEARCH }); const isSearchingForCutoffUnmetAlbums = _.some(commands, { name: commandNames.CUTOFF_UNMET_ALBUM_SEARCH });
return { return {
isSearchingForAlbums,
isSearchingForCutoffUnmetAlbums, isSearchingForCutoffUnmetAlbums,
isSaving: _.some(cutoffUnmet.items, { isSaving: true }), isSaving: _.some(cutoffUnmet.items, { isSaving: true }),
...cutoffUnmet ...cutoffUnmet

@ -114,7 +114,6 @@ class Missing extends Component {
filters, filters,
columns, columns,
totalRecords, totalRecords,
isSearchingForAlbums,
isSearchingForMissingAlbums, isSearchingForMissingAlbums,
isSaving, isSaving,
onFilterSelect, onFilterSelect,
@ -139,8 +138,7 @@ class Missing extends Component {
<PageToolbarButton <PageToolbarButton
label="Search Selected" label="Search Selected"
iconName={icons.SEARCH} iconName={icons.SEARCH}
isDisabled={!itemsSelected} isDisabled={!itemsSelected || isSearchingForMissingAlbums}
isSpinning={isSearchingForAlbums}
onPress={this.onSearchSelectedPress} onPress={this.onSearchSelectedPress}
/> />
@ -277,7 +275,6 @@ Missing.propTypes = {
filters: PropTypes.arrayOf(PropTypes.object).isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number, totalRecords: PropTypes.number,
isSearchingForAlbums: PropTypes.bool.isRequired,
isSearchingForMissingAlbums: PropTypes.bool.isRequired, isSearchingForMissingAlbums: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired,
onFilterSelect: PropTypes.func.isRequired, onFilterSelect: PropTypes.func.isRequired,

@ -19,11 +19,9 @@ function createMapStateToProps() {
(state) => state.wanted.missing, (state) => state.wanted.missing,
createCommandsSelector(), createCommandsSelector(),
(missing, commands) => { (missing, commands) => {
const isSearchingForAlbums = _.some(commands, { name: commandNames.ALBUM_SEARCH });
const isSearchingForMissingAlbums = _.some(commands, { name: commandNames.MISSING_ALBUM_SEARCH }); const isSearchingForMissingAlbums = _.some(commands, { name: commandNames.MISSING_ALBUM_SEARCH });
return { return {
isSearchingForAlbums,
isSearchingForMissingAlbums, isSearchingForMissingAlbums,
isSaving: _.some(missing.items, { isSaving: true }), isSaving: _.some(missing.items, { isSaving: true }),
...missing ...missing

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -24,37 +24,6 @@ namespace Lidarr.Api.V1.Commands
[JsonIgnore] [JsonIgnore]
public string CompletionMessage { get; set; } public string CompletionMessage { get; set; }
//Legacy
public CommandStatus State
{
get
{
return Status;
}
set { }
}
public bool Manual
{
get
{
return Trigger == CommandTrigger.Manual;
}
set { }
}
public DateTime StartedOn
{
get
{
return Queued;
}
set { }
}
public DateTime? StateChangeTime public DateTime? StateChangeTime
{ {
get get

@ -15,7 +15,7 @@ namespace NzbDrone.Core.Test.Messaging.Commands
[TestFixture] [TestFixture]
public class CommandExecutorFixture : TestBase<CommandExecutor> public class CommandExecutorFixture : TestBase<CommandExecutor>
{ {
private BlockingCollection<CommandModel> _commandQueue; private CommandQueue _commandQueue;
private Mock<IExecute<CommandA>> _executorA; private Mock<IExecute<CommandA>> _executorA;
private Mock<IExecute<CommandB>> _executorB; private Mock<IExecute<CommandB>> _executorB;
private bool _commandExecuted = false; private bool _commandExecuted = false;
@ -46,7 +46,7 @@ namespace NzbDrone.Core.Test.Messaging.Commands
private void GivenCommandQueue() private void GivenCommandQueue()
{ {
_commandQueue = new BlockingCollection<CommandModel>(new CommandQueue()); _commandQueue = new CommandQueue();
Mocker.GetMock<IManageCommandQueue>() Mocker.GetMock<IManageCommandQueue>()
.Setup(s => s.Queue(It.IsAny<CancellationToken>())) .Setup(s => s.Queue(It.IsAny<CancellationToken>()))

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using FluentAssertions; using FluentAssertions;
using Moq; using Moq;
@ -42,6 +43,10 @@ namespace NzbDrone.Core.Test.Messaging.Commands
{ {
var command = Subject.Push<CheckForFinishedDownloadCommand>(new CheckForFinishedDownloadCommand()); var command = Subject.Push<CheckForFinishedDownloadCommand>(new CheckForFinishedDownloadCommand());
// Start the command to mimic CommandQueue's behaviour
command.StartedAt = DateTime.Now;
command.Status = CommandStatus.Started;
Subject.Start(command); Subject.Start(command);
Subject.Complete(command, "All done"); Subject.Complete(command, "All done");
Subject.CleanCommands(); Subject.CleanCommands();

@ -1,9 +1,9 @@
using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Commands;
namespace NzbDrone.Core.Download namespace NzbDrone.Core.Download
{ {
public class CheckForFinishedDownloadCommand : Command public class CheckForFinishedDownloadCommand : Command
{ {
public override bool RequiresDiskAccess => true;
} }
} }

@ -8,6 +8,7 @@ namespace NzbDrone.Core.MediaFiles.Commands
public List<int> ArtistIds { get; set; } public List<int> ArtistIds { get; set; }
public override bool SendUpdatesToClient => true; public override bool SendUpdatesToClient => true;
public override bool RequiresDiskAccess => true;
public RenameArtistCommand() public RenameArtistCommand()
{ {

@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Commands;
namespace NzbDrone.Core.MediaFiles.Commands namespace NzbDrone.Core.MediaFiles.Commands
@ -9,6 +9,7 @@ namespace NzbDrone.Core.MediaFiles.Commands
public List<int> Files { get; set; } public List<int> Files { get; set; }
public override bool SendUpdatesToClient => true; public override bool SendUpdatesToClient => true;
public override bool RequiresDiskAccess => true;
public RenameFilesCommand() public RenameFilesCommand()
{ {
@ -20,4 +21,4 @@ namespace NzbDrone.Core.MediaFiles.Commands
Files = files; Files = files;
} }
} }
} }

@ -8,6 +8,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
public List<ManualImportFile> Files { get; set; } public List<ManualImportFile> Files { get; set; }
public override bool SendUpdatesToClient => true; public override bool SendUpdatesToClient => true;
public override bool RequiresDiskAccess => true;
public ImportMode ImportMode { get; set; } public ImportMode ImportMode { get; set; }
} }

@ -20,8 +20,9 @@ namespace NzbDrone.Core.Messaging.Commands
} }
public virtual bool UpdateScheduledTask => true; public virtual bool UpdateScheduledTask => true;
public virtual string CompletionMessage => "Completed"; public virtual string CompletionMessage => "Completed";
public virtual bool RequiresDiskAccess => false;
public virtual bool IsExclusive => false;
public string Name { get; private set; } public string Name { get; private set; }
public DateTime? LastExecutionTime { get; set; } public DateTime? LastExecutionTime { get; set; }

@ -1,27 +1,36 @@
using System; using System;
using System.Collections; using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading;
namespace NzbDrone.Core.Messaging.Commands namespace NzbDrone.Core.Messaging.Commands
{ {
public class CommandQueue : IProducerConsumerCollection<CommandModel> public class CommandQueue : IEnumerable
{ {
private object Mutex = new object(); private readonly object _mutex = new object();
private readonly List<CommandModel> _items;
private List<CommandModel> _items;
public CommandQueue() public CommandQueue()
{ {
_items = new List<CommandModel>(); _items = new List<CommandModel>();
} }
public int Count => _items.Count;
public void Add(CommandModel item)
{
lock (_mutex)
{
_items.Add(item);
}
}
public IEnumerator<CommandModel> GetEnumerator() public IEnumerator<CommandModel> GetEnumerator()
{ {
List<CommandModel> copy = null; List<CommandModel> copy = null;
lock (Mutex) lock (_mutex)
{ {
copy = new List<CommandModel>(_items); copy = new List<CommandModel>(_items);
} }
@ -34,77 +43,123 @@ namespace NzbDrone.Core.Messaging.Commands
return GetEnumerator(); return GetEnumerator();
} }
public void CopyTo(Array array, int index) public List<CommandModel> All()
{ {
lock (Mutex) List<CommandModel> rval = null;
lock (_mutex)
{ {
((ICollection)_items).CopyTo(array, index); rval = _items;
} }
}
public int Count => _items.Count; return rval;
}
public object SyncRoot => Mutex;
public bool IsSynchronized => true; public CommandModel Find(int id)
{
return All().FirstOrDefault(q => q.Id == id);
}
public void CopyTo(CommandModel[] array, int index) public void RemoveMany(IEnumerable<CommandModel> commands)
{ {
lock (Mutex) lock (_mutex)
{ {
_items.CopyTo(array, index); foreach (var command in commands)
{
_items.Remove(command);
}
} }
} }
public bool TryAdd(CommandModel item) public List<CommandModel> QueuedOrStarted()
{
return All().Where(q => q.Status == CommandStatus.Queued || q.Status == CommandStatus.Started)
.ToList();
}
public IEnumerable<CommandModel> GetConsumingEnumerable()
{ {
Add(item); return GetConsumingEnumerable(CancellationToken.None);
return true;
} }
public bool TryTake(out CommandModel item) public IEnumerable<CommandModel> GetConsumingEnumerable(CancellationToken cancellationToken)
{ {
bool rval = true; while (!cancellationToken.IsCancellationRequested)
lock (Mutex)
{ {
if (_items.Count == 0) if (TryGet(out var command))
{ {
item = default(CommandModel); yield return command;
rval = false;
} }
else Thread.Sleep(10);
{
item = _items.Where(c => c.Status == CommandStatus.Queued)
.OrderByDescending(c => c.Priority)
.ThenBy(c => c.QueuedAt)
.First();
_items.Remove(item);
}
} }
return rval;
} }
public CommandModel[] ToArray() public bool TryGet(out CommandModel item)
{ {
CommandModel[] rval = null; var rval = true;
item = default(CommandModel);
lock (Mutex) lock (_mutex)
{ {
rval = _items.ToArray(); if (_items.Count == 0)
} {
rval = false;
}
return rval; else
} {
var startedCommands = _items.Where(c => c.Status == CommandStatus.Started)
.ToList();
var localItem = _items.Where(c =>
{
// If an executing command requires disk access don't return a command that
// requires disk access. A lower priority or later queued task could be returned
// instead, but that will allow other tasks to execute whiule waiting for disk access.
if (startedCommands.Any(x => x.Body.RequiresDiskAccess))
{
return c.Status == CommandStatus.Queued &&
!c.Body.RequiresDiskAccess;
}
return c.Status == CommandStatus.Queued;
})
.OrderByDescending(c => c.Priority)
.ThenBy(c => c.QueuedAt)
.FirstOrDefault();
// Nothing queued that meets the requirements
if (localItem == null)
{
rval = false;
}
// If any executing command is exclusive don't want return another command until it completes.
else if (startedCommands.Any(c => c.Body.IsExclusive))
{
rval = false;
}
// If the next command to execute is exclusive wait for executing commands to complete.
// This will prevent other tasks from starting so the exclusive task executes in the order it should.
else if (localItem.Body.IsExclusive && startedCommands.Any())
{
rval = false;
}
// A command ready to execute
else
{
localItem.StartedAt = DateTime.Now;
localItem.Status = CommandStatus.Started;
item = localItem;
}
}
}
public void Add(CommandModel item) return rval;
{
lock (Mutex)
{
_items.Add(item);
} }
} }
}
} }

@ -1,11 +1,9 @@
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using NLog; using NLog;
using NzbDrone.Common; using NzbDrone.Common;
using NzbDrone.Common.Cache;
using NzbDrone.Common.EnsureThat; using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Serializer; using NzbDrone.Common.Serializer;
using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Lifecycle;
@ -35,20 +33,17 @@ namespace NzbDrone.Core.Messaging.Commands
private readonly IServiceFactory _serviceFactory; private readonly IServiceFactory _serviceFactory;
private readonly Logger _logger; private readonly Logger _logger;
private readonly ICached<CommandModel> _commandCache; private readonly CommandQueue _commandQueue;
private readonly BlockingCollection<CommandModel> _commandQueue;
public CommandQueueManager(ICommandRepository repo, public CommandQueueManager(ICommandRepository repo,
IServiceFactory serviceFactory, IServiceFactory serviceFactory,
ICacheManager cacheManager,
Logger logger) Logger logger)
{ {
_repo = repo; _repo = repo;
_serviceFactory = serviceFactory; _serviceFactory = serviceFactory;
_logger = logger; _logger = logger;
_commandCache = cacheManager.GetCache<CommandModel>(GetType()); _commandQueue = new CommandQueue();
_commandQueue = new BlockingCollection<CommandModel>(new CommandQueue());
} }
public List<CommandModel> PushMany<TCommand>(List<TCommand> commands) where TCommand : Command public List<CommandModel> PushMany<TCommand>(List<TCommand> commands) where TCommand : Command
@ -56,8 +51,7 @@ namespace NzbDrone.Core.Messaging.Commands
_logger.Trace("Publishing {0} commands", commands.Count); _logger.Trace("Publishing {0} commands", commands.Count);
var commandModels = new List<CommandModel>(); var commandModels = new List<CommandModel>();
var existingCommands = _commandCache.Values.Where(q => q.Status == CommandStatus.Queued || var existingCommands = _commandQueue.QueuedOrStarted();
q.Status == CommandStatus.Started).ToList();
foreach (var command in commands) foreach (var command in commands)
{ {
@ -86,7 +80,6 @@ namespace NzbDrone.Core.Messaging.Commands
foreach (var commandModel in commandModels) foreach (var commandModel in commandModels)
{ {
_commandCache.Set(commandModel.Id.ToString(), commandModel);
_commandQueue.Add(commandModel); _commandQueue.Add(commandModel);
} }
@ -124,7 +117,6 @@ namespace NzbDrone.Core.Messaging.Commands
_logger.Trace("Inserting new command: {0}", commandModel.Name); _logger.Trace("Inserting new command: {0}", commandModel.Name);
_repo.Insert(commandModel); _repo.Insert(commandModel);
_commandCache.Set(commandModel.Id.ToString(), commandModel);
_commandQueue.Add(commandModel); _commandQueue.Add(commandModel);
return commandModel; return commandModel;
@ -146,28 +138,31 @@ namespace NzbDrone.Core.Messaging.Commands
public CommandModel Get(int id) public CommandModel Get(int id)
{ {
return _commandCache.Get(id.ToString(), () => FindCommand(_repo.Get(id))); var command = _commandQueue.Find(id);
if (command == null)
{
command = _repo.Get(id);
}
return command;
} }
public List<CommandModel> GetStarted() public List<CommandModel> GetStarted()
{ {
_logger.Trace("Getting started commands"); _logger.Trace("Getting started commands");
return _commandCache.Values.Where(c => c.Status == CommandStatus.Started).ToList(); return _commandQueue.All().Where(c => c.Status == CommandStatus.Started).ToList();
} }
public void SetMessage(CommandModel command, string message) public void SetMessage(CommandModel command, string message)
{ {
command.Message = message; command.Message = message;
_commandCache.Set(command.Id.ToString(), command);
} }
public void Start(CommandModel command) public void Start(CommandModel command)
{ {
command.StartedAt = DateTime.UtcNow; // Marks the command as started in the DB, the queue takes care of marking it as started on it's own
command.Status = CommandStatus.Started;
_logger.Trace("Marking command as started: {0}", command.Name); _logger.Trace("Marking command as started: {0}", command.Name);
_commandCache.Set(command.Id.ToString(), command);
_repo.Start(command); _repo.Start(command);
} }
@ -195,12 +190,11 @@ namespace NzbDrone.Core.Messaging.Commands
{ {
_logger.Trace("Cleaning up old commands"); _logger.Trace("Cleaning up old commands");
var old = _commandCache.Values.Where(c => c.EndedAt < DateTime.UtcNow.AddMinutes(-5)); var commands = _commandQueue.All()
.Where(c => c.EndedAt < DateTime.UtcNow.AddMinutes(-5))
.ToList();
foreach (var command in old) _commandQueue.RemoveMany(commands);
{
_commandCache.Remove(command.Id.ToString());
}
_repo.Trim(); _repo.Trim();
} }
@ -215,18 +209,6 @@ namespace NzbDrone.Core.Messaging.Commands
return Json.Deserialize("{}", commandType); return Json.Deserialize("{}", commandType);
} }
private CommandModel FindCommand(CommandModel command)
{
var cachedCommand = _commandCache.Find(command.Id.ToString());
if (cachedCommand != null)
{
command.Message = cachedCommand.Message;
}
return command;
}
private void Update(CommandModel command, CommandStatus status, string message) private void Update(CommandModel command, CommandStatus status, string message)
{ {
SetMessage(command, message); SetMessage(command, message);
@ -236,15 +218,14 @@ namespace NzbDrone.Core.Messaging.Commands
command.Status = status; command.Status = status;
_logger.Trace("Updating command status"); _logger.Trace("Updating command status");
_commandCache.Set(command.Id.ToString(), command);
_repo.End(command); _repo.End(command);
} }
private List<CommandModel> QueuedOrStarted(string name) private List<CommandModel> QueuedOrStarted(string name)
{ {
return _commandCache.Values.Where(q => q.Name == name && return _commandQueue.QueuedOrStarted()
(q.Status == CommandStatus.Queued || .Where(q => q.Name == name)
q.Status == CommandStatus.Started)).ToList(); .ToList();
} }
public void Handle(ApplicationStartedEvent message) public void Handle(ApplicationStartedEvent message)

@ -10,6 +10,7 @@ namespace NzbDrone.Core.Music.Commands
public string DestinationRootFolder { get; set; } public string DestinationRootFolder { get; set; }
public override bool SendUpdatesToClient => true; public override bool SendUpdatesToClient => true;
public override bool RequiresDiskAccess => true;
} }
public class BulkMoveArtist : IEquatable<BulkMoveArtist> public class BulkMoveArtist : IEquatable<BulkMoveArtist>

@ -9,5 +9,6 @@ namespace NzbDrone.Core.Music.Commands
public string DestinationPath { get; set; } public string DestinationPath { get; set; }
public override bool SendUpdatesToClient => true; public override bool SendUpdatesToClient => true;
public override bool RequiresDiskAccess => true;
} }
} }

@ -34,7 +34,7 @@ namespace NzbDrone.Core.Music
_logger = logger; _logger = logger;
} }
private void MoveSingleArtist(Artist artist, string sourcePath, string destinationPath) private void MoveSingleArtist(Artist artist, string sourcePath, string destinationPath, int? index = null, int? total = null)
{ {
if (!_diskProvider.FolderExists(sourcePath)) if (!_diskProvider.FolderExists(sourcePath))
{ {
@ -42,7 +42,14 @@ namespace NzbDrone.Core.Music
return; return;
} }
_logger.ProgressInfo("Moving {0} from '{1}' to '{2}'", artist.Name, sourcePath, destinationPath); if (index != null && total != null)
{
_logger.ProgressInfo("Moving {0} from '{1}' to '{2}' ({3}/{4})", artist.Name, sourcePath, destinationPath, index + 1, total);
}
else
{
_logger.ProgressInfo("Moving {0} from '{1}' to '{2}'", artist.Name, sourcePath, destinationPath);
}
try try
{ {
@ -81,12 +88,13 @@ namespace NzbDrone.Core.Music
_logger.ProgressInfo("Moving {0} artist to '{1}'", artistToMove.Count, destinationRootFolder); _logger.ProgressInfo("Moving {0} artist to '{1}'", artistToMove.Count, destinationRootFolder);
foreach (var s in artistToMove) for (var index = 0; index < artistToMove.Count; index++)
{ {
var s = artistToMove[index];
var artist = _artistService.GetArtist(s.ArtistId); var artist = _artistService.GetArtist(s.ArtistId);
var destinationPath = Path.Combine(destinationRootFolder, _filenameBuilder.GetArtistFolder(artist)); var destinationPath = Path.Combine(destinationRootFolder, _filenameBuilder.GetArtistFolder(artist));
MoveSingleArtist(artist, s.SourcePath, destinationPath); MoveSingleArtist(artist, s.SourcePath, destinationPath, index, artistToMove.Count);
} }
_logger.ProgressInfo("Finished moving {0} artist to '{1}'", artistToMove.Count, destinationRootFolder); _logger.ProgressInfo("Finished moving {0} artist to '{1}'", artistToMove.Count, destinationRootFolder);

@ -5,6 +5,7 @@ namespace NzbDrone.Core.Update.Commands
public class ApplicationUpdateCommand : Command public class ApplicationUpdateCommand : Command
{ {
public override bool SendUpdatesToClient => true; public override bool SendUpdatesToClient => true;
public override bool IsExclusive => true;
public override string CompletionMessage => null; public override string CompletionMessage => null;
} }

@ -1,3 +1,4 @@
#! /bin/bash
PLATFORM=$1 PLATFORM=$1
TYPE=$2 TYPE=$2
WHERE="cat != ManualTest" WHERE="cat != ManualTest"

Loading…
Cancel
Save