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() {