New: Improve Plugin Installation and Removal Process

Fixes restart loops
reduces github bans
improves UX with messaging for restart
improves version notes
This commit is contained in:
bakerboy448 2025-09-07 17:53:26 -05:00 committed by Auggie
parent 08a84b6b70
commit c79acf328a
9 changed files with 292 additions and 65 deletions

View file

@ -7,11 +7,13 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel'; import FormLabel from 'Components/Form/FormLabel';
import SpinnerButton from 'Components/Link/SpinnerButton'; import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import PageContent from 'Components/Page/PageContent'; import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody'; import PageContentBody from 'Components/Page/PageContentBody';
import Table from 'Components/Table/Table'; import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import { inputTypes, kinds } from 'Helpers/Props'; import { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import PluginRow from './PluginRow'; import PluginRow from './PluginRow';
const columns = [ const columns = [
@ -78,7 +80,15 @@ class Plugins extends Component {
isInstallingPlugin, isInstallingPlugin,
onInstallPluginPress, onInstallPluginPress,
isUninstallingPlugin, isUninstallingPlugin,
onUninstallPluginPress onUninstallPluginPress,
isRestartRequiredModalOpen,
onCloseRestartRequiredModal,
pluginOwner,
pluginName,
pluginVersion,
pluginAction,
pluginDetailsUrl,
pluginBranch
} = this.props; } = this.props;
const { const {
@ -87,6 +97,23 @@ class Plugins extends Component {
const noPlugins = isPopulated && !error && !items.length; const noPlugins = isPopulated && !error && !items.length;
// Build modal title and message
let modalTitle = translate('RestartRequired');
let modalMessage = translate('LidarrRequiresRestartToApplyPluginChanges');
if (pluginOwner && pluginName && pluginAction) {
const versionText = pluginVersion ? ` v${pluginVersion}` : '';
const branchText = pluginBranch ? ` (${pluginBranch})` : '';
const actionText = pluginAction === 'install' ? 'installed' : 'uninstalled';
modalTitle = `Plugin ${actionText} - ${translate('RestartRequired')}`;
if (pluginDetailsUrl) {
modalMessage = `Plugin: [${pluginOwner}/${pluginName}]${versionText}${branchText}\n\nInstalled from:\n${pluginDetailsUrl}\n\nPlease restart Lidarr to apply changes.`;
} else {
modalMessage = `Plugin: [${pluginOwner}/${pluginName}]${versionText}${branchText}\n\nPlugin ${actionText} successfully.\n\nPlease restart Lidarr to apply changes.`;
}
}
return ( return (
<PageContent title="Plugins"> <PageContent title="Plugins">
<PageContentBody> <PageContentBody>
@ -148,6 +175,17 @@ class Plugins extends Component {
</Table> </Table>
} }
</FieldSet> </FieldSet>
<ConfirmModal
isOpen={isRestartRequiredModalOpen}
kind={kinds.INFO}
title={modalTitle}
message={modalMessage}
confirmLabel={translate('Ok')}
hideCancelButton={true}
onConfirm={onCloseRestartRequiredModal}
onCancel={onCloseRestartRequiredModal}
/>
</PageContentBody> </PageContentBody>
</PageContent> </PageContent>
); );
@ -162,7 +200,15 @@ Plugins.propTypes = {
isInstallingPlugin: PropTypes.bool.isRequired, isInstallingPlugin: PropTypes.bool.isRequired,
onInstallPluginPress: PropTypes.func.isRequired, onInstallPluginPress: PropTypes.func.isRequired,
isUninstallingPlugin: PropTypes.bool.isRequired, isUninstallingPlugin: PropTypes.bool.isRequired,
onUninstallPluginPress: PropTypes.func.isRequired onUninstallPluginPress: PropTypes.func.isRequired,
isRestartRequiredModalOpen: PropTypes.bool,
onCloseRestartRequiredModal: PropTypes.func,
pluginOwner: PropTypes.string,
pluginName: PropTypes.string,
pluginVersion: PropTypes.string,
pluginAction: PropTypes.string,
pluginDetailsUrl: PropTypes.string,
pluginBranch: PropTypes.string
}; };
export default Plugins; export default Plugins;

View file

@ -38,6 +38,20 @@ class PluginsConnector extends Component {
// //
// Lifecycle // Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isRestartRequiredModalOpen: false,
pluginOwner: '',
pluginName: '',
pluginVersion: '',
pluginAction: '',
pluginDetailsUrl: '',
pluginBranch: ''
};
}
componentDidMount() { componentDidMount() {
registerPagePopulator(this.repopulate); registerPagePopulator(this.repopulate);
@ -59,27 +73,108 @@ class PluginsConnector extends Component {
// Listeners // Listeners
onInstallPluginPress = (url) => { onInstallPluginPress = (url) => {
this.currentPluginOperation = { action: 'install', url };
this.props.dispatchExecuteCommand({ this.props.dispatchExecuteCommand({
name: commandNames.INSTALL_PLUGIN, name: commandNames.INSTALL_PLUGIN,
githubUrl: url githubUrl: url,
commandFinished: this.onPluginCommandFinished
}); });
}; };
onUninstallPluginPress = (url) => { onUninstallPluginPress = (url) => {
this.currentPluginOperation = { action: 'uninstall', url };
this.props.dispatchExecuteCommand({ this.props.dispatchExecuteCommand({
name: commandNames.UNINSTALL_PLUGIN, name: commandNames.UNINSTALL_PLUGIN,
githubUrl: url githubUrl: url,
commandFinished: this.onPluginCommandFinished
}); });
}; };
onPluginCommandFinished = (command) => {
let pluginOwner = '';
let pluginName = '';
let pluginVersion = '';
let pluginAction = '';
let pluginDetailsUrl = '';
let pluginBranch = '';
if (this.currentPluginOperation && command) {
const url = this.currentPluginOperation.url;
const match = url.match(/github\.com\/([^/]+)\/([^/]+)/);
if (match) {
[, pluginOwner, pluginName] = match;
pluginAction = this.currentPluginOperation.action;
// Extract branch from GitHub URL
if (url.includes('/tree/')) {
const branchMatch = url.match(/\/tree\/([^/]+)/);
if (branchMatch) {
pluginBranch = branchMatch[1];
}
}
if (this.currentPluginOperation.action === 'install') {
if (command && command.message) {
const pluginMatch = command.message.match(/Plugin \[([^/]+)\/([^\]]+)\] v([0-9.]+) installed/);
if (pluginMatch) {
pluginVersion = pluginMatch[3];
}
console.log('Plugin match result:', pluginMatch);
}
pluginDetailsUrl = url;
} else {
if (command && command.message) {
const pluginMatch = command.message.match(/Plugin \[([^/]+)\/([^\]]+)\] v([0-9.]+) uninstalled/);
if (pluginMatch) {
pluginVersion = pluginMatch[3];
}
}
}
}
}
this.setState({
isRestartRequiredModalOpen: true,
pluginOwner,
pluginName,
pluginVersion,
pluginAction,
pluginDetailsUrl,
pluginBranch
});
this.repopulate();
};
onCloseRestartRequiredModal = () => {
this.setState({
isRestartRequiredModalOpen: false,
pluginOwner: '',
pluginName: '',
pluginVersion: '',
pluginAction: '',
pluginDetailsUrl: '',
pluginBranch: ''
});
this.currentPluginOperation = null;
};
// //
// Render // Render
render() { render() {
return ( return (
<Plugins <Plugins
isRestartRequiredModalOpen={this.state.isRestartRequiredModalOpen}
pluginOwner={this.state.pluginOwner}
pluginName={this.state.pluginName}
pluginVersion={this.state.pluginVersion}
pluginAction={this.state.pluginAction}
pluginDetailsUrl={this.state.pluginDetailsUrl}
pluginBranch={this.state.pluginBranch}
onInstallPluginPress={this.onInstallPluginPress} onInstallPluginPress={this.onInstallPluginPress}
onUninstallPluginPress={this.onUninstallPluginPress} onUninstallPluginPress={this.onUninstallPluginPress}
onCloseRestartRequiredModal={this.onCloseRestartRequiredModal}
{...this.props} {...this.props}
/> />
); );
@ -89,7 +184,8 @@ class PluginsConnector extends Component {
PluginsConnector.propTypes = { PluginsConnector.propTypes = {
dispatchFetchInstalledPlugins: PropTypes.func.isRequired, dispatchFetchInstalledPlugins: PropTypes.func.isRequired,
dispatchExecuteCommand: PropTypes.func.isRequired dispatchExecuteCommand: PropTypes.func.isRequired,
items: PropTypes.array
}; };
export default connect(createMapStateToProps, mapDispatchToProps)(PluginsConnector); export default connect(createMapStateToProps, mapDispatchToProps)(PluginsConnector);

View file

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Lidarr.Http.REST; using Lidarr.Http.REST;
@ -17,6 +18,21 @@ public class PluginResource : RestResource
public static class PluginResourceMapper public static class PluginResourceMapper
{ {
private static string FormatVersion(Version version)
{
if (version == null)
{
return string.Empty;
}
// Always show 4-part version for UI consistency, handling undefined components
var major = version.Major;
var minor = version.Minor;
var build = version.Build == -1 ? 0 : version.Build;
var revision = version.Revision == -1 ? 0 : version.Revision;
return $"{major}.{minor}.{build}.{revision}";
}
public static PluginResource ToResource(this IPlugin plugin) public static PluginResource ToResource(this IPlugin plugin)
{ {
return new PluginResource return new PluginResource
@ -24,8 +40,8 @@ public static PluginResource ToResource(this IPlugin plugin)
Name = plugin.Name, Name = plugin.Name,
Owner = plugin.Owner, Owner = plugin.Owner,
GithubUrl = plugin.GithubUrl, GithubUrl = plugin.GithubUrl,
InstalledVersion = plugin.InstalledVersion.ToString(), InstalledVersion = FormatVersion(plugin.InstalledVersion),
AvailableVersion = plugin.AvailableVersion.ToString(), AvailableVersion = FormatVersion(plugin.AvailableVersion),
UpdateAvailable = plugin.AvailableVersion > plugin.InstalledVersion UpdateAvailable = plugin.AvailableVersion > plugin.InstalledVersion
}; };
} }

View file

@ -678,6 +678,7 @@
"LastSearched": "Last Searched", "LastSearched": "Last Searched",
"LastUsed": "Last Used", "LastUsed": "Last Used",
"LastWriteTime": "Last Write Time", "LastWriteTime": "Last Write Time",
"LidarrRequiresRestartToApplyPluginChanges": "Lidarr requires a restart to apply plugin changes",
"LatestAlbum": "Latest Album", "LatestAlbum": "Latest Album",
"LatestAlbumData": "Monitor the latest albums and future albums", "LatestAlbumData": "Monitor the latest albums and future albums",
"LaunchBrowserHelpText": " Open a web browser and navigate to {appName} homepage on app start.", "LaunchBrowserHelpText": " Open a web browser and navigate to {appName} homepage on app start.",
@ -914,6 +915,7 @@
"Period": "Period", "Period": "Period",
"Permissions": "Permissions", "Permissions": "Permissions",
"Playlist": "Playlist", "Playlist": "Playlist",
"Plugins": "Plugins",
"Port": "Port", "Port": "Port",
"PortNumber": "Port Number", "PortNumber": "Port Number",
"PostImportCategory": "Post-Import Category", "PostImportCategory": "Post-Import Category",
@ -1082,6 +1084,7 @@
"Restart": "Restart", "Restart": "Restart",
"RestartLidarr": "Restart {appName}", "RestartLidarr": "Restart {appName}",
"RestartNow": "Restart Now", "RestartNow": "Restart Now",
"RestartRequired": "Restart Required",
"RestartRequiredHelpTextWarning": "Requires restart to take effect", "RestartRequiredHelpTextWarning": "Requires restart to take effect",
"Restore": "Restore", "Restore": "Restore",
"RestoreBackup": "Restore Backup", "RestoreBackup": "Restore Backup",

View file

@ -8,6 +8,5 @@ public class InstallPluginCommand : Command
public override bool SendUpdatesToClient => true; public override bool SendUpdatesToClient => true;
public override bool IsExclusive => true; public override bool IsExclusive => true;
public override string CompletionMessage => null;
} }
} }

View file

@ -8,6 +8,5 @@ public class UninstallPluginCommand : Command
public override bool SendUpdatesToClient => true; public override bool SendUpdatesToClient => true;
public override bool IsExclusive => true; public override bool IsExclusive => true;
public override string CompletionMessage => null;
} }
} }

View file

@ -1,5 +1,6 @@
using System;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Linq;
using NLog; using NLog;
using NzbDrone.Common; using NzbDrone.Common;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
@ -7,7 +8,6 @@
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http; using NzbDrone.Common.Http;
using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.Lifecycle;
using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Plugins.Commands; using NzbDrone.Core.Plugins.Commands;
@ -20,7 +20,6 @@ public class InstallPluginService : IExecute<InstallPluginCommand>, IExecute<Uni
private readonly IAppFolderInfo _appFolderInfo; private readonly IAppFolderInfo _appFolderInfo;
private readonly IHttpClient _httpClient; private readonly IHttpClient _httpClient;
private readonly IArchiveService _archiveService; private readonly IArchiveService _archiveService;
private readonly ILifecycleService _lifecycleService;
private readonly Logger _logger; private readonly Logger _logger;
public InstallPluginService(IPluginService pluginService, public InstallPluginService(IPluginService pluginService,
@ -28,7 +27,6 @@ public InstallPluginService(IPluginService pluginService,
IAppFolderInfo appFolderInfo, IAppFolderInfo appFolderInfo,
IHttpClient httpClient, IHttpClient httpClient,
IArchiveService archiveService, IArchiveService archiveService,
ILifecycleService lifecycleService,
Logger logger) Logger logger)
{ {
_pluginService = pluginService; _pluginService = pluginService;
@ -36,14 +34,19 @@ public InstallPluginService(IPluginService pluginService,
_appFolderInfo = appFolderInfo; _appFolderInfo = appFolderInfo;
_httpClient = httpClient; _httpClient = httpClient;
_archiveService = archiveService; _archiveService = archiveService;
_lifecycleService = lifecycleService;
_logger = logger; _logger = logger;
} }
public void Execute(UninstallPluginCommand message) public void Execute(UninstallPluginCommand message)
{ {
var (owner, name) = _pluginService.ParseUrl(message.GithubUrl); var (owner, name) = _pluginService.ParseUrl(message.GithubUrl);
UninstallPlugin(owner, name);
// Get installed version before uninstalling
var installedPlugins = _pluginService.GetInstalledPlugins();
var installedPlugin = installedPlugins.FirstOrDefault(p => p.Owner == owner && p.Name == name);
var version = installedPlugin?.InstalledVersion;
UninstallPlugin(owner, name, version);
} }
public void Execute(InstallPluginCommand message) public void Execute(InstallPluginCommand message)
@ -67,24 +70,30 @@ private void InstallPlugin(RemotePlugin package)
} }
var packageDestination = Path.Combine(tempFolder, $"{package.Name}.zip"); var packageDestination = Path.Combine(tempFolder, $"{package.Name}.zip");
var packageTitle = $"{package.Owner}/{package.Name} v{package.Version}";
_logger.ProgressInfo($"Downloading plugin {package.Name}"); _logger.ProgressInfo($"Downloading plugin [{packageTitle}]");
_httpClient.DownloadFile(package.PackageUrl, packageDestination); _httpClient.DownloadFile(package.PackageUrl, packageDestination);
_logger.ProgressInfo("Extracting Plugin package"); _logger.ProgressInfo($"Extracting plugin [{packageTitle}]");
_archiveService.Extract(packageDestination, Path.Combine(PluginFolder(), package.Owner, package.Name)); _archiveService.Extract(packageDestination, Path.Combine(PluginFolder(), package.Owner, package.Name));
_logger.ProgressInfo($"Installed {package.Name}, restarting"); _logger.ProgressInfo($"Plugin [{package.Owner}/{package.Name}] v{package.Version} installed. Please restart Lidarr.");
Task.Factory.StartNew(() => _lifecycleService.Restart());
} }
private void UninstallPlugin(string owner, string name) private void UninstallPlugin(string owner, string name, Version version)
{ {
_logger.ProgressInfo($"Uninstalling Plugin {owner}/{name}"); _logger.ProgressInfo($"Uninstalling plugin [{owner}/{name}]");
_diskProvider.DeleteFolder(Path.Combine(PluginFolder(), owner, name), true); var pluginFolder = Path.Combine(PluginFolder(), owner, name);
_logger.ProgressInfo($"Uninstalled Plugin {owner}/{name}, restarting"); _logger.Debug("Deleting folder: {0}", pluginFolder);
_diskProvider.DeleteFolder(pluginFolder, true);
Task.Factory.StartNew(() => _lifecycleService.Restart()); if (version != null)
{
_logger.ProgressInfo($"Plugin [{owner}/{name}] v{version} uninstalled. Please restart Lidarr.");
}
else
{
_logger.ProgressInfo($"Plugin [{owner}/{name}] uninstalled. Please restart Lidarr.");
}
} }
private void EnsurePluginFolder() private void EnsurePluginFolder()

View file

@ -52,51 +52,69 @@ public PluginService(IHttpClient httpClient,
public RemotePlugin GetRemotePlugin(string repoUrl) public RemotePlugin GetRemotePlugin(string repoUrl)
{ {
var (owner, name) = ParseUrl(repoUrl); try
var releaseUrl = $"https://api.github.com/repos/{owner}/{name}/releases";
var releases = _httpClient.Get<List<Release>>(new HttpRequest(releaseUrl)).Resource;
if (!releases?.Any() ?? true)
{ {
_logger.Warn($"No releases found for {name}"); var (owner, name) = ParseUrl(repoUrl);
var releaseUrl = $"https://api.github.com/repos/{owner}/{name}/releases";
var releases = _httpClient.Get<List<Release>>(new HttpRequest(releaseUrl)).Resource;
if (!releases?.Any() ?? true)
{
_logger.Warn($"No releases found for {name}");
return null;
}
var latest = releases.OrderByDescending(x => x.PublishedAt).FirstOrDefault(x => IsSupported(x));
if (latest == null)
{
_logger.Warn($"Plugin {name} requires newer version of Lidarr");
return null;
}
var version = Version.Parse(latest.TagName.TrimStart('v'));
var framework = "net6.0";
var asset = latest.Assets.FirstOrDefault(x => x.Name.EndsWith($"{framework}.zip"));
if (asset == null)
{
_logger.Warn($"No plugin package found for {framework} for {name}");
return null;
}
return new RemotePlugin
{
GithubUrl = repoUrl,
Name = name,
Owner = owner,
Version = version,
PackageUrl = asset.BrowserDownloadUrl
};
}
catch (Exception ex)
{
_logger.Warn(ex, "Unable to get remote plugin information for {0}", repoUrl);
return null; return null;
} }
var latest = releases.OrderByDescending(x => x.PublishedAt).FirstOrDefault(x => IsSupported(x));
if (latest == null)
{
_logger.Warn($"Plugin {name} requires newer version of Lidarr");
return null;
}
var version = Version.Parse(latest.TagName.TrimStart('v'));
var framework = "net6.0";
var asset = latest.Assets.FirstOrDefault(x => x.Name.EndsWith($"{framework}.zip"));
if (asset == null)
{
_logger.Warn($"No plugin package found for {framework} for {name}");
return null;
}
return new RemotePlugin
{
GithubUrl = repoUrl,
Name = name,
Owner = owner,
Version = version,
PackageUrl = asset.BrowserDownloadUrl
};
} }
public List<IPlugin> GetInstalledPlugins() public List<IPlugin> GetInstalledPlugins()
{ {
foreach (var plugin in _installedPlugins) foreach (var plugin in _installedPlugins)
{ {
var remote = GetRemotePlugin(plugin.GithubUrl); try
plugin.AvailableVersion = remote.Version; {
var remote = GetRemotePlugin(plugin.GithubUrl);
if (remote != null)
{
plugin.AvailableVersion = remote.Version;
}
}
catch (Exception ex)
{
_logger.Warn(ex, "Unable to check for updates for plugin {0}/{1}", plugin.Owner, plugin.Name);
}
} }
return _installedPlugins; return _installedPlugins;

View file

@ -2,6 +2,7 @@
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Common.Serializer; using NzbDrone.Common.Serializer;
using NzbDrone.Core.Lifecycle.Commands;
using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Plugins.Commands; using NzbDrone.Core.Plugins.Commands;
using NzbDrone.Integration.Test.Client; using NzbDrone.Integration.Test.Client;
@ -16,11 +17,13 @@ public class PluginFixture : IntegrationTest
[Order(0)] [Order(0)]
public void should_install_plugin() public void should_install_plugin()
{ {
PostAndWaitForRestart(new InstallPluginCommand PostAndWaitForCompletion(new InstallPluginCommand
{ {
GithubUrl = "https://github.com/ta264/Lidarr.Plugin.Deemix" GithubUrl = "https://github.com/ta264/Lidarr.Plugin.Deemix"
}); });
PostAndWaitForRestart(new RestartCommand());
WaitForRestart(); WaitForRestart();
var plugins = Plugins.All(); var plugins = Plugins.All();
@ -36,18 +39,19 @@ public void should_uninstall_plugin()
plugins.Should().HaveCount(1); plugins.Should().HaveCount(1);
plugins[0].Name.Should().Be("Deemix"); plugins[0].Name.Should().Be("Deemix");
PostAndWaitForRestart(new UninstallPluginCommand PostAndWaitForCompletion(new UninstallPluginCommand
{ {
GithubUrl = "https://github.com/ta264/Lidarr.Plugin.Deemix" GithubUrl = "https://github.com/ta264/Lidarr.Plugin.Deemix"
}); });
PostAndWaitForRestart(new RestartCommand());
WaitForRestart(); WaitForRestart();
plugins = Plugins.All(); plugins = Plugins.All();
plugins.Should().BeEmpty(); plugins.Should().BeEmpty();
} }
// Installing / uninstalling triggers a restart, so manually shutdown the restarted app
[OneTimeTearDown] [OneTimeTearDown]
public void TearDown() public void TearDown()
{ {
@ -57,6 +61,43 @@ public void TearDown()
RestClient.Execute(request); RestClient.Execute(request);
} }
private SimpleCommandResource PostAndWaitForCompletion<T>(T command)
where T : Command, new()
{
var request = new RestRequest("command");
request.Method = Method.POST;
request.AddHeader("Authorization", ApiKey);
request.AddJsonBody(command);
var result = RestClient.Execute(request);
var resource = Json.Deserialize<SimpleCommandResource>(result.Content);
var id = resource.Id;
id.Should().NotBe(0);
for (var i = 0; i < 50; i++)
{
if (resource?.Status == CommandStatus.Completed)
{
return resource;
}
var get = new RestRequest($"command/{id}");
get.AddHeader("Authorization", ApiKey);
result = RestClient.Execute(get);
TestContext.Progress.WriteLine("Waiting for command to finish : {0} [{1}] {2}\n{3}", result.ResponseStatus, result.StatusDescription, result.ErrorException?.Message, result.Content);
resource = Json.Deserialize<SimpleCommandResource>(result.Content);
Thread.Sleep(500);
}
Assert.Fail("Command failed");
return resource;
}
private SimpleCommandResource PostAndWaitForRestart<T>(T command) private SimpleCommandResource PostAndWaitForRestart<T>(T command)
where T : Command, new() where T : Command, new()
{ {