diff --git a/frontend/src/System/Plugins/Plugins.js b/frontend/src/System/Plugins/Plugins.js
index e7d65e31d..8bebf3c56 100644
--- a/frontend/src/System/Plugins/Plugins.js
+++ b/frontend/src/System/Plugins/Plugins.js
@@ -7,11 +7,13 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { inputTypes, kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
import PluginRow from './PluginRow';
const columns = [
@@ -78,7 +80,15 @@ class Plugins extends Component {
isInstallingPlugin,
onInstallPluginPress,
isUninstallingPlugin,
- onUninstallPluginPress
+ onUninstallPluginPress,
+ isRestartRequiredModalOpen,
+ onCloseRestartRequiredModal,
+ pluginOwner,
+ pluginName,
+ pluginVersion,
+ pluginAction,
+ pluginDetailsUrl,
+ pluginBranch
} = this.props;
const {
@@ -87,6 +97,23 @@ class Plugins extends Component {
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 (
@@ -148,6 +175,17 @@ class Plugins extends Component {
}
+
+
);
@@ -162,7 +200,15 @@ Plugins.propTypes = {
isInstallingPlugin: PropTypes.bool.isRequired,
onInstallPluginPress: PropTypes.func.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;
diff --git a/frontend/src/System/Plugins/PluginsConnector.js b/frontend/src/System/Plugins/PluginsConnector.js
index 5a87be330..3882cd10d 100644
--- a/frontend/src/System/Plugins/PluginsConnector.js
+++ b/frontend/src/System/Plugins/PluginsConnector.js
@@ -38,6 +38,20 @@ class PluginsConnector extends Component {
//
// Lifecycle
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isRestartRequiredModalOpen: false,
+ pluginOwner: '',
+ pluginName: '',
+ pluginVersion: '',
+ pluginAction: '',
+ pluginDetailsUrl: '',
+ pluginBranch: ''
+ };
+ }
+
componentDidMount() {
registerPagePopulator(this.repopulate);
@@ -59,27 +73,108 @@ class PluginsConnector extends Component {
// Listeners
onInstallPluginPress = (url) => {
+ this.currentPluginOperation = { action: 'install', url };
this.props.dispatchExecuteCommand({
name: commandNames.INSTALL_PLUGIN,
- githubUrl: url
+ githubUrl: url,
+ commandFinished: this.onPluginCommandFinished
});
};
onUninstallPluginPress = (url) => {
+ this.currentPluginOperation = { action: 'uninstall', url };
this.props.dispatchExecuteCommand({
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() {
return (
);
@@ -89,7 +184,8 @@ class PluginsConnector extends Component {
PluginsConnector.propTypes = {
dispatchFetchInstalledPlugins: PropTypes.func.isRequired,
- dispatchExecuteCommand: PropTypes.func.isRequired
+ dispatchExecuteCommand: PropTypes.func.isRequired,
+ items: PropTypes.array
};
export default connect(createMapStateToProps, mapDispatchToProps)(PluginsConnector);
diff --git a/src/Lidarr.Api.V1/System/Plugins/PluginResource.cs b/src/Lidarr.Api.V1/System/Plugins/PluginResource.cs
index 4b757735f..90fdf2747 100644
--- a/src/Lidarr.Api.V1/System/Plugins/PluginResource.cs
+++ b/src/Lidarr.Api.V1/System/Plugins/PluginResource.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Linq;
using Lidarr.Http.REST;
@@ -17,6 +18,21 @@ public class PluginResource : RestResource
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)
{
return new PluginResource
@@ -24,8 +40,8 @@ public static PluginResource ToResource(this IPlugin plugin)
Name = plugin.Name,
Owner = plugin.Owner,
GithubUrl = plugin.GithubUrl,
- InstalledVersion = plugin.InstalledVersion.ToString(),
- AvailableVersion = plugin.AvailableVersion.ToString(),
+ InstalledVersion = FormatVersion(plugin.InstalledVersion),
+ AvailableVersion = FormatVersion(plugin.AvailableVersion),
UpdateAvailable = plugin.AvailableVersion > plugin.InstalledVersion
};
}
diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json
index 531cbc38d..584893c3a 100644
--- a/src/NzbDrone.Core/Localization/Core/en.json
+++ b/src/NzbDrone.Core/Localization/Core/en.json
@@ -678,6 +678,7 @@
"LastSearched": "Last Searched",
"LastUsed": "Last Used",
"LastWriteTime": "Last Write Time",
+ "LidarrRequiresRestartToApplyPluginChanges": "Lidarr requires a restart to apply plugin changes",
"LatestAlbum": "Latest Album",
"LatestAlbumData": "Monitor the latest albums and future albums",
"LaunchBrowserHelpText": " Open a web browser and navigate to {appName} homepage on app start.",
@@ -914,6 +915,7 @@
"Period": "Period",
"Permissions": "Permissions",
"Playlist": "Playlist",
+ "Plugins": "Plugins",
"Port": "Port",
"PortNumber": "Port Number",
"PostImportCategory": "Post-Import Category",
@@ -1082,6 +1084,7 @@
"Restart": "Restart",
"RestartLidarr": "Restart {appName}",
"RestartNow": "Restart Now",
+ "RestartRequired": "Restart Required",
"RestartRequiredHelpTextWarning": "Requires restart to take effect",
"Restore": "Restore",
"RestoreBackup": "Restore Backup",
diff --git a/src/NzbDrone.Core/Plugins/Commands/InstallPluginCommand.cs b/src/NzbDrone.Core/Plugins/Commands/InstallPluginCommand.cs
index f1c3517ea..64470f01b 100644
--- a/src/NzbDrone.Core/Plugins/Commands/InstallPluginCommand.cs
+++ b/src/NzbDrone.Core/Plugins/Commands/InstallPluginCommand.cs
@@ -8,6 +8,5 @@ public class InstallPluginCommand : Command
public override bool SendUpdatesToClient => true;
public override bool IsExclusive => true;
- public override string CompletionMessage => null;
}
}
diff --git a/src/NzbDrone.Core/Plugins/Commands/UninstallPluginCommand.cs b/src/NzbDrone.Core/Plugins/Commands/UninstallPluginCommand.cs
index 15658c7ce..2eb78bb33 100644
--- a/src/NzbDrone.Core/Plugins/Commands/UninstallPluginCommand.cs
+++ b/src/NzbDrone.Core/Plugins/Commands/UninstallPluginCommand.cs
@@ -8,6 +8,5 @@ public class UninstallPluginCommand : Command
public override bool SendUpdatesToClient => true;
public override bool IsExclusive => true;
- public override string CompletionMessage => null;
}
}
diff --git a/src/NzbDrone.Core/Plugins/InstallPluginService.cs b/src/NzbDrone.Core/Plugins/InstallPluginService.cs
index bbce68259..9e679e602 100644
--- a/src/NzbDrone.Core/Plugins/InstallPluginService.cs
+++ b/src/NzbDrone.Core/Plugins/InstallPluginService.cs
@@ -1,5 +1,6 @@
+using System;
using System.IO;
-using System.Threading.Tasks;
+using System.Linq;
using NLog;
using NzbDrone.Common;
using NzbDrone.Common.Disk;
@@ -7,7 +8,6 @@
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Common.Instrumentation.Extensions;
-using NzbDrone.Core.Lifecycle;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Plugins.Commands;
@@ -20,7 +20,6 @@ public class InstallPluginService : IExecute, IExecute p.Owner == owner && p.Name == name);
+ var version = installedPlugin?.InstalledVersion;
+
+ UninstallPlugin(owner, name, version);
}
public void Execute(InstallPluginCommand message)
@@ -67,24 +70,30 @@ private void InstallPlugin(RemotePlugin package)
}
var packageDestination = Path.Combine(tempFolder, $"{package.Name}.zip");
-
- _logger.ProgressInfo($"Downloading plugin {package.Name}");
+ var packageTitle = $"{package.Owner}/{package.Name} v{package.Version}";
+ _logger.ProgressInfo($"Downloading plugin [{packageTitle}]");
_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));
- _logger.ProgressInfo($"Installed {package.Name}, restarting");
-
- Task.Factory.StartNew(() => _lifecycleService.Restart());
+ _logger.ProgressInfo($"Plugin [{package.Owner}/{package.Name}] v{package.Version} installed. Please restart Lidarr.");
}
- private void UninstallPlugin(string owner, string name)
+ private void UninstallPlugin(string owner, string name, Version version)
{
- _logger.ProgressInfo($"Uninstalling Plugin {owner}/{name}");
- _diskProvider.DeleteFolder(Path.Combine(PluginFolder(), owner, name), true);
- _logger.ProgressInfo($"Uninstalled Plugin {owner}/{name}, restarting");
+ _logger.ProgressInfo($"Uninstalling plugin [{owner}/{name}]");
+ var pluginFolder = Path.Combine(PluginFolder(), owner, name);
+ _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()
diff --git a/src/NzbDrone.Core/Plugins/PluginService.cs b/src/NzbDrone.Core/Plugins/PluginService.cs
index 3c73c81d1..2c38bd6f1 100644
--- a/src/NzbDrone.Core/Plugins/PluginService.cs
+++ b/src/NzbDrone.Core/Plugins/PluginService.cs
@@ -52,51 +52,69 @@ public PluginService(IHttpClient httpClient,
public RemotePlugin GetRemotePlugin(string repoUrl)
{
- var (owner, name) = ParseUrl(repoUrl);
- var releaseUrl = $"https://api.github.com/repos/{owner}/{name}/releases";
-
- var releases = _httpClient.Get>(new HttpRequest(releaseUrl)).Resource;
-
- if (!releases?.Any() ?? true)
+ try
{
- _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>(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;
}
-
- 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 GetInstalledPlugins()
{
foreach (var plugin in _installedPlugins)
{
- var remote = GetRemotePlugin(plugin.GithubUrl);
- plugin.AvailableVersion = remote.Version;
+ try
+ {
+ 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;
diff --git a/src/NzbDrone.Integration.Test/ApiTests/PluginFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/PluginFixture.cs
index 7d8c352c9..db1753082 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/PluginFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/PluginFixture.cs
@@ -2,6 +2,7 @@
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.Serializer;
+using NzbDrone.Core.Lifecycle.Commands;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Plugins.Commands;
using NzbDrone.Integration.Test.Client;
@@ -16,11 +17,13 @@ public class PluginFixture : IntegrationTest
[Order(0)]
public void should_install_plugin()
{
- PostAndWaitForRestart(new InstallPluginCommand
+ PostAndWaitForCompletion(new InstallPluginCommand
{
GithubUrl = "https://github.com/ta264/Lidarr.Plugin.Deemix"
});
+ PostAndWaitForRestart(new RestartCommand());
+
WaitForRestart();
var plugins = Plugins.All();
@@ -36,18 +39,19 @@ public void should_uninstall_plugin()
plugins.Should().HaveCount(1);
plugins[0].Name.Should().Be("Deemix");
- PostAndWaitForRestart(new UninstallPluginCommand
+ PostAndWaitForCompletion(new UninstallPluginCommand
{
GithubUrl = "https://github.com/ta264/Lidarr.Plugin.Deemix"
});
+ PostAndWaitForRestart(new RestartCommand());
+
WaitForRestart();
plugins = Plugins.All();
plugins.Should().BeEmpty();
}
- // Installing / uninstalling triggers a restart, so manually shutdown the restarted app
[OneTimeTearDown]
public void TearDown()
{
@@ -57,6 +61,43 @@ public void TearDown()
RestClient.Execute(request);
}
+ private SimpleCommandResource PostAndWaitForCompletion(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(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(result.Content);
+ Thread.Sleep(500);
+ }
+
+ Assert.Fail("Command failed");
+ return resource;
+ }
+
private SimpleCommandResource PostAndWaitForRestart(T command)
where T : Command, new()
{