From f46acd61c8c9aca25690c64c43ab4f6be6863cec Mon Sep 17 00:00:00 2001 From: Meyn Date: Tue, 4 Nov 2025 16:49:04 +0100 Subject: [PATCH] Improved PluginService with tree support --- .../System/Plugins/PluginResource.cs | 20 +- .../Plugins/InstallPluginService.cs | 5 +- src/NzbDrone.Core/Plugins/Plugin.cs | 37 ++- src/NzbDrone.Core/Plugins/PluginService.cs | 231 ++++++++++++++---- src/NzbDrone.Core/Plugins/PluginVersion.cs | 122 +++++++++ 5 files changed, 334 insertions(+), 81 deletions(-) create mode 100644 src/NzbDrone.Core/Plugins/PluginVersion.cs diff --git a/src/Lidarr.Api.V1/System/Plugins/PluginResource.cs b/src/Lidarr.Api.V1/System/Plugins/PluginResource.cs index 90fdf2747..a5de44c5b 100644 --- a/src/Lidarr.Api.V1/System/Plugins/PluginResource.cs +++ b/src/Lidarr.Api.V1/System/Plugins/PluginResource.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using Lidarr.Http.REST; @@ -18,21 +17,6 @@ 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 @@ -40,8 +24,8 @@ public static PluginResource ToResource(this IPlugin plugin) Name = plugin.Name, Owner = plugin.Owner, GithubUrl = plugin.GithubUrl, - InstalledVersion = FormatVersion(plugin.InstalledVersion), - AvailableVersion = FormatVersion(plugin.AvailableVersion), + InstalledVersion = plugin.InstalledVersion.ToFormattedString(), + AvailableVersion = plugin.AvailableVersion.ToFormattedString(), UpdateAvailable = plugin.AvailableVersion > plugin.InstalledVersion }; } diff --git a/src/NzbDrone.Core/Plugins/InstallPluginService.cs b/src/NzbDrone.Core/Plugins/InstallPluginService.cs index 9e679e602..2ef7e29d0 100644 --- a/src/NzbDrone.Core/Plugins/InstallPluginService.cs +++ b/src/NzbDrone.Core/Plugins/InstallPluginService.cs @@ -1,4 +1,3 @@ -using System; using System.IO; using System.Linq; using NLog; @@ -39,7 +38,7 @@ public InstallPluginService(IPluginService pluginService, public void Execute(UninstallPluginCommand message) { - var (owner, name) = _pluginService.ParseUrl(message.GithubUrl); + var (owner, name, tree) = _pluginService.ParseRepositoryInput(message.GithubUrl); // Get installed version before uninstalling var installedPlugins = _pluginService.GetInstalledPlugins(); @@ -79,7 +78,7 @@ private void InstallPlugin(RemotePlugin package) _logger.ProgressInfo($"Plugin [{package.Owner}/{package.Name}] v{package.Version} installed. Please restart Lidarr."); } - private void UninstallPlugin(string owner, string name, Version version) + private void UninstallPlugin(string owner, string name, PluginVersion version) { _logger.ProgressInfo($"Uninstalling plugin [{owner}/{name}]"); var pluginFolder = Path.Combine(PluginFolder(), owner, name); diff --git a/src/NzbDrone.Core/Plugins/Plugin.cs b/src/NzbDrone.Core/Plugins/Plugin.cs index 5184b6069..667f2a9d2 100644 --- a/src/NzbDrone.Core/Plugins/Plugin.cs +++ b/src/NzbDrone.Core/Plugins/Plugin.cs @@ -1,4 +1,5 @@ -using System; +using System.Reflection; +using NzbDrone.Common.Extensions; namespace NzbDrone.Core.Plugins { @@ -7,18 +8,41 @@ public interface IPlugin string Name { get; } string Owner { get; } string GithubUrl { get; } - Version InstalledVersion { get; } - Version AvailableVersion { get; set; } + PluginVersion InstalledVersion { get; } + PluginVersion AvailableVersion { get; set; } } public abstract class Plugin : IPlugin { + private PluginVersion _installedVersion; + public virtual string Name { get; } public virtual string Owner { get; } public virtual string GithubUrl { get; } - public Version InstalledVersion => GetType().Assembly.GetName().Version; - public Version AvailableVersion { get; set; } + public PluginVersion InstalledVersion + { + get + { + if (_installedVersion != null) + { + return _installedVersion; + } + + var informationalVersion = GetType().Assembly + .GetCustomAttribute()? + .InformationalVersion; + + if (informationalVersion.IsNotNullOrWhiteSpace()) + { + _installedVersion = PluginVersion.Parse(informationalVersion); + } + + return _installedVersion ??= GetType().Assembly.GetName().Version; + } + } + + public PluginVersion AvailableVersion { get; set; } } public class RemotePlugin @@ -26,7 +50,8 @@ public class RemotePlugin public string Name { get; set; } public string Owner { get; set; } public string GithubUrl { get; set; } - public Version Version { get; set; } + public PluginVersion Version { get; set; } public string PackageUrl { get; set; } + public string Tree { get; set; } } } diff --git a/src/NzbDrone.Core/Plugins/PluginService.cs b/src/NzbDrone.Core/Plugins/PluginService.cs index 394cd3137..9efc4d7e6 100644 --- a/src/NzbDrone.Core/Plugins/PluginService.cs +++ b/src/NzbDrone.Core/Plugins/PluginService.cs @@ -11,90 +11,115 @@ namespace NzbDrone.Core.Plugins { public interface IPluginService { - (string, string) ParseUrl(string repoUrl); - RemotePlugin GetRemotePlugin(string repoUrl); + (string Owner, string Name, string Tree) ParseRepositoryInput(string input); + RemotePlugin GetRemotePlugin(string input); List GetInstalledPlugins(); } - public class PluginService : IPluginService + public partial class PluginService : IPluginService { - private static readonly Regex RepoRegex = new Regex(@"https://github.com/(?[^/]*)/(?[^/]*)", RegexOptions.Compiled); - private static readonly Regex MinVersionRegex = new Regex(@"Minimum Lidarr Version: (?\d+\.\d+\.\d+\.\d+)", RegexOptions.Compiled); - private readonly IHttpClient _httpClient; + private readonly IPlatformInfo _platformInfo; private readonly List _installedPlugins; private readonly Logger _logger; - public PluginService(IHttpClient httpClient, - IEnumerable installedPlugins, - Logger logger) + public PluginService( + IHttpClient httpClient, + IPlatformInfo platformInfo, + IEnumerable installedPlugins, + Logger logger) { _httpClient = httpClient; - _installedPlugins = installedPlugins.ToList(); + _platformInfo = platformInfo; _logger = logger; + _installedPlugins = installedPlugins?.ToList() ?? new List(); } - public (string, string) ParseUrl(string repoUrl) - { - var match = RepoRegex.Match(repoUrl); + private string Framework => $"net{_platformInfo.Version.Major}.0"; - if (!match.Success) - { - _logger.Warn("Invalid plugin repo URL"); - return (null, null); - } - - var owner = match.Groups["owner"].Value; - var name = match.Groups["name"].Value; - - return (owner, name); - } - - public RemotePlugin GetRemotePlugin(string repoUrl) + public RemotePlugin GetRemotePlugin(string input) { try { - 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) + var (owner, name, tree) = ParseRepositoryInput(input); + if (string.IsNullOrWhiteSpace(owner)) { - _logger.Warn($"No releases found for {name}"); return null; } - var latest = releases.OrderByDescending(x => x.PublishedAt).FirstOrDefault(x => IsSupported(x)); + _logger.Trace($"Fetching releases for {owner}/{name}" + (tree != null ? $" on tree '{tree}'" : "")); - if (latest == null) + var releasesUrl = $"https://api.github.com/repos/{owner}/{name}/releases"; + var releases = _httpClient.Get>(new HttpRequest(releasesUrl)).Resource; + + if (releases?.Any() != true) { - _logger.Warn($"Plugin {name} requires newer version of Lidarr"); + _logger.Warn($"No releases found for {owner}/{name}"); return null; } - var version = Version.Parse(latest.TagName.TrimStart('v')); - var framework = "net8.0"; - var asset = latest.Assets.FirstOrDefault(x => x.Name.EndsWith($"{framework}.zip")); + _logger.Trace($"Found {releases.Count} total releases, filtering for framework {Framework}" + (tree != null ? $" and tree '{tree}'" : "")); + + var compatibleReleases = releases + .Where(r => !r.Draft && + (IsMatchingTree(r.TargetCommitish, tree) || IsMatchingTree(r.TagName, tree)) && + HasCompatibleAsset(r, Framework) && MeetsMinimumVersion(r.Body)) + .OrderByDescending(r => r.PublishedAt) + .ToList(); + + _logger.Trace($"Found {compatibleReleases.Count} compatible releases after filtering"); + + var release = compatibleReleases.FirstOrDefault(); + if (release == null) + { + _logger.Warn($"No compatible release found for {name} with framework {Framework}" + + (tree != null ? $" and tree {tree}" : "")); + return null; + } + + var releaseUrl = release.HtmlUrl; + var urlMatch = RepoUrlExtractorRegex().Match(releaseUrl); + if (urlMatch.Success) + { + owner = urlMatch.Groups["owner"].Value; + name = urlMatch.Groups["n"].Value; + } + + var targetTree = release.TargetCommitish; + var isDefaultTree = PluginVersion.IsDefaultTree(targetTree); + var tag = release.TagName; + + var version = isDefaultTree + ? ParseFullVersion(tag) + : new PluginVersion(ParseVersionFromTag(tag), targetTree); + + var actualTree = isDefaultTree ? null : targetTree; + + var asset = release.Assets.FirstOrDefault(a => + a.Name.Contains($"{Framework}.zip", StringComparison.OrdinalIgnoreCase)); if (asset == null) { - _logger.Warn($"No plugin package found for {framework} for {name}"); + _logger.Warn($"No asset found matching {Framework}.zip for {name}"); return null; } + var githubUrl = $"https://github.com/{owner}/{name}" + (actualTree != null ? $"/tree/{actualTree}" : ""); + + _logger.Info($"Found plugin {owner}/{name} v{version} from tree '{actualTree ?? "default"}' with asset {asset.Name}"); return new RemotePlugin { - GithubUrl = repoUrl, + GithubUrl = githubUrl, Name = name, Owner = owner, Version = version, - PackageUrl = asset.BrowserDownloadUrl + PackageUrl = asset.BrowserDownloadUrl, + Tree = actualTree }; } catch (Exception ex) { - _logger.Warn(ex, "Unable to get remote plugin information for {0}", repoUrl); + _logger.Error(ex, $"Failed to get remote plugin for {input}"); return null; } } @@ -106,30 +131,128 @@ public List GetInstalledPlugins() try { var remote = GetRemotePlugin(plugin.GithubUrl); - if (remote != null) - { - plugin.AvailableVersion = remote.Version; - } + plugin.AvailableVersion = remote?.Version ?? new PluginVersion(new Version(0, 0, 0, 0), "unavailable"); } catch (Exception ex) { - _logger.Warn(ex, "Unable to check for updates for plugin {0}/{1}", plugin.Owner, plugin.Name); + _logger.Warn(ex, $"Unable to check updates for {plugin.Owner}/{plugin.Name}"); + plugin.AvailableVersion = new PluginVersion(new Version(0, 0, 0, 0), "unavailable"); } } return _installedPlugins; } - private bool IsSupported(Release release) + public (string Owner, string Name, string Tree) ParseRepositoryInput(string input) { - var match = MinVersionRegex.Match(release.Body); - if (match.Success) + if (string.IsNullOrWhiteSpace(input)) { - var minVersion = Version.Parse(match.Groups["version"].Value); - return minVersion <= BuildInfo.Version; + _logger.Error("Repository input cannot be empty"); + return default; } - return true; + input = input.Trim(); + + string owner = null; + string name = null; + string tree = null; + + var urlMatch = GitHubUrlRegex().Match(input); + if (urlMatch.Success) + { + name = urlMatch.Groups["n"].Value; + return ( + urlMatch.Groups["owner"].Value, + name.EndsWith(".git", StringComparison.OrdinalIgnoreCase) ? name[..^4] : name, + urlMatch.Groups["tree"].Success ? urlMatch.Groups["tree"].Value : null); + } + + var nameTokens = new List(); + + foreach (var token in input.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = TagSplitterRegex().Split(token).Where(p => !string.IsNullOrWhiteSpace(p)).ToArray(); + + for (var i = 0; i < parts.Length; i++) + { + switch (parts[i]) + { + case "@" when i + 1 < parts.Length: + owner = parts[++i]; + break; + case "#" when i + 1 < parts.Length: + tree = parts[++i]; + break; + case not "@" and not "#": + nameTokens.Add(parts[i]); + break; + } + } + } + + name = nameTokens.Count > 0 ? string.Concat(nameTokens).Trim() : null; + + if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(name)) + { + return default; + } + + return (owner, name, tree); } + + private static bool IsMatchingTree(string targetTree, string requestedTree) => + requestedTree == null + ? PluginVersion.IsDefaultTree(targetTree) + : targetTree.Equals(requestedTree, StringComparison.OrdinalIgnoreCase); + + private static bool HasCompatibleAsset(Release release, string framework) + => release.Assets.Any(a => a.Name.Contains($"{framework}.zip", StringComparison.OrdinalIgnoreCase)); + + private static PluginVersion ParseFullVersion(string tag) + { + var match = VersionWithPrereleaseRegex().Match(tag.TrimStart('v')); + return match.Success + ? new PluginVersion( + Version.Parse(match.Groups["version"].Value), + match.Groups["prerelease"].Success ? match.Groups["prerelease"].Value : null) + : new PluginVersion(ParseVersionFromTag(tag)); + } + + private static Version ParseVersionFromTag(string tag) + { + var match = VersionOnlyRegex().Match(tag.TrimStart('v')); + return match.Success && Version.TryParse(match.Groups[1].Value, out var version) + ? version + : new Version(0, 0, 0); + } + + private bool MeetsMinimumVersion(string body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return true; + } + + var match = MinimumVersionRegex().Match(body); + return !match.Success || Version.Parse(match.Groups["version"].Value) <= BuildInfo.Version; + } + + [GeneratedRegex(@"^https?://github\.com/(?[^/]+)/(?[^/\s]+)(?:/tree/(?[^/\s]+))?(?:\.git)?/?$", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex GitHubUrlRegex(); + + [GeneratedRegex(@"([@#])", RegexOptions.Compiled)] + private static partial Regex TagSplitterRegex(); + + [GeneratedRegex(@"github\.com/(?[^/]+)/(?[^/]+)/", RegexOptions.Compiled)] + private static partial Regex RepoUrlExtractorRegex(); + + [GeneratedRegex(@"Minimum\s+Lidarr\s+Version:?\s*(?:\*\*)?[\s]*(?\d+\.\d+\.\d+\.\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private partial Regex MinimumVersionRegex(); + + [GeneratedRegex(@"^(?\d+\.\d+\.\d+(?:\.\d+)?)(?:-(?.+))?$", RegexOptions.Compiled)] + private static partial Regex VersionWithPrereleaseRegex(); + + [GeneratedRegex(@"^(\d+\.\d+\.\d+(?:\.\d+)?)", RegexOptions.Compiled)] + private static partial Regex VersionOnlyRegex(); } } diff --git a/src/NzbDrone.Core/Plugins/PluginVersion.cs b/src/NzbDrone.Core/Plugins/PluginVersion.cs new file mode 100644 index 000000000..00272f753 --- /dev/null +++ b/src/NzbDrone.Core/Plugins/PluginVersion.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.Plugins +{ + public class PluginVersion : IComparable, IEquatable + { + private static readonly IReadOnlyCollection DefaultTreeNames = new[] { "master", "main" }; + + public Version BaseVersion { get; } + public string Suffix { get; } + public bool HasSuffix => Suffix != null; + + public PluginVersion(Version version, string suffix = null) + { + BaseVersion = version ?? throw new ArgumentNullException(nameof(version)); + Suffix = CleanSuffix(suffix); + } + + public static bool IsDefaultTree(string treeName) => + DefaultTreeNames.Contains(treeName, StringComparer.OrdinalIgnoreCase); + + public static PluginVersion Parse(string versionString) + { + if (string.IsNullOrWhiteSpace(versionString)) + { + return null; + } + + var parts = versionString.TrimStart('v').Split('-', 2); + return Version.TryParse(parts[0], out var version) ? new PluginVersion(version, parts.Length > 1 ? parts[1] : null) : null; + } + + public override string ToString() => + HasSuffix ? $"{BaseVersion}-{Suffix}" : BaseVersion.ToString(); + + public string ToFormattedString() + { + var build = BaseVersion.Build == -1 ? 0 : BaseVersion.Build; + var revision = BaseVersion.Revision == -1 ? 0 : BaseVersion.Revision; + var baseVersionString = $"{BaseVersion.Major}.{BaseVersion.Minor}.{build}.{revision}"; + + return HasSuffix && !IsDefaultTree(Suffix) + ? $"{baseVersionString} ({Suffix})" + : baseVersionString; + } + + public int CompareTo(PluginVersion other) + { + if (other is null) + { + return 1; + } + + var baseComp = BaseVersion.CompareTo(other.BaseVersion); + if (baseComp != 0) + { + return baseComp; + } + + return (Suffix, other.Suffix) switch + { + (null, not null) => 1, + (not null, null) => -1, + _ => string.Compare(Suffix, other.Suffix, StringComparison.OrdinalIgnoreCase) + }; + } + + public bool Equals(PluginVersion other) => + other is not null && + (ReferenceEquals(this, other) || + (BaseVersion.Equals(other.BaseVersion) && + string.Equals(Suffix, other.Suffix, StringComparison.OrdinalIgnoreCase))); + + public override bool Equals(object obj) => + obj is PluginVersion other && Equals(other); + + public override int GetHashCode() => + HashCode.Combine(BaseVersion, Suffix?.ToUpperInvariant()); + + public static bool operator ==(PluginVersion left, PluginVersion right) => + left is null ? right is null : left.Equals(right); + + public static bool operator !=(PluginVersion left, PluginVersion right) => + !(left == right); + + public static bool operator <(PluginVersion left, PluginVersion right) => + left is null ? right is not null : left.CompareTo(right) < 0; + + public static bool operator <=(PluginVersion left, PluginVersion right) => + left is null || left.CompareTo(right) <= 0; + + public static bool operator >(PluginVersion left, PluginVersion right) => + left is not null && left.CompareTo(right) > 0; + + public static bool operator >=(PluginVersion left, PluginVersion right) => + left is null ? right is null : left.CompareTo(right) >= 0; + + public static implicit operator PluginVersion(Version version) => + version is null ? null : new PluginVersion(version); + + public static explicit operator Version(PluginVersion pluginVersion) + => pluginVersion?.BaseVersion; + + private static string CleanSuffix(string suffix) + { + if (string.IsNullOrWhiteSpace(suffix)) + { + return null; + } + + var plusIndex = suffix.IndexOf('+', StringComparison.OrdinalIgnoreCase); + if (plusIndex >= 0) + { + suffix = suffix[..plusIndex]; + } + + return string.IsNullOrWhiteSpace(suffix) ? null : suffix.Trim(); + } + } +}