Improved PluginService with tree support

This commit is contained in:
Meyn 2025-11-04 16:49:04 +01:00 committed by Robin Dadswell
parent 53132987a1
commit f46acd61c8
5 changed files with 334 additions and 81 deletions

View file

@ -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
};
}

View file

@ -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);

View file

@ -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<AssemblyInformationalVersionAttribute>()?
.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; }
}
}

View file

@ -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<IPlugin> GetInstalledPlugins();
}
public class PluginService : IPluginService
public partial class PluginService : IPluginService
{
private static readonly Regex RepoRegex = new Regex(@"https://github.com/(?<owner>[^/]*)/(?<name>[^/]*)", RegexOptions.Compiled);
private static readonly Regex MinVersionRegex = new Regex(@"Minimum Lidarr Version: (?<version>\d+\.\d+\.\d+\.\d+)", RegexOptions.Compiled);
private readonly IHttpClient _httpClient;
private readonly IPlatformInfo _platformInfo;
private readonly List<IPlugin> _installedPlugins;
private readonly Logger _logger;
public PluginService(IHttpClient httpClient,
public PluginService(
IHttpClient httpClient,
IPlatformInfo platformInfo,
IEnumerable<IPlugin> installedPlugins,
Logger logger)
{
_httpClient = httpClient;
_installedPlugins = installedPlugins.ToList();
_platformInfo = platformInfo;
_logger = logger;
_installedPlugins = installedPlugins?.ToList() ?? new List<IPlugin>();
}
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<List<Release>>(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<List<Release>>(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<IPlugin> 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;
}
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<string>();
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/(?<owner>[^/]+)/(?<n>[^/\s]+)(?:/tree/(?<tree>[^/\s]+))?(?:\.git)?/?$", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex GitHubUrlRegex();
[GeneratedRegex(@"([@#])", RegexOptions.Compiled)]
private static partial Regex TagSplitterRegex();
[GeneratedRegex(@"github\.com/(?<owner>[^/]+)/(?<n>[^/]+)/", RegexOptions.Compiled)]
private static partial Regex RepoUrlExtractorRegex();
[GeneratedRegex(@"Minimum\s+Lidarr\s+Version:?\s*(?:\*\*)?[\s]*(?<version>\d+\.\d+\.\d+\.\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private partial Regex MinimumVersionRegex();
[GeneratedRegex(@"^(?<version>\d+\.\d+\.\d+(?:\.\d+)?)(?:-(?<prerelease>.+))?$", RegexOptions.Compiled)]
private static partial Regex VersionWithPrereleaseRegex();
[GeneratedRegex(@"^(\d+\.\d+\.\d+(?:\.\d+)?)", RegexOptions.Compiled)]
private static partial Regex VersionOnlyRegex();
}
}

View file

@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace NzbDrone.Core.Plugins
{
public class PluginVersion : IComparable<PluginVersion>, IEquatable<PluginVersion>
{
private static readonly IReadOnlyCollection<string> 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();
}
}
}